// author: simon-pierre boucher export type ErrorCode = | "ERR_INVALID_URL" | "ERR_SSRF_BLOCKED" | "ERR_UNSUPPORTED_TYPE" | "ERR_TOO_LARGE" | "ERR_UNAUTHORIZED" | "ERR_QUOTA_EXCEEDED" | "ERR_RATE_LIMITED" | "ERR_ROBOTS_DENIED" | "ERR_TARGET_BLOCKED" | "ERR_TARGET_4XX" | "ERR_TARGET_5XX" | "ERR_TIER_TIMEOUT" | "ERR_POOL_EXHAUSTED" | "ERR_DAEMON_DOWN" | "ERR_SAFARI_BUSY" | "ERR_PROFILE_EXPIRED" | "ERR_EXTRACT_FAILED" | "ERR_REDIRECT_LOOP" | "ERR_INTERNAL"; export interface ErrorSpec { readonly http: number; readonly retryable: boolean; readonly message: string; } /** * The single source of truth for the §15 error taxonomy: code → HTTP status, * retry-ability, and a default human message. The API layer reads `http`; the * queue layer reads `retryable`. Do not duplicate this mapping anywhere else. */ export const ERROR_TAXONOMY: Readonly> = { ERR_INVALID_URL: { http: 400, retryable: false, message: "Malformed or unsupported scheme" }, ERR_SSRF_BLOCKED: { http: 400, retryable: false, message: "Resolved to a private or forbidden address" }, ERR_UNSUPPORTED_TYPE: { http: 415, retryable: false, message: "Content type not handled" }, ERR_TOO_LARGE: { http: 413, retryable: false, message: "Exceeded maxSizeBytes" }, ERR_UNAUTHORIZED: { http: 401, retryable: false, message: "Bad or revoked key" }, ERR_QUOTA_EXCEEDED: { http: 402, retryable: false, message: "Plan limit hit" }, ERR_RATE_LIMITED: { http: 429, retryable: true, message: "Rate limited" }, ERR_ROBOTS_DENIED: { http: 403, retryable: false, message: "robots.txt disallows" }, ERR_TARGET_BLOCKED: { http: 502, retryable: true, message: "Target returned a challenge at every tier" }, ERR_TARGET_4XX: { http: 502, retryable: false, message: "Upstream 4xx status" }, ERR_TARGET_5XX: { http: 502, retryable: true, message: "Upstream 5xx status" }, ERR_TIER_TIMEOUT: { http: 504, retryable: true, message: "Render exceeded timeout" }, ERR_POOL_EXHAUSTED: { http: 503, retryable: true, message: "No WebView available in 30s" }, ERR_DAEMON_DOWN: { http: 503, retryable: true, message: "Swift daemon unreachable, circuit open" }, ERR_SAFARI_BUSY: { http: 503, retryable: true, message: "Tier 2 session conflict" }, ERR_PROFILE_EXPIRED: { http: 409, retryable: false, message: "Session profile needs re-auth" }, ERR_EXTRACT_FAILED: { http: 422, retryable: false, message: "Schema produced no fields" }, ERR_REDIRECT_LOOP: { http: 502, retryable: false, message: "Same URL twice in a redirect chain" }, ERR_INTERNAL: { http: 500, retryable: true, message: "Internal error" }, }; export interface TendrilError { readonly code: ErrorCode; readonly message: string; readonly details?: Readonly>; readonly cause?: unknown; } export function tendrilError( code: ErrorCode, overrides?: { message?: string; details?: Record; cause?: unknown }, ): TendrilError { const base: TendrilError = { code, message: overrides?.message ?? ERROR_TAXONOMY[code].message, }; const details = overrides?.details; const cause = overrides?.cause; return { ...base, ...(details !== undefined ? { details } : {}), ...(cause !== undefined ? { cause } : {}), }; } export function httpStatusFor(code: ErrorCode): number { return ERROR_TAXONOMY[code].http; } export function isRetryable(code: ErrorCode): boolean { return ERROR_TAXONOMY[code].retryable; }