mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +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>
61 lines
2.4 KiB
TypeScript
61 lines
2.4 KiB
TypeScript
import type { Context } from 'hono'
|
|
import { Hono } from 'hono'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { AppError, NameConflictError } from '../usecases/ports'
|
|
import { isHandledError, jsonError } from './error-handler'
|
|
import type { Env } from './platform'
|
|
|
|
// Build a real Context so jsonError's c.json / c.set behave as in production.
|
|
async function ctx(): Promise<Context<Env>> {
|
|
let captured!: Context<Env>
|
|
const app = new Hono<Env>()
|
|
app.get('/x', (c) => {
|
|
c.set('errorLog', null)
|
|
captured = c as unknown as Context<Env>
|
|
return c.body(null, 200)
|
|
})
|
|
await app.request('/x')
|
|
return captured
|
|
}
|
|
|
|
describe('jsonError', () => {
|
|
it('renders an AppError as its AIP-193 body + status and records errorLog', async () => {
|
|
const c = await ctx()
|
|
const res = jsonError(c, new AppError(402, 'Insufficient credits', { reason: 'INSUFFICIENT_CREDITS' }))
|
|
expect(res.status).toBe(402)
|
|
expect(await res.json()).toMatchObject({
|
|
error: { status: 'FAILED_PRECONDITION', message: 'Insufficient credits' },
|
|
})
|
|
expect(c.get('errorLog')).toEqual({ reason: 'INSUFFICIENT_CREDITS', message: 'Insufficient credits' })
|
|
})
|
|
|
|
it('renders a mapped domain error with its mapped status + reason', async () => {
|
|
const c = await ctx()
|
|
const res = jsonError(c, new NameConflictError('doc.txt', 'id-1'))
|
|
expect(res.status).toBe(409)
|
|
expect(c.get('errorLog')?.reason).toBe('NAME_CONFLICT')
|
|
})
|
|
|
|
it('renders an unknown error as a generic 500 while logging the full cause chain', async () => {
|
|
const c = await ctx()
|
|
const err = new Error('top') as Error & { cause?: unknown }
|
|
err.cause = new Error('D1_ERROR: disk full')
|
|
const res = jsonError(c, err)
|
|
expect(res.status).toBe(500)
|
|
expect(((await res.json()) as { error: { message: string } }).error.message).toBe('Internal Server Error')
|
|
const log = c.get('errorLog')
|
|
expect(log?.reason).toBe('INTERNAL')
|
|
expect(log?.message).toContain('top')
|
|
expect(log?.message).toContain('D1_ERROR: disk full')
|
|
})
|
|
})
|
|
|
|
describe('isHandledError', () => {
|
|
it('is true for AppError and mapped domain errors, false otherwise', () => {
|
|
expect(isHandledError(new AppError(400, 'x'))).toBe(true)
|
|
expect(isHandledError(new NameConflictError('a', 'b'))).toBe(true)
|
|
expect(isHandledError(new Error('boom'))).toBe(false)
|
|
expect(isHandledError(null)).toBe(false)
|
|
})
|
|
})
|