/** * Classification: a thrown transport error → the {@link InferenceAttemptError} * taxonomy the executor routes on. * * `errors.ts` owns what the router *does* with a classified failure * (`failureDisposition`) and `executor.ts` applies it. This module is the step * before both — the one every host was writing itself. The PRD's split is * "transports classify facts; the executor decides," and shipping only the * decider left each consumer to hand-roll the classifier, where the mistakes * are not obvious: misfile a moderation refusal as a credential failure and the * circuit breaker opens the endpoint for everyone sharing that breaker key — * which, for a host that does not set `credentialScope`, is every tenant on the * process. * * The host keeps the two things that are genuinely its own — recognizing its * error class ({@link AttemptClassification.asTransportFailure}) and any * provider-specific status reading ({@link AttemptClassification.categorizeStatus}). * Neither can live here: the first is a class this package must not import, and * the second is provider-registry knowledge. */ import type { AttemptTarget, HttpFailureCategory, InferenceAttemptError } from "./errors.js"; import type { ProviderId } from "./canonical-model.js"; import type { CompletionDefectKind } from "../completion/defects.js"; /** * What a transport observed, in the vocabulary the taxonomy needs. A host maps * its own error class onto this once. * * `"api"` is the catch-all for a response the provider rejected with a status * the transport did not interpret further; it is the only kind whose routing * behavior depends on {@link TransportFailure.statusCode}. */ export type TransportFailureKind = CompletionDefectKind | "server_error" | "rate_limit" | "no_credits" | "api" | "network"; export interface TransportFailure { readonly kind: TransportFailureKind; /** HTTP status when the failure carried one; null for transport-level faults. */ readonly statusCode: number | null; /** Parsed `Retry-After`, which bounds-extends the breaker cooldown. */ readonly retryAfterMs: number | null; } export interface AttemptClassification { /** * Recognize the host's own transport-error class and describe it. Return * `null` for anything that is not one — those propagate as a client error * rather than burning the plan (see {@link classifyAttemptError}). */ readonly asTransportFailure: (error: unknown) => TransportFailure | null; /** * Provider-specific status → category, consulted before the neutral * {@link categorizeHttpStatus}. Return `null` to fall through to it. * * This exists because status codes are not portable across providers: one * gateway answers 403 for moderation-flagged *input* (request-shaped — a * different provider may accept it, and the endpoint is healthy), where the * neutral mapping reads 403 as a credential failure and opens the circuit * immediately. */ readonly categorizeStatus?: (statusCode: number, providerId: ProviderId) => HttpFailureCategory | null; /** * Recognize a caller-cancellation. Defaults to {@link isAbortByName} — * `error.name === "AbortError"`, which is what `AbortSignal` and `fetch` * produce. * * **Override this if your SDK wraps aborts in its own class.** The one that * bites: `openai`'s `APIUserAbortError` extends its `APIError` and never sets * `name`, so `error.name` is the inherited `"Error"` — it matches neither the * default nor an `instanceof` check you didn't write. Left unrecognized, a * deliberate cancellation classifies as a propagating `client_error`: it * still stops the plan, but it is attributed as a fault rather than a * cancellation, which pollutes failure telemetry and any breaker or retry * accounting keyed off it. * * ```ts * isAbort: (e) => e instanceof OpenAI.APIUserAbortError || isAbortByName(e), * ``` */ readonly isAbort?: (error: unknown) => boolean; } /** * The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection * carries. Exported so a host overriding {@link AttemptClassification.isAbort} * can widen it rather than replace it. */ export declare function isAbortByName(error: unknown): boolean; /** * Map a thrown error onto the attempt-error taxonomy. The original error rides * in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error * classes and messages survive routing untouched. * * Order is deliberate. An abort is checked first: a cancelled call must never * be reclassified as a provider fault, whatever else is true of it. Anything * the host does not recognize as a transport failure is classified * `client_error` — which propagates rather than traverses — because an error * that escaped the transport without becoming one of its own is a programming * defect (a `TypeError`, a validation throw), and burning every provider and * the fallback model retrying a bug wastes a whole plan to arrive at the same * exception. */ export declare function classifyAttemptError(error: unknown, target: AttemptTarget, classification: AttemptClassification): InferenceAttemptError; /** * Parse a `Retry-After` response header into milliseconds, per RFC 9110: either * delta-seconds or an HTTP-date. Returns `null` when absent or unparseable — * the breaker then falls back to its own cooldown, so a header this cannot read * degrades to the default rather than to no cooldown at all. * * **This value is attacker-influenceable and is not bounded here.** It comes * from whatever answered the request — the provider, a gateway, a proxy — and * RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can * ask for a delay of years. Clamp before using it as a delay: * {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that * sleeps on this directly must impose its own bound, or one bad response header * parks an endpoint indefinitely. * * `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain * object, or a `Map` depending on version and runtime. Anything with a `get` * method is asked for the header (covering `Headers` and `Map` without naming * either global, which keeps this portable to runtimes that ship neither); * a plain object is read case-insensitively for the two spellings that occur * in practice. * * @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`. */ export declare function retryAfterMsFromHeaders(headers: unknown, now?: () => number): number | null;