## 27) Error response body shape is inconsistent

This commit is contained in:
2026-04-28 22:28:02 +02:00
parent a2129bb9bd
commit 1e2576af2a
16 changed files with 632 additions and 99 deletions

View File

@@ -10,6 +10,7 @@
* to guarantee type safety at the API boundary.
*/
import { ErrorResponse } from "../types/response";
import { ENDPOINTS } from "./endpoints";
/** Base URL for all API calls. Falls back to `/api` in production. */
@@ -27,15 +28,20 @@ export class ApiError extends Error {
/** Raw response body text as returned by the server. */
public readonly body: string;
/** Parsed error response (if response was a valid ErrorResponse), undefined otherwise. */
public readonly errorResponse: ErrorResponse | undefined;
/**
* @param status - The HTTP status code.
* @param body - The raw response body text.
* @param errorResponse - Parsed ErrorResponse if available.
*/
constructor(status: number, body: string) {
super(`API error ${String(status)}: ${body}`);
constructor(status: number, body: string, errorResponse?: ErrorResponse) {
super(`API error ${String(status)}: ${errorResponse?.detail || body}`);
this.name = "ApiError";
this.status = status;
this.body = body;
this.errorResponse = errorResponse;
}
}
@@ -79,7 +85,7 @@ export function setUnauthorizedHandler(handler: (() => void) | null): void {
* @param url - Fully-qualified URL.
* @param options - Standard `RequestInit` options.
* @returns Parsed JSON response cast to `T`.
* @throws {FetchError} When the request fails or server returns non-2xx status.
* @throws {ApiError} When the request fails or server returns non-2xx status.
*/
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
try {
@@ -100,7 +106,18 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
unauthorizedHandler?.();
}
throw new ApiError(response.status, body);
// Try to parse as ErrorResponse
let errorResponse: ErrorResponse | undefined;
try {
const parsed = JSON.parse(body);
if (parsed && typeof parsed === "object" && "code" in parsed && "detail" in parsed) {
errorResponse = parsed as ErrorResponse;
}
} catch {
// If parsing fails, errorResponse remains undefined
}
throw new ApiError(response.status, body, errorResponse);
}
// 204 No Content — return undefined cast to T.

View File

@@ -19,6 +19,12 @@ export interface ApiErrorPayload {
body: string;
/** User-friendly error message derived from status and body. */
message: string;
/** Machine-readable error code for client-side branching (e.g., "jail_not_found"). */
code?: string;
/** Human-readable error description from the server. */
detail?: string;
/** Optional structured context for the error (e.g., field names, constraint violations). */
metadata?: Record<string, string | number | boolean | null>;
}
/**

View File

@@ -25,3 +25,12 @@ export interface CommandResponse {
/** Whether the command succeeded. */
success: boolean;
}
export interface ErrorResponse {
/** Machine-readable error code for client-side branching (e.g., "jail_not_found", "rate_limit_exceeded"). */
code: string;
/** Human-readable error description for display to users. */
detail: string;
/** Optional structured context for the error (field names, constraint violations, etc.). */
metadata: Record<string, string | number | boolean | null | undefined>;
}