API error contract

This commit is contained in:
adilallo
2026-04-22 19:15:04 -06:00
parent 4d066dad0e
commit 5457d3554b
18 changed files with 717 additions and 117 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { NextRequest, NextResponse } from "next/server";
import { internalError } from "./responses";
import {
getOrCreateRequestId,
logRouteError,
withRequestId,
} from "./requestId";
export interface ApiRouteMeta {
requestId: string;
}
export type ApiHandler<Ctx> = (
request: NextRequest,
ctx: Ctx,
meta: ApiRouteMeta,
) => Promise<NextResponse> | NextResponse;
/**
* Minimal wrapper around a Route Handler that:
*
* - generates or forwards an `x-request-id`,
* - attaches that id to every response (success and error),
* - catches uncaught throws, logs them with the id via `lib/logger`, and
* returns the canonical 500 `internal_error` body.
*
* Pass a `scope` like `"auth.magicLink.request"` so logs are filterable per
* route. Handlers that don't need request-id correlation can skip the
* wrapper.
*/
export function apiRoute<Ctx = undefined>(
scope: string,
handler: ApiHandler<Ctx>,
): (request: NextRequest, ctx: Ctx) => Promise<NextResponse> {
return async (request, ctx) => {
const requestId = getOrCreateRequestId(request);
try {
const res = await handler(request, ctx, { requestId });
return withRequestId(res, requestId);
} catch (err) {
logRouteError(scope, requestId, err);
return withRequestId(internalError(), requestId);
}
};
}
+70
View File
@@ -0,0 +1,70 @@
import type { NextResponse } from "next/server";
import { logger } from "../logger";
export const REQUEST_ID_HEADER = "x-request-id";
const MAX_REQUEST_ID_LENGTH = 128;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
/**
* Returns the incoming `x-request-id` header (sanitized) when present and
* well-formed, otherwise generates a fresh UUID. Sanitization rejects
* oversized values and characters outside `[A-Za-z0-9_.-]` so log lines
* cannot be poisoned by client-controlled input.
*/
export function getOrCreateRequestId(request: Request): string {
const raw = request.headers.get(REQUEST_ID_HEADER);
if (raw) {
const trimmed = raw.trim();
if (
trimmed.length > 0 &&
trimmed.length <= MAX_REQUEST_ID_LENGTH &&
REQUEST_ID_PATTERN.test(trimmed)
) {
return trimmed;
}
}
return crypto.randomUUID();
}
/**
* Attach the request id to a response so callers (and log drains) can
* correlate logs with the response. Returns the same response for chaining.
*/
export function withRequestId<T extends NextResponse>(
res: T,
requestId: string,
): T {
res.headers.set(REQUEST_ID_HEADER, requestId);
return res;
}
interface ErrorLogPayload {
scope: string;
requestId: string;
message: string;
stack?: string;
[key: string]: unknown;
}
/**
* Structured error log including the request id. Use from route handlers
* (and the `apiRoute` wrapper) so support can tie a 5xx back to log lines.
*/
export function logRouteError(
scope: string,
requestId: string,
err: unknown,
extra?: Record<string, unknown>,
): void {
const payload: ErrorLogPayload = {
scope,
requestId,
message: err instanceof Error ? err.message : String(err),
...(extra ?? {}),
};
if (err instanceof Error && err.stack) {
payload.stack = err.stack;
}
logger.error(payload);
}
+78 -3
View File
@@ -1,8 +1,83 @@
import { NextResponse } from "next/server";
/**
* Canonical API error contract for `app/api/**`.
*
* Response body shape: `{ error: { code, message }, details? }`.
*
* Codes are kept intentionally small. Add a new code only when an existing
* one cannot describe the failure; route handlers should not invent codes
* inline (use `errorJson(code, …)` here so the union stays the source of
* truth).
*/
export type ApiErrorCode =
| "db_unavailable"
| "unauthorized"
| "forbidden"
| "not_found"
| "validation_error"
| "invalid_json"
| "payload_too_large"
| "rate_limited"
| "server_misconfigured"
| "mail_failed"
| "internal_error";
export interface ApiErrorBody {
error: { code: ApiErrorCode; message: string };
details?: unknown;
}
interface ErrorOpts {
details?: unknown;
headers?: HeadersInit;
}
export function errorJson(
code: ApiErrorCode,
message: string,
status: number,
opts: ErrorOpts = {},
): NextResponse {
const body: ApiErrorBody = { error: { code, message } };
if (opts.details !== undefined) {
body.details = opts.details;
}
return NextResponse.json(body, { status, headers: opts.headers });
}
export function dbUnavailable(): NextResponse {
return NextResponse.json(
{ error: "Database is not configured (DATABASE_URL)." },
{ status: 503 },
return errorJson(
"db_unavailable",
"Database is not configured (DATABASE_URL).",
503,
);
}
export function unauthorized(message = "Unauthorized"): NextResponse {
return errorJson("unauthorized", message, 401);
}
export function notFound(message = "Not found"): NextResponse {
return errorJson("not_found", message, 404);
}
export function rateLimited(retryAfterMs: number): NextResponse {
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
return errorJson("rate_limited", "Too many requests", 429, {
details: { retryAfterMs },
headers: { "retry-after": String(retryAfterSec) },
});
}
export function serverMisconfigured(
message = "Server misconfiguration",
): NextResponse {
return errorJson("server_misconfigured", message, 500);
}
export function internalError(
message = "Internal server error",
): NextResponse {
return errorJson("internal_error", message, 500);
}