Error Handling Patterns
Overview
Ever Works implements a multi-layered error handling strategy that classifies, normalizes, and routes errors to appropriate handlers. The system distinguishes between HTTP exceptions (passed through directly), domain-specific classified errors (AI credits, provider auth, Git auth, account-level), and unknown errors (normalized to user-friendly messages). Classified errors trigger targeted user notifications to help users resolve issues.
Architecture
Source Files
| File | Purpose |
|---|---|
packages/agent/src/services/utils/error-classification.utils.ts | Error classification and notification routing |
packages/agent/src/services/utils/error.utils.ts | Error normalization and re-throw helper |
packages/agent/src/utils/error.util.ts | Safe error message/stack extraction |
packages/agent/src/facades/base.facade.ts | Facade-level error classes (FacadeError, NoProviderError) |
packages/agent/src/facades/ai.facade.ts | AI-specific error class (AiFacadeError) |
packages/agent/src/pipeline/pipeline-builder.service.ts | Pipeline errors (CircularDependencyError, MissingDependencyError) |
apps/api/src/common/filters/facade-exception.filter.ts | Global filter mapping the FacadeError hierarchy → HTTP status codes |
apps/api/src/common/filters/insufficient-credits.filter.ts | Global filter mapping credit-balance exhaustion → HTTP 402 |
HTTP boundary: the FacadeException filter
Rule of thumb: a service must never let a non-HttpException reach a
controller expecting it to become a meaningful status. Nest's default filter
turns any non-HttpException into a generic 500 (and hides its message).
For domain errors that are caller-actionable — "no provider configured",
"GitHub not connected", "provider not found" — a 500 is wrong: it implies a
server fault when the caller can fix it.
The FacadeError hierarchy (git / deploy / oauth / content-extractor
facades) is mapped to the correct 4xx by a single global exception filter,
FacadeExceptionFilter, registered via APP_FILTER in api.module.ts:
Facade error (by .name) | HTTP | Meaning |
|---|---|---|
NoProviderError, NoGitProviderError, NoDeployProviderError, NoOAuthProviderError, NoContentExtractorProviderError | 409 | No provider enabled for the capability — enable a plugin |
NoGitCredentialsError, NoDeployCredentialsError | 409 | The user hasn't connected the account / added credentials |
ProviderNotFoundError, GitProviderNotFoundError, DeployProviderNotFoundError, OAuthProviderNotFoundError, ContentExtractorProviderNotFoundError | 404 | The named providerId doesn't exist among loaded plugins |
OAuthNotSupportedError | 400 | The resolved plugin doesn't implement the operation |
any other FacadeError (generic *FacadeError wrappers) | 500 | Genuine upstream/internal failure — body stays generic, no message leak |
Design invariants:
- Map by
.name, notinstanceof. The hierarchy is intentionally inconsistent (NoGitProviderError extends GitFacadeError, notNoProviderError). Each class assigns a stablethis.namein its constructor, so the filter keys off that — robust to minification and immune to the class tree. - HTTP-only by construction. A global filter runs only in the HTTP request pipeline. FacadeErrors thrown in BullMQ workers, Trigger.dev tasks, the internal-CLI, or generation pipelines are never touched — so this can't wrongly 4xx a background failure.
- Additive. Controllers that already catch a
FacadeErrorand convert it to anHttpException(e.g.search/screenshot/agent-memory) are unaffected; theirHttpExceptionis not aFacadeError, so@Catch(FacadeError)never sees it. The filter only nets the previously-UNCAUGHT cases. - No info leak on 500. For the unmapped wrappers, the filter returns the
same generic
"Internal server error"body Nest's default filter would — the facade's raw message is surfaced ONLY for the mapped 4xx (those messages are intentional and caller-facing).
Concrete effect. A data-repo operation on a work whose owner has not
connected a git provider (gitFacade.cloneOrPull → NoGitCredentialsError)
now returns 409 instead of a generic 500. This covers taxonomy writes
(categories / tags / collections), comparison generation, community-PR
processing, and POST /api/templates/fork.
HTTP boundary: credit exhaustion → 402
CreditLedgerService.record() rejects a debit that would take the balance
below zero (overdraft off — CREDITS_ALLOW_OVERDRAFT, default false) with
InsufficientCreditsError. It is a plain Error, so before
InsufficientCreditsExceptionFilter existed it reached the client as an
unmapped 500, which the billing/usage PRD §6 forbids.
Credits error (by .name) | HTTP | Body |
|---|---|---|
InsufficientCreditsError | 402 | { statusCode: 402, error: 'InsufficientCredits', message: <constant> } |
| any other name (future subclass) | 500 | Generic "Internal server error" — conservative default, no message leak |
Why 402, not 409. The codebase already answers spend exhaustion with 402:
BudgetExceededException (packages/agent/src/budgets) throws
HttpStatus.PAYMENT_REQUIRED with error: 'BudgetExceeded' when a Work hits
its monthly cap. Credit exhaustion is the same condition ("add money / raise
the cap"), so it reuses that status and the same un-suffixed error code
shape. In the sibling facade filter 409 already means something else — "a
precondition you resolve by CONFIGURING something" (no provider enabled, no
credentials connected) — and overloading it would make the two
indistinguishable to a client. apps/web already treats 402 as the
credits/quota signal.
Why the body is a constant. The thrown error carries userId,
requestedCredits, balanceCredits and a message interpolating the last two.
None of it is echoed: the filter cannot prove the HTTP caller owns the balance
— the signature-authenticated /internal/trigger/remote/call worker RPC route
reaches this filter too. Owners read exact figures from the owner-scoped
GET /api/credits/balance.
Side benefit on the worker path. TriggerInternalApiClient retries 5xx
three times with exponential backoff. An exhausted balance is deterministic, so
the old 500 bought three pointless round-trips; a 402 is terminal for that
client.
Spend paths all raise the typed error. Every credit debit in the platform
funnels through CreditLedgerService.record() (directly, or via
consumeForRun), the only caller of CreditLedgerRepository.recordAtomic.
Both entrypoints are pinned by spec to reject with InsufficientCreditsError
rather than a bare Error, because two consumers key off it: this filter (by
.name) and RunCostSettlementService (by type — it converts exhaustion into
a partial debit plus an AI_CREDITS notification instead of failing the run).
Key Classes
Error Classification System
The classifier examines error messages using keyword matching and returns a typed classification:
export type ErrorClassificationType = 'ai_credits' | 'ai_provider' | 'git_auth' | 'account_level' | 'unknown';
export type ErrorClassification = {
type: ErrorClassificationType;
provider: string;
message: string;
};
export function classifyGenerationError(error: unknown): ErrorClassification {
const message = error instanceof Error ? error.message : String(error);
const errorLower = message.toLowerCase();
if (isAiCreditsError(errorLower)) {
return { type: 'ai_credits', provider: detectAiProvider(errorLower), message };
}
if (isAiProviderError(errorLower)) {
return { type: 'ai_provider', provider: detectAiProvider(errorLower), message };
}
if (isGitAuthError(errorLower)) {
return { type: 'git_auth', provider: detectGitProvider(errorLower), message };
}
if (isAccountLevelError(errorLower)) {
return { type: 'account_level', provider: '', message };
}
return { type: 'unknown', provider: '', message };
}
Detection Functions
Each error type has a dedicated detector that checks for known error message patterns:
function isAiCreditsError(error: string): boolean {
return (
error.includes('insufficient_quota') ||
error.includes('rate_limit') ||
error.includes('quota exceeded') ||
error.includes('credits') ||
error.includes('billing')
);
}
function isAiProviderError(error: string): boolean {
return error.includes('invalid_api_key') || error.includes('authentication') || error.includes('unauthorized');
}
Provider Detection
The system auto-detects which AI or Git provider is involved:
function detectAiProvider(error: string): string {
if (error.includes('openai')) return 'OpenAI';
if (error.includes('anthropic') || error.includes('claude')) return 'Anthropic';
if (error.includes('google') || error.includes('gemini')) return 'Google';
if (error.includes('groq')) return 'Groq';
if (error.includes('ollama')) return 'Ollama';
if (error.includes('openrouter')) return 'OpenRouter';
return 'AI Provider';
}
Error Normalization
Transforms raw errors into user-friendly messages:
export function normalizeGeneratorError(error: any): string {
let message = error?.message || error?.error || String(error);
const lower = message.toLowerCase();
if (lower.includes('not found')) {
return 'Repository not found. Please verify the repository exists.';
}
if (lower.includes('enotfound') || lower.includes('getaddrinfo')) {
return 'Connection failed. Please check your network.';
}
if (lower.includes('timeout') || lower.includes('timedout')) {
return 'Request timed out. Please try again.';
}
if (lower.includes('could not read username')) {
return 'Please reconnect your Git account to continue.';
}
return message;
}
Re-throw as Normalized
A utility that preserves NestJS HttpExceptions and normalizes everything else to BadRequestException:
export function rethrowAsNormalized(
error: unknown,
logger: Logger,
context: string,
extraFields?: Record<string, unknown>
): never {
if (error instanceof HttpException) {
throw error; // Pass through as-is
}
logger.error(`Error ${context}:`, error);
throw new BadRequestException({
status: 'error',
message: normalizeGeneratorError(error),
...extraFields
});
}
Facade Error Hierarchy
export class FacadeError extends Error {
constructor(
message: string,
public readonly operation: string,
public readonly provider?: string,
public readonly cause?: Error
) {
super(message);
}
}
export class NoProviderError extends FacadeError {
constructor(capability: string) {
super(`No ${capability} provider configured or available`, 'getPlugin');
}
}
export class ProviderNotFoundError extends FacadeError {
constructor(providerId: string, capability: string) {
super(`${capability} provider not found: ${providerId}`, 'getPlugin', providerId);
}
}
export class AiFacadeError extends FacadeError {
// AI-specific errors with operation and provider context
}
Pipeline Errors
export class CircularDependencyError extends Error {
constructor(public readonly cycle: string[]) {
super(`Circular dependency detected: ${cycle.join(' -> ')}`);
}
}
export class MissingDependencyError extends Error {
constructor(
public readonly stepId: string,
public readonly missingDependency: string
) {
super(`Step "${stepId}" depends on missing step "${missingDependency}"`);
}
}
Safe Error Extraction
Type-safe utilities for extracting error information from unknown values:
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
export function getErrorStack(error: unknown): string | undefined {
if (error instanceof Error) return error.stack;
return undefined;
}
Configuration
Notification Integration
Classified errors route to the notification service, which creates user-visible alerts:
export async function notifyForClassifiedError(
notificationService: NotificationService,
userId: string,
workId: string,
workName: string,
classification: ErrorClassification
): Promise<void> {
switch (classification.type) {
case 'ai_credits':
await notificationService.notifyAiCreditsDepleted(userId, classification.provider, classification.message);
break;
case 'git_auth':
await notificationService.notifyGitAuthExpired(userId, classification.provider);
break;
// ... other cases
}
}
Code Examples
Using Error Classification in a Service
try {
await this.generateWork(work, user);
} catch (error) {
const classification = classifyGenerationError(error);
await notifyForClassifiedError(this.notificationService, user.id, work.id, work.name, classification);
// Re-throw as normalized HTTP exception
rethrowAsNormalized(error, this.logger, 'generating work');
}
Handling Facade Errors
try {
const result = await this.aiFacade.askJson(prompt, schema, options, facadeOptions);
} catch (error) {
if (error instanceof NoProviderError) {
throw new BadRequestException('No AI provider configured');
}
if (error instanceof AiFacadeError) {
this.logger.warn(`AI error: ${error.operation} - ${error.message}`);
}
throw error;
}
Best Practices
-
Classify before notifying -- always run
classifyGenerationError()to categorize the error and provide actionable user notifications. -
Preserve HttpExceptions -- use
rethrowAsNormalized()which passes NestJS exceptions through untouched while normalizing everything else. -
Use typed error classes -- prefer
FacadeError,AiFacadeError, etc. over genericErrorto carry operation and provider context. -
Never expose internal details --
normalizeGeneratorError()maps internal messages (ENOTFOUND, timeouts) to user-friendly descriptions. -
Always use
getErrorMessage()-- when catchingunknownerrors, use the safe extraction utility instead of casting. -
Log before transforming -- always
logger.error()the original error before re-throwing a normalized version. -
Detect providers from error text -- the
detectAiProvider()anddetectGitProvider()functions enable provider-specific notification messages.