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 |
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.
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.