/** * Managed-client recovery primitives. * * These exist because of one production incident and one line of code: * * ```ts * this.activeRevision ??= this.application_request('/application') * .then(value => value.revision_id); * ``` * * `??=` caches whatever the expression produced — including a *rejected* * promise. One transient timeout during canonical revision discovery therefore * became permanent client state: every later operation awaited the same * rejection, and the only recovery was to construct a new client. A long-lived * `HttpJsDb` never recovered, however healthy the authority became. * * The distinction the fix rests on is between **sharing an in-flight * operation** and **caching its outcome**. Those are different things, and * conflating them is what turned a network blip into a poisoned client: * * ```text * SUCCESS the value is cached, permanently * IN-FLIGHT concurrent callers share one request * FAILURE the in-flight state is cleared, the failure reaches every * caller that was waiting, and the next caller starts fresh * ``` * * Nothing here retries anything on its own. A retry happens when the * application performs another operation, which is deliberate: a client that * retried revision discovery by itself would turn a permanently misconfigured * application into a request storm against the authority. */ /** * Why an operation failed, in the only two categories a client can act on. * * `transient` means the request may succeed later without anything being * fixed: a timeout, a reset connection, a 5xx, a 429. `permanent` means it * will not: bad credentials, a missing grant, an application that is not * configured the way the client thinks it is. The difference decides whether * an automatic reconnect loop should keep trying or stop and wait for someone * to change something. */ export type FailureKind = 'transient' | 'permanent'; export type ManagedFailureClass = 'transient' | 'authentication' | 'authorization' | 'configuration' | 'closed'; /** A failure rendered for an operator, without swallowing the typed original. */ export interface ManagedFailure { kind: FailureKind; failure_class: ManagedFailureClass; name: string; message: string; code?: string; status?: number; at: string; } /** Classify a failure without requiring applications to inspect messages. */ export declare function classifyFailureClass(error: unknown): ManagedFailureClass; /** * Classify a failure as transient or permanent. * * The default is `transient`, and deliberately so: misclassifying a transient * failure as permanent reproduces the incident this module exists to fix, * while misclassifying a permanent one as transient costs a retry the caller * was going to make anyway. The asymmetry is the whole point. */ export declare function classifyFailure(error: unknown): FailureKind; /** Render a failure for the health surface without discarding the original. */ export declare function describeFailure(error: unknown): ManagedFailure; /** What a client knows about a value it discovers once and then caches. */ export type DiscoveryState = 'cached' | 'loading' | 'unavailable'; export interface DiscoverySnapshot { state: DiscoveryState; consecutive_failures: number; last_failure?: ManagedFailure; last_success_at?: string; } /** * A value discovered once, shared while in flight, and never cached when it * fails. * * The three invariants, all of which the incident violated: * * 1. a successful value is cached and never re-fetched; * 2. concurrent callers share one request rather than issuing N; * 3. a failed request leaves **no** cached state, so the next caller starts a * new request — and every caller that was waiting sees the same typed * failure, not a different one each. */ export declare class SingleFlightValue { private cached?; private inFlight?; private failures; private lastFailure?; private lastSuccessAt?; /** The cached value, if discovery has ever succeeded. */ get value(): T | undefined; get state(): DiscoveryState; snapshot(): DiscoverySnapshot; /** * Return the value, discovering it with `load` if necessary. * * `load` runs at most once per in-flight window. It is passed in rather than * held so that this type stays a state machine and nothing else: it knows * how to share and how to forget, and nothing about HTTP. */ get(load: () => Promise): Promise; /** * Adopt a value the caller already has, without a request. * * A revision supplied at construction is as good as one discovered, and the * state should say `cached` rather than pretending nothing is known. */ seed(value: T): void; /** Forget everything, including a successful value. */ reset(): void; } export interface BackoffOptions { /** The first delay's ceiling, in milliseconds. */ base?: number; /** The largest ceiling any delay may have. */ max?: number; /** How much the ceiling grows per consecutive failure. */ factor?: number; /** Injected for tests; defaults to Math.random. */ random?: () => number; } /** * Exponential backoff with a cap and jitter. * * The jitter is **partial**, not full: a delay is drawn uniformly from the * upper half of its ceiling, `[ceiling / 2, ceiling)`. Full jitter — `[0, * ceiling)` — would let the fifth consecutive failure sleep for less than the * first, which makes "increasing after consecutive failures" untrue and * untestable. Partial jitter keeps both properties: every delay is strictly * larger than every delay possible at a lower failure count, and two clients * failing together still separate, because they draw independently. * * The previous behaviour was a flat one-second retry, forever. That is * neither bounded in aggregate nor jittered, so a managed service coming back * after an outage was met by every client it had ever served, in lockstep. */ export declare class BoundedBackoff { private readonly base; private readonly max; private readonly factor; private readonly random; private consecutive; constructor(options?: BackoffOptions); /** How many consecutive failures have been recorded. */ get attempts(): number; /** The ceiling this many consecutive failures in, capped. */ ceiling(attempt?: number): number; /** Draw the next delay and count the failure that caused it. */ next(): number; /** A connection that stayed up long enough to count returns us to base. */ reset(): void; } /** * Sleep, unless the signal aborts first. * * Two properties matter and both were missing: the timer must not hold a Node * process open on its own, and the abort listener must be removed when the * timer fires normally. Without the second, a long-lived subscription * accumulates one listener per reconnect attempt, which is a leak that only * shows up on the clients that have been running longest. */ export declare function cancellableDelay(ms: number, signal: AbortSignal): Promise; /** Where the event-stream connection currently is. */ export type ConnectionState = 'closed' | 'connecting' | 'connected' | 'backoff' | 'failed'; /** Overall authority usability, independent of event-stream lifecycle. */ export type AuthorityAvailabilityState = 'unknown' | 'available' | 'recovering' | 'degraded' | 'closed'; /** * Enough structured state to answer one question: is this client connected, * recovering, or poisoned? * * Deliberately not a monitoring framework. It is the operational surface that * sits beside — never instead of — the typed error the caller already gets: * an application handles `FeltDBServiceError`, an operator reads this. */ export interface ManagedClientHealth { connection_state: ConnectionState; availability_state: AuthorityAvailabilityState; usable: boolean; recovery_possible: boolean; revision_state: DiscoveryState; revision_id?: string; consecutive_failures: number; last_success_at?: string; last_error?: ManagedFailure; next_retry_at?: string; next_retry_in_ms?: number; } //# sourceMappingURL=managed-recovery.d.ts.map