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

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.

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.