Files
zpan/server/lib/http-errors.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

107 lines
3.8 KiB
TypeScript

import {
type CanonicalStatus,
canonicalStatusForHttp,
ERROR_DOMAIN,
ERROR_INFO_TYPE,
ErrorReason,
type ErrorResponse,
} from '@shared/schemas'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import {
BackgroundJobError,
DownloadError,
NameConflictError,
ObjectUploadSessionError,
StorageQuotaExceededError,
WebDavPathError,
} from '../usecases/ports'
// Per-error overrides for the AIP-193 body. `reason` defaults to the canonical
// `status`; `status` defaults to the HTTP-status mapping; `domain` to zpan.dev.
export interface ErrorOptions {
reason?: string
status?: CanonicalStatus
metadata?: Record<string, string>
domain?: string
}
// The single place that builds an AIP-193 (`google.rpc.Status`) error body. Every
// error the API surfaces — `AppError` values and mapped domain errors, all rendered
// by `jsonError` — flows through here, so the wire shape is defined exactly once.
export function buildErrorBody(httpStatus: number, message: string, opts: ErrorOptions = {}): ErrorResponse {
const status = opts.status ?? canonicalStatusForHttp(httpStatus)
const reason = opts.reason ?? status
return {
error: {
code: httpStatus,
message,
status,
details: [
{
'@type': ERROR_INFO_TYPE,
reason,
domain: opts.domain ?? ERROR_DOMAIN,
...(opts.metadata ? { metadata: opts.metadata } : {}),
},
],
},
}
}
export interface DomainErrorMapping {
status: ContentfulStatusCode
/** Plain message for text responses (e.g. WebDAV). */
message: string
/** AIP-193 body for JSON responses. */
json: ErrorResponse
}
const mapping = (status: ContentfulStatusCode, message: string, opts?: ErrorOptions): DomainErrorMapping => ({
status,
message,
json: buildErrorBody(status, message, opts),
})
// Translate a domain error a usecase threw into its HTTP status + AIP-193 body.
// Wired into the global `app.onError`, so handlers `throw` instead of hand-rolling
// per-route try/catch. Returns null for errors we don't translate; `onError` then
// falls back to a generic 500. To support a new domain error: add a branch here.
export function mapDomainError(error: unknown): DomainErrorMapping | null {
if (error instanceof StorageQuotaExceededError) {
return mapping(422, 'Quota exceeded', { reason: ErrorReason.QUOTA_EXCEEDED, status: 'RESOURCE_EXHAUSTED' })
}
if (error instanceof NameConflictError) {
const metadata: Record<string, string> = { conflictingName: error.conflictingName }
if (error.conflictingId) metadata.conflictingId = error.conflictingId
return mapping(409, error.message, { reason: ErrorReason.NAME_CONFLICT, status: 'ALREADY_EXISTS', metadata })
}
if (error instanceof ObjectUploadSessionError) {
if (error.code === 'storage_failure') {
return mapping(502, error.message, { reason: 'STORAGE_FAILURE' })
}
if (error.code === 'not_found') {
return mapping(404, 'Not found')
}
return mapping(409, 'Invalid upload session state', { reason: 'INVALID_STATE' })
}
if (error instanceof WebDavPathError) {
return mapping(error.status as ContentfulStatusCode, error.message)
}
if (error instanceof DownloadError) {
const reason = error.code.toUpperCase()
if (error.code === 'not_found') return mapping(404, 'Not found', { reason })
if (error.code === 'forbidden') return mapping(403, 'Forbidden', { reason })
return mapping(409, error.message, { reason })
}
if (error instanceof BackgroundJobError) {
if (error.code === 'not_cancelable') {
return mapping(409, 'Background job cannot be canceled', { reason: 'NOT_CANCELABLE' })
}
if (error.code === 'not_retryable') {
return mapping(409, 'Background job cannot be retried', { reason: 'NOT_RETRYABLE' })
}
return mapping(404, 'Not found')
}
return null
}