## 27) Error response body shape is inconsistent
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user