mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
8abca2f88c
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>
107 lines
3.8 KiB
TypeScript
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
|
|
}
|