#Scope contract
FILE_ISOLATION: Modify only files inside the scope the task names; report any out-of-scope change instead of making it.
#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 aterror, 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
422and 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
500at 3am on one code path. - Never swallow an error.
catch {}andcatch (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
tsprocess.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
| Failure | Retry |
|---|---|
| Connection reset, DNS failure, timeout | Yes, with backoff |
5xx from a dependency | Yes, with backoff |
429 | Yes, honour Retry-After |
| Database deadlock / serialization failure | Yes, immediately, bounded |
4xx other than 429/408 | No — the request is wrong |
| Non-idempotent write with no idempotency key | No |
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-pattern | Why it fails | Fix |
|---|---|---|
catch {} | Turns a failure into wrong data | Handle or rethrow |
Returning null on error | Caller cannot distinguish absent from failed | Throw, or a result type |
| Error handling in every handler | Inconsistent responses; drifts | One boundary |
| Logging at every catch | Five entries per failure; noise | Log once, at the boundary |
Expected failures logged as error | Alert fatigue; real errors buried | info/warn for domain failures |
| Stack traces in responses | Maps the system for attackers | Generic message + requestId |
| Driver errors surfaced verbatim | Leaks schema and constraint names | Translate to domain errors |
No requestId | Support tickets are unresolvable | Echo one on every response |
Continuing after uncaughtException | Unknown process state | Log and exit |
| Config errors surfacing at request time | Fails at 3am, not at deploy | Validate at startup |
Retrying 4xx | Hammering a permanently invalid request | Retry only transient classes |
| Retries without jitter or a budget | Thundering herd; retry storm | Backoff, jitter, circuit breaker |
| Machine codes changed between releases | Silently breaks client branching | Codes are contract |
#Checklist
- Verify: Expected failures are modelled as typed domain errors
- Verify: One boundary converts errors to responses
- Verify: Every response carries a
requestId - Verify: Unexpected errors log a full trace exactly once, at
error - Verify: Expected failures do not log at
error - Verify: No stack traces, SQL, driver codes or paths reach the client
- Verify: Machine-readable codes are stable; human messages are actionable
- Verify: No empty
catch; every catch handles or rethrows with context - Verify: Configuration is validated at startup and crashes on failure
- Verify:
unhandledRejectionanduncaughtExceptionlog and exit - Verify: Shutdown drains in-flight work with a bounded timeout
- Verify: Retries are limited to transient failures, with backoff and jitter
- Verify: Retried operations are idempotent or carry an idempotency key
- Verify: A circuit breaker protects persistently failing dependencies