Claude Fable 5.1 & GPT-6 Astra packages are live

Error Handling

Free

Error handling in a server — the boundary that converts failures to responses, typed domain errors, retries, and what must never reach the client.

244 lines8.8 KB Claude Backend
targetModels
Claude Fable 5.1Claude Opus 5Claude Sonnet 5Claude 5 FamilyFuture Claude Models
name
error-handling
category
Backend
description
Error handling in a server — the boundary that converts failures to responses, typed domain errors, retries, and what must never reach the client.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for Claude: scripts/model-profiles.json -->

<critical_constraints> FORBIDDEN: Truncating code or writing placeholders such as "// ... existing code ..." or "# rest unchanged". Every edit is complete and applies as written. FORBIDDEN: Reporting a check as passed without showing the command and its output. REQUIRED: Reason through the rules below before the first edit; when two rules conflict, the one stated first wins. </critical_constraints>


#Purpose

Rules for handling errors in a backend service. Two categories, handled completely differently:

  • Expected failures — validation, not found, insufficient funds, conflict. These are part of the domain. Model them, return them, do not log them as errors.
  • Unexpected failures — a null dereference, a dead connection, a bug. These are alerts. They get a generic response and a full trace in the logs.

Conflating the two produces alert fatigue on one side and silent data loss on the other.


#One boundary converts errors to responses

Handlers throw. One place converts.

ts
// Domain layer: throws typed errors, knows nothing about HTTP
class AppError extends Error {
  constructor(readonly code: string, readonly status: number, message: string,
              readonly details?: unknown) { super(message); }
}
class NotFound          extends AppError { constructor(what: string) { super("not_found", 404, `${what} not found`); } }
class ValidationFailed  extends AppError { constructor(details: unknown) { super("validation_failed", 422, "Validation failed", details); } }
class InsufficientFunds extends AppError { constructor() { super("insufficient_funds", 422, "Insufficient funds"); } }

// Edge: the single translation point
app.use((err, req, res, _next) => {
  const requestId = req.id;
  if (err instanceof AppError) {
    req.log.info({ code: err.code, requestId }, "handled failure");
    return res.status(err.status).json({ code: err.code, message: err.message,
                                         details: err.details, requestId });
  }
  req.log.error({ err, requestId }, "unhandled error");     // full stack, once
  res.status(500).json({ code: "internal_error",
                         message: "An unexpected error occurred.", requestId });
});
  • Every response carries a requestId, success or failure. It is what turns a support ticket into a log query. → API/rest
  • Expected failures log at info/warn. Only unexpected ones log at error, so the error rate means something.
  • The handler logs once. Logging at every frame produces five entries for one failure and makes the real trace unfindable.

#Never leak internals

perl
"ER_DUP_ENTRY: Duplicate entry 'a@b.com' for key 'users.email_unique'"
❌ TypeError: Cannot read properties of undefined (reading 'tenantId')
     at OrderService.load (/srv/app/dist/order.js:112:29)
✅ { "code": "email_taken", "message": "That email is already registered.",
     "requestId": "req_01J8Z…" }

Stack traces, SQL fragments, driver error codes, internal hostnames, file paths and library versions all go to the logs and never to the client. They map the system for an attacker and mean nothing to a legitimate caller. → API/api-security

Be careful that framework defaults do not do this for you — many development error pages ship enabled if NODE_ENV is not set correctly in the container.


#Fail fast, and fail at the boundary

  • Validate input at the edge, before any business logic. A parse that fails should fail immediately with a 422 and a field list, not three layers deep. → Backend/validation
  • Validate configuration at startup. A missing environment variable should crash the process at boot, not produce a 500 at 3am on one code path.
  • Never swallow an error. catch {} and catch (e) { return null } convert a failure into wrong data. If you catch, either handle it meaningfully or rethrow with context.
  • Add context when rethrowing, and preserve the original: throw new AppError("charge_failed", 502, "…", { cause: err }).
  • Prefer a returned result type over exceptions for genuinely expected outcomes in hot paths — but be consistent; a codebase that does both randomly is worse than either.

#Process-level safety

ts
process.on("unhandledRejection", (reason) => { log.fatal({ reason }); shutdown(1); });
process.on("uncaughtException",  (err)    => { log.fatal({ err });    shutdown(1); });

After an uncaught exception the process state is unknown. Log, then exit — let the supervisor restart a clean process. Continuing serves requests from corrupted state.

Shutdown must be graceful: stop accepting new connections, finish in-flight requests with a bounded timeout, close the database pool, then exit. → DevOps/deployment


#Transient failures and retries

FailureRetry
Connection reset, DNS failure, timeoutYes, with backoff
5xx from a dependencyYes, with backoff
429Yes, honour Retry-After
Database deadlock / serialization failureYes, immediately, bounded
4xx other than 429/408No — the request is wrong
Non-idempotent write with no idempotency keyNo

Retries need exponential backoff with jitter, a bounded attempt count, and a budget so a struggling dependency is not retried into collapse. Add a circuit breaker for a dependency that fails persistently — retrying a dead service turns its outage into yours. → System Design/resilience

Any operation that is retried must be idempotent, or carry an idempotency key. → API/webhooks


#Errors are a product surface

The message a user sees is part of the product. Say what happened, and what to do next.

arduino
"Error 422""Invalid input""Card declined by your bank. Try a different card or contact your bank."

Keep the machine code stable across releases — clients branch on it — while the human message stays free to improve.


#Anti-patterns

Anti-patternWhy it failsFix
catch {}Turns a failure into wrong dataHandle or rethrow
Returning null on errorCaller cannot distinguish absent from failedThrow, or a result type
Error handling in every handlerInconsistent responses; driftsOne boundary
Logging at every catchFive entries per failure; noiseLog once, at the boundary
Expected failures logged as errorAlert fatigue; real errors buriedinfo/warn for domain failures
Stack traces in responsesMaps the system for attackersGeneric message + requestId
Driver errors surfaced verbatimLeaks schema and constraint namesTranslate to domain errors
No requestIdSupport tickets are unresolvableEcho one on every response
Continuing after uncaughtExceptionUnknown process stateLog and exit
Config errors surfacing at request timeFails at 3am, not at deployValidate at startup
Retrying 4xxHammering a permanently invalid requestRetry only transient classes
Retries without jitter or a budgetThundering herd; retry stormBackoff, jitter, circuit breaker
Machine codes changed between releasesSilently breaks client branchingCodes are contract

#Checklist

  • Expected failures are modelled as typed domain errors
  • One boundary converts errors to responses
  • Every response carries a requestId
  • Unexpected errors log a full trace exactly once, at error
  • Expected failures do not log at error
  • No stack traces, SQL, driver codes or paths reach the client
  • Machine-readable codes are stable; human messages are actionable
  • No empty catch; every catch handles or rethrows with context
  • Configuration is validated at startup and crashes on failure
  • unhandledRejection and uncaughtException log and exit
  • Shutdown drains in-flight work with a bounded timeout
  • Retries are limited to transient failures, with backoff and jitter
  • Retried operations are idempotent or carry an idempotency key
  • A circuit breaker protects persistently failing dependencies