Files
zpan/server/middleware/error-handler.ts
T
Jasper Van 8abca2f88c refactor(errors)!: unify error handling on typed AppError + single jsonError renderer (#445)
Collapse the two error conventions (string-reason `{ok:false,reason}` outcomes
and thrown domain-error classes) onto one. Usecases now produce typed `AppError`
values via factories (`notFound()`/`quotaExceeded()`/`featureBlocked()`/…);
handlers `throw result.error`; and `jsonError` (renamed from `renderError`) is the
single place that renders any error to an AIP-193 body + access-log line, in
`app.onError`/accessLog.

Why: the previous setup had a string→code mapping (`outcomeError` + the `OUTCOME`
table) living in parallel with a type→code mapping (`mapDomainError`), plus inline
`apiError(c, <status>, …)` calls that hand-wrote the status at every site — exactly
the drift that left the same `quota_exceeded` at 400 in one handler and 422 in the
rest. Now the status/reason live once, in the factory.

- Add `server/usecases/ports/app-error.ts`: `AppError` + factories. Status/reason
  are baked in per factory, so no usecase or handler writes an HTTP code or a
  magic-string reason. `AppError` also carries optional response headers
  (`Retry-After`) via a `rateLimited()` factory.
- Delete `apiError`, `outcomeError`, the `OUTCOME` table, and the dead `ApiError`
  class. The 67 inline guard/middleware `apiError` sites became `throw <factory>()`.
- Control-flow outcomes a handler branches on (not just renders) stay discriminated
  reasons (e.g. `deleteObject` `not_trashed`); internal shared sub-usecases
  (traffic-metering, licensing internals) keep string reasons, mapped at the boundary.
- Regenerate the Go OpenAPI client (saveShare gained a 422 response).

BREAKING CHANGE: POST /shares/{token}/objects quota rejection now returns 422
(was an inconsistent 400); every other quota path already returned 422.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:47:59 -04:00

47 lines
2.1 KiB
TypeScript

import type { Context } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { formatError } from '../lib/errors'
import { buildErrorBody, mapDomainError } from '../lib/http-errors'
import { AppError } from '../usecases/ports'
import type { Env } from './platform'
// Render a business error as its AIP-193 JSON response, and stash reason + message
// on the context for the access log. THE one place errors become responses: usecases
// return `AppError` values, handlers `throw result.error`, and the accessLog boundary
// + `app.onError` pass every thrown error through here.
//
// An `AppError` carries its own status/reason/message/headers (built once by the
// factories in usecases/ports/app-error). Legacy domain errors are still translated
// by `mapDomainError`. Anything else is an unexpected failure: the client gets a
// generic 500 while the full `cause` chain goes only to `errorLog` → the access log.
export function jsonError(c: Context<Env>, err: unknown): Response {
if (err instanceof AppError) {
const body = buildErrorBody(err.httpStatus, err.message, {
reason: err.meta.reason,
status: err.meta.canonicalStatus,
metadata: err.meta.metadata,
})
c.set('errorLog', { reason: body.error.details?.[0]?.reason ?? body.error.status, message: err.message })
return c.json(body, err.httpStatus as ContentfulStatusCode, err.meta.headers)
}
const mapped = mapDomainError(err)
if (mapped) {
c.set('errorLog', {
reason: mapped.json.error.details?.[0]?.reason ?? mapped.json.error.status,
message: mapped.message,
})
return c.json(mapped.json, mapped.status)
}
const detail = formatError(err)
c.set('errorLog', { reason: 'INTERNAL', message: detail })
return c.json(buildErrorBody(500, 'Internal Server Error', { reason: 'INTERNAL' }), 500)
}
// True when `jsonError` would translate `err` into a specific (non-500) result.
// Lets `app.onError` log only genuinely unhandled errors as `http.unhandled_error`.
export function isHandledError(err: unknown): boolean {
return err instanceof AppError || mapDomainError(err) !== null
}