Files
accounted/lib/api/v1/errors.ts
T
Jakob Wennberg 3447da027a feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill

EndpointDefinition.example is required and every one of the 125 v1 endpoints
populates example.response, but generateOpenApiSpec() never emitted it. The
examples reached only the docs markdown builder, so /api/v1/openapi.json
carried none and the generated skills/accounted-api had zero json blocks in
all 12 reference files: every agent reading the spec or installing the skill
got schemas with no concrete body.

Emit example on the application/json media types (request body and 200
response) and teach the portable renderOperationMd to print it as a fenced
json block. 178 worked examples now reach the skill. SKILL.md is unchanged:
the examples land in the on-demand reference files, not the entry file.

Attached to JSON media types only, so a multipart body and a binary
application/pdf response do not advertise an example they cannot send.

Adds the one missing example.request (currency-revaluation) so the new
exhaustive coverage assertions hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): emit Retry-After on a v1 429 so the documented contract is real

The published accounted-api skill has told agents to honor Retry-After on a
429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth
failure path early-returns through v1ErrorResponseFromCode, whose finalize()
set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to
pace against and had to back off blindly.

60 seconds is an exact upper bound rather than a guess: the rate limiter is a
fixed one-minute tumbling window per key row and the limited branch does not
slide it. The value moves into an exported constant next to that limiter, so
the MCP server's hardcoded '60' now reads from the same place.

Also corrects the withApiV1 doc comment, which claimed step 8 stamps
X-RateLimit-Limit. It never did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): guard the tools/list payload for the namespace new installs get

The payload ratchet only ever serialized the gnubok_* projection. The
accounted_* projection is inherently larger (every tool reference gains 3
chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP
installs at exactly that namespace, so the payload a new user's client
receives was never measured. It had already drifted ~90 tokens past the
63.4K ceiling while the guarded number sat comfortably under it.

Measure both and assert on the larger. The ceiling moves to 63.6K to cover
the real worst case; this buys no new catalog surface. A second test pins the
direction of the delta so Math.max cannot silently stop describing reality.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:35:47 +02:00

187 lines
6.1 KiB
TypeScript

/**
* v1 REST error envelope.
*
* Wraps the existing structured-error machinery (lib/errors/get-structured-error)
* into the v1-specific shape that agents consume:
*
* {
* error: {
* code: machine-readable, stable forever
* message: Swedish prose
* message_en: English prose (agents prefer this)
* details: structured context (pgCode, field issues, period_id...)
* recovery_hint: natural-language next step the agent can act on
* docs_url: canonical error-doc URL
* valid_alternatives: hints like { unlock_endpoint, next_open_period, ...}
* request_id: correlation id, echoed in X-Request-Id header
* }
* }
*
* The first three fields exist on the legacy `getStructuredError` output.
* `recovery_hint`, `docs_url`, `valid_alternatives` are additive: derived from
* the registry's `remediation` block (when present) plus a per-code doc-URL
* derivation rule.
*/
import { NextResponse } from 'next/server'
import {
errorResponse as legacyErrorResponse,
errorResponseFromCode as legacyErrorResponseFromCode,
} from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import type { Logger } from '@/lib/logger'
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
const DOCS_BASE = process.env.NEXT_PUBLIC_APP_URL
? `${process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '')}/docs/api/errors`
: '/docs/api/errors'
export interface V1ErrorBody {
error: {
code: string
message: string
message_en?: string
details?: unknown
recovery_hint?: string
docs_url?: string
valid_alternatives?: Record<string, unknown>
request_id?: string
}
}
export interface V1ErrorContext {
requestId: string
/** Extra structured context for the agent (period_id, customer_id, ...). */
details?: unknown
/** Override the http status from the registry entry. */
status?: number
/** Agent-actionable next-step suggestions: { unlock_endpoint, next_open_period }. */
validAlternatives?: Record<string, unknown>
/**
* Seconds to advertise in `Retry-After`. Set on retryable throttles so an
* unattended client can pace itself instead of backing off blindly.
*/
retryAfterSeconds?: number
}
function docsUrlFor(code: string): string {
return `${DOCS_BASE}/${code}`
}
/**
* Transform a legacy error envelope from `errorResponse()` into the v1 shape.
*
* The legacy shape is:
* { error: { code, message, message_en?, remediation?, requestId?, details? } }
*
* v1 needs:
* { error: { code, message, message_en?, details?, recovery_hint?, docs_url, valid_alternatives?, request_id? } }
*
* The remediation.description becomes recovery_hint; docs_url is derived from
* the code; valid_alternatives is passed through unchanged.
*/
async function rewriteEnvelope(
legacyResponse: NextResponse,
ctx: V1ErrorContext,
): Promise<NextResponse> {
const status = ctx.status ?? legacyResponse.status
const body = (await legacyResponse.json().catch(() => null)) as
| { error: { code: string; message: string; message_en?: string; remediation?: { description?: string }; details?: unknown } }
| null
if (!body?.error) {
// Should never happen: legacyErrorResponse always returns the envelope.
const fallback: V1ErrorBody = {
error: {
code: 'INTERNAL_ERROR',
message: 'Ett oväntat serverfel uppstod. Försök igen senare.',
message_en: 'Internal server error.',
docs_url: docsUrlFor('INTERNAL_ERROR'),
request_id: ctx.requestId,
},
}
return finalize(NextResponse.json(fallback, { status }), ctx)
}
const { code, message, message_en, remediation, details } = body.error
const v1Body: V1ErrorBody = {
error: {
code,
message,
...(message_en ? { message_en } : {}),
...(details !== undefined ? { details } : {}),
...(remediation?.description ? { recovery_hint: remediation.description } : {}),
docs_url: docsUrlFor(code),
...(ctx.validAlternatives ? { valid_alternatives: ctx.validAlternatives } : {}),
request_id: ctx.requestId,
},
}
return finalize(NextResponse.json(v1Body, { status }), ctx)
}
function finalize(res: NextResponse, ctx: V1ErrorContext): NextResponse {
res.headers.set('X-Request-Id', ctx.requestId)
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
// The published skill tells agents to honor Retry-After on a 429. Until
// this landed, /api/v1 never sent one, so that instruction pointed at a
// header that did not exist.
if (ctx.retryAfterSeconds !== undefined) {
res.headers.set('Retry-After', String(ctx.retryAfterSeconds))
}
return res
}
/**
* v1 error response from a thrown value. Dispatches through the legacy
* machinery for code resolution, then rewrites into the v1 shape.
*
* Always logs the underlying error; never throws.
*/
export async function v1ErrorResponse(
err: unknown,
log: Logger,
ctx: V1ErrorContext,
): Promise<NextResponse> {
const legacy = legacyErrorResponse(err, log, {
requestId: ctx.requestId,
details: ctx.details,
status: ctx.status,
})
return rewriteEnvelope(legacy, ctx)
}
/**
* v1 error response from a known code (no thrown value involved).
*
* Use this when the route already knows the failure mode:
*
* return v1ErrorResponseFromCode('PERIOD_LOCKED', log, {
* requestId: ctx.requestId,
* details: { period_id, locked_at },
* validAlternatives: { unlock_endpoint: '/v1/.../fiscal-periods/:id:unlock' },
* })
*/
export async function v1ErrorResponseFromCode(
code: string,
log: Logger,
ctx: V1ErrorContext & { reason?: string },
): Promise<NextResponse> {
const legacy = legacyErrorResponseFromCode(code, log, {
requestId: ctx.requestId,
details: ctx.details,
status: ctx.status,
reason: ctx.reason,
})
return rewriteEnvelope(legacy, ctx)
}
/**
* Quick check: does this code map to a registered entry? Used by callers that
* want to validate a code before throwing it (e.g. registry-driven dispatch).
*/
export function isRegisteredV1Code(code: string): boolean {
return getErrorEntry(code) !== undefined
}