Skip to main content

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

FilePurpose
packages/agent/src/services/utils/error-classification.utils.tsError classification and notification routing
packages/agent/src/services/utils/error.utils.tsError normalization and re-throw helper
packages/agent/src/utils/error.util.tsSafe error message/stack extraction
packages/agent/src/facades/base.facade.tsFacade-level error classes (FacadeError, NoProviderError)
packages/agent/src/facades/ai.facade.tsAI-specific error class (AiFacadeError)
packages/agent/src/pipeline/pipeline-builder.service.tsPipeline errors (CircularDependencyError, MissingDependencyError)
apps/api/src/common/filters/facade-exception.filter.tsGlobal filter mapping the FacadeError hierarchy → HTTP status codes
apps/api/src/common/filters/insufficient-credits.filter.tsGlobal 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)HTTPMeaning
NoProviderError, NoGitProviderError, NoDeployProviderError, NoOAuthProviderError, NoContentExtractorProviderError409No provider enabled for the capability — enable a plugin
NoGitCredentialsError, NoDeployCredentialsError409The user hasn't connected the account / added credentials
ProviderNotFoundError, GitProviderNotFoundError, DeployProviderNotFoundError, OAuthProviderNotFoundError, ContentExtractorProviderNotFoundError404The named providerId doesn't exist among loaded plugins
OAuthNotSupportedError400The resolved plugin doesn't implement the operation
any other FacadeError (generic *FacadeError wrappers)500Genuine upstream/internal failure — body stays generic, no message leak

Design invariants:

  • Map by .name, not instanceof. The hierarchy is intentionally inconsistent (NoGitProviderError extends GitFacadeError, not NoProviderError). Each class assigns a stable this.name in 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 FacadeError and convert it to an HttpException (e.g. search / screenshot / agent-memory) are unaffected; their HttpException is not a FacadeError, 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.cloneOrPullNoGitCredentialsError) 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)HTTPBody
InsufficientCreditsError402{ statusCode: 402, error: 'InsufficientCredits', message: <constant> }
any other name (future subclass)500Generic "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

  1. Classify before notifying -- always run classifyGenerationError() to categorize the error and provide actionable user notifications.

  2. Preserve HttpExceptions -- use rethrowAsNormalized() which passes NestJS exceptions through untouched while normalizing everything else.

  3. Use typed error classes -- prefer FacadeError, AiFacadeError, etc. over generic Error to carry operation and provider context.

  4. Never expose internal details -- normalizeGeneratorError() maps internal messages (ENOTFOUND, timeouts) to user-friendly descriptions.

  5. Always use getErrorMessage() -- when catching unknown errors, use the safe extraction utility instead of casting.

  6. Log before transforming -- always logger.error() the original error before re-throwing a normalized version.

  7. Detect providers from error text -- the detectAiProvider() and detectGitProvider() functions enable provider-specific notification messages.