UNPKG

@durable-streams/client

Version:

TypeScript client for the Durable Streams protocol

268 lines (234 loc) 7.32 kB
/** * Fetch utilities with retry and backoff support. * Based on @electric-sql/client patterns. */ import { FetchBackoffAbortError, FetchError } from "./error" /** * HTTP status codes that should be retried. */ const HTTP_RETRY_STATUS_CODES = [429, 503] /** * Options for configuring exponential backoff retry behavior. */ export interface BackoffOptions { /** * Initial delay before retrying in milliseconds. */ initialDelay: number /** * Maximum retry delay in milliseconds. * After reaching this, delay stays constant. */ maxDelay: number /** * Multiplier for exponential backoff. */ multiplier: number /** * Callback invoked on each failed attempt. */ onFailedAttempt?: () => void /** * Enable debug logging. */ debug?: boolean /** * Maximum number of retry attempts before giving up. * Set to Infinity for indefinite retries (useful for offline scenarios). */ maxRetries?: number } /** * Default backoff options. */ export const BackoffDefaults: BackoffOptions = { initialDelay: 100, maxDelay: 60_000, // Cap at 60s multiplier: 1.3, maxRetries: Infinity, // Retry forever by default } /** * Parse Retry-After header value and return delay in milliseconds. * Supports both delta-seconds format and HTTP-date format. * Returns 0 if header is not present or invalid. */ export function parseRetryAfterHeader(retryAfter: string | undefined): number { if (!retryAfter) return 0 // Try parsing as seconds (delta-seconds format) const retryAfterSec = Number(retryAfter) if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) { return retryAfterSec * 1000 } // Try parsing as HTTP-date const retryDate = Date.parse(retryAfter) if (!isNaN(retryDate)) { // Handle clock skew: clamp to non-negative, cap at reasonable max const deltaMs = retryDate - Date.now() return Math.max(0, Math.min(deltaMs, 3600_000)) // Cap at 1 hour } return 0 } /** * Creates a fetch client that retries failed requests with exponential backoff. * * @param fetchClient - The base fetch client to wrap * @param backoffOptions - Options for retry behavior * @returns A fetch function with automatic retry */ export function createFetchWithBackoff( fetchClient: typeof fetch, backoffOptions: BackoffOptions = BackoffDefaults ): typeof fetch { const { initialDelay, maxDelay, multiplier, debug = false, onFailedAttempt, maxRetries = Infinity, } = backoffOptions return async (...args: Parameters<typeof fetch>): Promise<Response> => { const url = args[0] const options = args[1] let delay = initialDelay let attempt = 0 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { try { const result = await fetchClient(...args) if (result.ok) { return result } const err = await FetchError.fromResponse(result, url.toString()) throw err } catch (e) { onFailedAttempt?.() if (options?.signal?.aborted) { throw new FetchBackoffAbortError() } else if ( e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500 ) { // Client errors (except 429) cannot be backed off on throw e } else { // Check max retries attempt++ if (attempt > maxRetries) { if (debug) { console.log( `Max retries reached (${attempt}/${maxRetries}), giving up` ) } throw e } // Calculate wait time honoring server-driven backoff as a floor // Parse server-provided Retry-After (if present) const serverMinimumMs = e instanceof FetchError ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0 // Calculate client backoff with full jitter strategy // Full jitter: random_between(0, min(cap, exponential_backoff)) const jitter = Math.random() * delay const clientBackoffMs = Math.min(jitter, maxDelay) // Server minimum is the floor, client cap is the ceiling const waitMs = Math.max(serverMinimumMs, clientBackoffMs) if (debug) { const source = serverMinimumMs > 0 ? `server+client` : `client` console.log( `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)` ) } // Wait for the calculated duration await new Promise((resolve) => setTimeout(resolve, waitMs)) // Increase the delay for the next attempt (capped at maxDelay) delay = Math.min(delay * multiplier, maxDelay) } } } } } /** * Status codes where we shouldn't try to read the body. */ const NO_BODY_STATUS_CODES = [201, 204, 205] /** * Creates a fetch client that ensures the response body is fully consumed. * This prevents issues with connection pooling when bodies aren't read. * * Uses arrayBuffer() instead of text() to preserve binary data integrity. * * @param fetchClient - The base fetch client to wrap * @returns A fetch function that consumes response bodies */ export function createFetchWithConsumedBody( fetchClient: typeof fetch ): typeof fetch { return async (...args: Parameters<typeof fetch>): Promise<Response> => { const url = args[0] const res = await fetchClient(...args) try { if (res.status < 200 || NO_BODY_STATUS_CODES.includes(res.status)) { return res } // Read body as arrayBuffer to preserve binary data integrity const buf = await res.arrayBuffer() return new Response(buf, { status: res.status, statusText: res.statusText, headers: res.headers, }) } catch (err) { if (args[1]?.signal?.aborted) { throw new FetchBackoffAbortError() } throw new FetchError( res.status, undefined, undefined, Object.fromEntries([...res.headers.entries()]), url.toString(), err instanceof Error ? err.message : typeof err === `string` ? err : `failed to read body` ) } } } /** * Chains an AbortController to an optional source signal. * If the source signal is aborted, the provided controller will also abort. */ export function chainAborter( aborter: AbortController, sourceSignal?: AbortSignal | null ): { signal: AbortSignal cleanup: () => void } { let cleanup = noop if (!sourceSignal) { // no-op, nothing to chain to } else if (sourceSignal.aborted) { // source signal is already aborted, abort immediately aborter.abort(sourceSignal.reason) } else { // chain to source signal abort event const abortParent = () => aborter.abort(sourceSignal.reason) sourceSignal.addEventListener(`abort`, abortParent, { once: true, signal: aborter.signal, }) cleanup = () => sourceSignal.removeEventListener(`abort`, abortParent) } return { signal: aborter.signal, cleanup, } } function noop() {}