diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index 6e93394d3..e0db72ea5 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -294,11 +294,12 @@ Retries are: Callers and tools need no changes: a retried request either resolves normally or throws the same error it would have thrown without retries. -The CLI's transport (`packages/cli/src/lib/sentry-client.ts`) follows the same -shared rule. Only the caps differ (`RETRY_POLICY`): the hosted MCP keeps its -total wait short so a Workers request isn't held open, while the CLI can wait -longer. The CLI also applies its own per-request timeout, which counts as a -network failure. +The CLI sends its requests through the shared transport in +`@sentry/toolkit-core/api-transport`, configured in +`packages/cli/src/lib/sentry-client.ts`. It follows the same shared rule; only +the caps differ (`RETRY_POLICY`): the hosted MCP keeps its total wait short so +a Workers request isn't held open, while the CLI can wait longer. The CLI also +applies its own per-request timeout, which counts as a network failure. ## Best Practices diff --git a/packages/cli/src/lib/fetch-input.ts b/packages/cli/src/lib/fetch-input.ts index 1ee418e74..37d5c2d56 100644 --- a/packages/cli/src/lib/fetch-input.ts +++ b/packages/cli/src/lib/fetch-input.ts @@ -1,19 +1,6 @@ /** - * Helpers for the `input` argument of `fetch()`. + * Helpers for the `input` argument of `fetch()`. The transport owns the + * implementation; this path stays for CLI callers and tests. */ -/** - * Extract the full URL string from a fetch input. - * - * `String(request)` yields `"[object Request]"`, so `Request` inputs need - * their `.url` read explicitly. - */ -export function extractInputUrl(input: Request | string | URL): string { - if (typeof input === "string") { - return input; - } - if (input instanceof URL) { - return input.href; - } - return input.url; -} +export { extractInputUrl } from "@sentry/toolkit-core/api-transport"; diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 426eaa9d5..03a0c7bc1 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -8,21 +8,18 @@ * and its parsing hooks are configured separately in sentry-sdk-client.ts. */ -import { setTimeout as sleepMs } from "node:timers/promises"; import type { SentryClient } from "@sentry/api"; import { getTraceData } from "@sentry/node"; +import type { RetryPolicy } from "@sentry/toolkit-core/api-retry"; import { - isIdempotentRequest, - nextRetryDelayMs, - parseRetryDelayMs, - type RetryPolicy, -} from "@sentry/toolkit-core/api-retry"; + createApiTransport, + extractUrlPath, + getValidatedResponseSource, + type ResponseBodyMode, + type TransportCache, +} from "@sentry/toolkit-core/api-transport"; import { computeInvalidationPrefixes } from "@sentry/toolkit-core/response-cache"; -import { - formatAuthHeader, - normalizeAuthToken, - trimAuthToken, -} from "./auth-header.js"; +import { formatAuthHeader, normalizeAuthToken } from "./auth-header.js"; import { seedCompletions } from "./api/completion-seed.js"; import { maybeWarnEnvTokenIgnored } from "./auth-hint.js"; import { @@ -55,13 +52,12 @@ import { extractInputUrl } from "./fetch-input.js"; import { isIdempotentOperation } from "./idempotent-operations.js"; import { logger } from "./logger.js"; import { - type CacheLookup, clearLastCacheHitAge, invalidateCachedResponsesMatching, lookupCachedResponse, storeCachedResponse, } from "./response-cache.js"; -import { normalizeHttpOrigin, normalizeOrigin } from "./sentry-urls.js"; +import { normalizeOrigin } from "./sentry-urls.js"; import { isManualHttpTracing, withTracingSpan } from "./telemetry.js"; import { parseSntrysClaim } from "./token-claims.js"; import { @@ -109,12 +105,6 @@ const RETRY_POLICY: RetryPolicy = { /** Header to mark requests as retries, preventing infinite token refresh loops */ const RETRY_MARKER_HEADER = "x-sentry-cli-retry"; -/** Stamped on thrown errors caused by our own per-request timeout. */ -const INTERNAL_TIMEOUT_MARKER = Symbol("sentry-cli:internal-timeout"); -const MAX_REDIRECTS = 20; -const responseRequestOrigins = new WeakMap(); -const responseCredentialIdentities = new WeakMap(); -type ResponseBodyMode = "auto" | "buffer" | "stream"; const sdkResponseBodyModes = new WeakMap(); /** @@ -134,69 +124,36 @@ export function registerSdkResponseBodyMode( }); } -function shouldBufferResponse( - response: Response, - mode: ResponseBodyMode, -): boolean { - const contentType = response.headers.get("Content-Type"); - // SSE reconnects and streaming parsers own consumption of their bodies. - if ( - contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream" - ) { - return false; - } - // The SDK's auto parser returns a stream when Content-Type is absent. - return ( - response.ok && (mode === "buffer" || (mode === "auto" && !!contentType)) - ); -} - /** Provenance attached only after manually validated discovery requests. */ export function getResponseRequestOrigin( response: Response, ): string | undefined { - return responseRequestOrigins.get(response); + return getValidatedResponseSource(response)?.origin; } export function getResponseCredentialIdentity( response: Response, ): string | undefined { - return responseCredentialIdentities.get(response); -} - -/** Check if an error is a user-initiated abort */ -function isUserAbort(error: unknown, signal?: AbortSignal | null): boolean { - return ( - error instanceof DOMException && - error.name === "AbortError" && - Boolean(signal?.aborted) - ); + return getValidatedResponseSource(response)?.identity; } /** - * Prepare request headers with auth token and default headers. + * Reject requests whose host the credential is not scoped to, then add the + * CLI's headers. The transport sets Authorization and User-Agent afterwards. * - * Only sets Authorization and User-Agent. Content-Type is intentionally NOT - * set here — callers are responsible for setting it based on their needs: + * Only sets tracing and custom headers. Content-Type is intentionally NOT set + * here — callers are responsible for setting it based on their needs: * - SDK functions set their own Content-Type * - apiRequestToRegion always sends JSON and sets it explicitly * - rawApiRequest may or may not want Content-Type (e.g., string bodies) - * - * When `init` is undefined (the SDK passes only a Request object), headers are - * read from the Request object to preserve Content-Type and other headers set - * by the SDK. Without this, fetch(Request, {headers}) would override the - * Request's headers with our empty headers, stripping Content-Type and causing - * HTTP 415 errors on Node.js (which strictly follows the spec). - * - * The returned Headers instance is intentionally shared and mutated across - * retry attempts (e.g., handleUnauthorized updates the Authorization header - * and sets the retry marker). Do not clone before passing to retry logic. */ function prepareHeaders( - input: Request | string | URL, - init: RequestInit | undefined, - credential: CredentialContext, -): Headers { + headers: Headers, + { + input, + credential, + }: { input: Request | string | URL; credential: CredentialContext }, +): void { // Host-scoping guard (defense in depth). Primary rejection happens at the // URL-arg / rc-shim entry points; this catches any code path that mutated // SENTRY_HOST/SENTRY_URL without going through those guards. @@ -212,8 +169,7 @@ function prepareHeaders( // multiple Sentry instances. The claim is unsigned (see token-claims.ts); // fail-open on parse errors. Uses isHostTrustedForClaim so multi-region // fan-out via the control silo's region URLs still works. - const normalizedToken = normalizeAuthToken(credential.token); - const claimUrl = parseSntrysClaim(normalizedToken)?.url; + const claimUrl = parseSntrysClaim(normalizeAuthToken(credential.token))?.url; if ( claimUrl && !isHostTrustedForClaim(input, claimUrl, credential.identity) @@ -225,17 +181,6 @@ function prepareHeaders( ); } - // When the SDK calls fetch(request) with no init, read headers from the Request - // object to preserve Content-Type. On Node.js, fetch(request, {headers}) replaces - // the Request's headers entirely per spec, so we must carry them forward explicitly. - const sourceHeaders = - init?.headers ?? (input instanceof Request ? input.headers : undefined); - const headers = new Headers(sourceHeaders); - headers.set("Authorization", formatAuthHeader(normalizedToken)); - if (!headers.has("User-Agent")) { - headers.set("User-Agent", getUserAgent()); - } - if (isManualHttpTracing()) { const traceData = getTraceData(); if (traceData["sentry-trace"]) { @@ -253,63 +198,56 @@ function prepareHeaders( input, isRequestOriginTrustedForContext(input, credential), ); +} - return headers; +/** Refresh ahead of expiry, once the token has used most of its lifetime. */ +async function prepareCredential( + credential: CredentialContext, +): Promise { + const shouldRefresh = Boolean( + credential.refreshable && + credential.expiresAt && + getRemainingLifetimeRatio( + credential.expiresAt, + credential.issuedAt, + Date.now(), + ) <= REFRESH_THRESHOLD, + ); + if (!shouldRefresh) { + return credential; + } + const refreshed = await refreshToken({ expectedCredential: credential }); + return Object.freeze({ ...credential, token: refreshed.token }); } /** - * Handle 401 response by refreshing the token. - * @returns true if the token was refreshed and request should be retried + * Refresh the token after a 401. + * @returns the refreshed credential, or undefined to return the 401 */ -async function handleUnauthorized( - headers: Headers, +async function refreshAfterUnauthorized( credential: CredentialContext, -): Promise { - if (headers.get(RETRY_MARKER_HEADER)) { - return false; - } +): Promise { // refreshToken handles the token selection: it refreshes OAuth when OAuth is // the effective auth source, or returns the env token without refresh when // SENTRY_FORCE_ENV_TOKEN is set. If the token can't be refreshed (env token, // no refresh token), `refreshed` is false and the 401 propagates. - let newToken: string; try { const result = await refreshToken({ force: true, expectedCredential: credential, }); - if (!result.refreshed) { - return false; - } - newToken = result.token; + return result.refreshed + ? Object.freeze({ ...credential, token: result.token }) + : undefined; } catch (error) { + // Invalid refreshed credentials must propagate as an auth error, rather + // than being swallowed by the best-effort refresh catch. if (error instanceof MalformedAuthTokenError) { throw error; } log.debug("Token refresh failed after 401", error); - return false; - } - - // Invalid refreshed credentials must propagate as an auth error, rather - // than being swallowed by the best-effort refresh catch above. - headers.set("Authorization", formatAuthHeader(newToken)); - headers.set(RETRY_MARKER_HEADER, "1"); - return true; -} - -/** Link an external abort signal to an AbortController */ -function linkAbortSignal( - signal: AbortSignal | undefined | null, - controller: AbortController, -): void { - if (!signal) { return; } - if (signal.aborted) { - controller.abort(); - return; - } - signal.addEventListener("abort", () => controller.abort(), { once: true }); } /** Resolve the per-request timeout for a URL via {@link ENDPOINT_TIMEOUT_OVERRIDES}. */ @@ -323,114 +261,18 @@ function resolveTimeoutMs(fullUrl: string): number { return REQUEST_TIMEOUT_MS; } -function isInternalTimeout(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - (error as Record)[INTERNAL_TIMEOUT_MARKER] === true - ); -} - -type FetchWithTimeoutArgs = { - input: Request | string | URL; - init: RequestInit | undefined; - headers: Headers; - externalSignal: AbortSignal | undefined | null; - timeoutMs: number; - responseBodyMode: ResponseBodyMode; -}; - -/** - * Execute a single fetch attempt with timeout. If the timeout fires, the - * thrown error is tagged with {@link INTERNAL_TIMEOUT_MARKER} so the retry - * loop can distinguish it from a user abort or network error. - */ -async function fetchWithTimeout({ - input, - init, - headers, - externalSignal, - timeoutMs, - responseBodyMode, -}: FetchWithTimeoutArgs): Promise { - const controller = new AbortController(); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - controller.abort(); - }, timeoutMs); - linkAbortSignal(externalSignal, controller); - - try { - // Spread custom TLS options (CA certs for corporate proxies). - // On Bun, this passes `tls: { ca }` to fetch(); on Node, the - // extra property is harmless (Node honors NODE_EXTRA_CA_CERTS natively). - const customTls = getCustomTlsOptions(); - if (customTls) { - warnIfSaasWithEnvCa(extractInputUrl(input)); - } - - const response = await fetch(input, { - ...init, - headers, - signal: controller.signal, - ...customTls, - }); - if (shouldBufferResponse(response, responseBodyMode)) { - // Drain a clone within this attempt so body failures can retry without - // replacing the original Response or consuming the caller's body. - await response.clone().body?.pipeTo(new WritableStream()); - } - return response; - } catch (error) { - if (timedOut) { - const tagged = ( - error instanceof Error ? error : new Error(String(error)) - ) as Error & { [INTERNAL_TIMEOUT_MARKER]?: true; timeoutMs?: number }; - tagged[INTERNAL_TIMEOUT_MARKER] = true; - tagged.timeoutMs = timeoutMs; - throw tagged; - } - throw error; - } finally { - clearTimeout(timeout); +/** Pass custom TLS options (CA certs for corporate proxies) to each fetch. */ +async function fetchWithCustomTls( + input: Request | string | URL, + init?: RequestInit, +): Promise { + // On Bun, this passes `tls: { ca }` to fetch(); on Node, the extra + // property is harmless (Node honors NODE_EXTRA_CA_CERTS natively). + const customTls = getCustomTlsOptions(); + if (customTls) { + warnIfSaasWithEnvCa(extractInputUrl(input)); } -} - -/** Result of a single fetch attempt - drives the retry loop */ -type AttemptResult = - | { action: "done"; response: Response } - | { action: "retry"; delayMs: number } - | { action: "throw"; error: unknown }; - -/** Per-attempt retry inputs. */ -type AttemptRetryState = { - method: string; - /** Sentry marks the operation idempotent, or the caller opted in. */ - idempotent: boolean; - /** Retries already made for this request, starting at 0. */ - attempt: number; - /** Total time already spent waiting between attempts. */ - waitedMs: number; - /** Request path, for error messages. */ - urlPath: string; -}; - -/** Delay before retrying after `status` (undefined for a network error or timeout). */ -function retryDelayMs( - { method, idempotent, attempt, waitedMs }: AttemptRetryState, - status: number | undefined, - serverDelayMs: number | undefined, -): number | undefined { - return nextRetryDelayMs({ - policy: RETRY_POLICY, - method, - idempotent, - status, - serverDelayMs, - attempt, - waitedMs, - }); + return await fetch(input, { ...init, ...customTls }); } /** Best-effort `detail` from a Sentry error body. */ @@ -446,352 +288,110 @@ async function readErrorDetail( } } -/** - * Decide what to do with a successful HTTP response. - * - * A 401 refreshes the token and retries for any method: the server rejected - * the request before processing it. Other transient statuses retry per the - * shared policy in `@sentry/toolkit-core/api-retry`. A 429 that is not retried throws - * {@link RateLimitError} so the user learns when to try again. - */ -async function handleResponse( - response: Response, - headers: Headers, - credential: CredentialContext, - retryState: AttemptRetryState, -): Promise { - if (response.status === 401 && retryState.attempt < RETRY_POLICY.maxRetries) { - const refreshed = await handleUnauthorized(headers, credential); - if (refreshed) { - await cancelResponseBody(response); - return { action: "retry", delayMs: 0 }; - } - return { action: "done", response }; - } - - const serverDelayMs = parseRetryDelayMs(response.status, response.headers); - const delayMs = retryDelayMs(retryState, response.status, serverDelayMs); - if (delayMs !== undefined) { - await cancelResponseBody(response); - return { action: "retry", delayMs }; - } - if (response.status === 429) { - return { - action: "throw", - error: new RateLimitError( - retryState.urlPath, - serverDelayMs, - await readErrorDetail(response), - ), - }; - } - return { action: "done", response }; -} - -/** - * Decide what to do with a fetch error. User aborts throw immediately; TLS - * cert errors throw immediately with actionable guidance (retrying is - * pointless). Network errors and internal timeouts retry only for retryable - * methods with attempts left: a write may have reached the server, so - * replaying it could apply it twice. - */ -function handleFetchError( - error: unknown, - signal: AbortSignal | undefined | null, - retryState: AttemptRetryState, -): AttemptResult { - if (error instanceof HostScopeError) { - return { action: "throw", error }; - } - if (isUserAbort(error, signal)) { - return { action: "throw", error }; - } - - // TLS certificate errors are deterministic — don't retry - if (isTlsCertError(error)) { - return { - action: "throw", - error: new ApiError( - "TLS certificate error", - 0, - buildTlsErrorDetail(error as Error), - ), - }; - } - - const delayMs = retryDelayMs(retryState, undefined, undefined); - if (isInternalTimeout(error) && delayMs === undefined) { - const timeoutMs = - (error as { timeoutMs?: number }).timeoutMs ?? REQUEST_TIMEOUT_MS; - const seconds = Math.round(timeoutMs / 1000); - return { - action: "throw", - error: new TimeoutError( - `Request timed out after ${seconds}s.`, - isIdempotentRequest(retryState.method, retryState.idempotent) - ? "The Sentry API did not respond in time. Try again; if the problem persists, check https://status.sentry.io." - : "The Sentry API did not respond in time. The change may still have been applied; check before trying again. If the problem persists, check https://status.sentry.io.", - ), - }; - } - if (delayMs === undefined) { - return { action: "throw", error }; - } - return { action: "retry", delayMs }; -} - -/** Extract the URL pathname for logging and cache keys */ -function extractUrlPath(input: Request | string | URL): string { - const raw = extractInputUrl(input); - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - try { - return new URL(raw).pathname; - } catch { - return raw; - } -} - -/** - * Look up a GET request in the response cache: a fresh hit, a stale entry - * to revalidate, or undefined on miss. - * - * @param requestHeaders - Headers that were (or will be) sent with the request, - * needed for correct `Vary` handling in CachePolicy freshness checks. - */ -async function tryCacheLookup( - method: string, - fullUrl: string, - requestHeaders: Record, - identity: string, -): Promise { - if (method !== "GET") { - return; - } - return await lookupCachedResponse(method, fullUrl, requestHeaders, identity); -} - -/** - * Store a successful GET response in the cache (fire-and-forget). - * Clones the response so the original body stream is preserved for the caller. - * - * @param requestHeaders - Headers sent with the request, stored in CachePolicy - * for future `Vary`-aware freshness checks. - */ -function cacheResponse( - request: { - method: string; - fullUrl: string; - requestHeaders: Record; - identity: string; - }, - response: Response, -): void { - const { method, fullUrl, requestHeaders, identity } = request; - if (method !== "GET" || !response.ok) { - return; - } - // Cast needed: Bun extends Response with extra properties (toJSON, count, getAll) - // that .clone() doesn't carry over, but our cache only reads standard Response API - storeCachedResponse( - method, - fullUrl, - requestHeaders, - response.clone() as Response, - identity, - ).catch((error) => { - log.debug("Response cache write failed", error); - }); -} - -/** - * Auto-invalidate cache entries that a successful non-GET mutation - * made stale. Awaited so a subsequent read in the same command sees - * fresh data. Prefix computation: {@link computeInvalidationPrefixes}. - * - * Never throws: a post-mutation housekeeping failure must not convert - * a successful mutation into a caller-visible error. Defense-in-depth - * for future regressions — the helpers we call are already no-throw - * today. - */ -async function invalidateAfterMutation( - method: string, - fullUrl: string, - response: Response, - identity: string, -): Promise { - if (method === "GET" || !response.ok) { - return; - } - try { - const prefixes = computeInvalidationPrefixes(fullUrl, getApiBaseUrl()); +/** The response cache, keyed by the CLI's identity fingerprint. */ +const responseCache: TransportCache = { + lookup: async ({ url, requestHeaders, identity }) => + await lookupCachedResponse("GET", url, requestHeaders, identity), + store: async ({ url, requestHeaders, identity }, response) => + await storeCachedResponse("GET", url, requestHeaders, response, identity), + /** Prefix computation: {@link computeInvalidationPrefixes}. */ + invalidate: async (url, identity) => { + const prefixes = computeInvalidationPrefixes(url, getApiBaseUrl()); await Promise.all( prefixes.map((prefix) => invalidateCachedResponsesMatching(prefix, identity), ), ); - } catch (error) { - log.debug("Post-mutation cache invalidation failed", error); - } -} - -/** Cache metadata must not validate a candidate before OAuth refresh selects a token. */ -function authHeaders(token: string | undefined): Record { - return token ? { authorization: `Bearer ${trimAuthToken(token)}` } : {}; -} - -type AttemptInputFactory = () => { - input: Request | string | URL; - init: RequestInit | undefined; + }, }; -/** - * Build an attempt factory that yields a fresh, body-readable - * `(input, init)` pair per retry. `fetch` consumes a request body on - * attempt 1; without this cloning every retry after a timeout / 5xx / - * 401-refresh would throw `TypeError: Request body already used` (CLI-1D6). - * - * `Request` inputs clone per attempt. Bodies that `fetch` re-reads on - * each call (string / ArrayBuffer / TypedArray / Blob / FormData / - * URLSearchParams / none) pass through unchanged — this is the hot path - * for every non-SDK call site, and importantly preserves the auto- - * negotiated `Content-Type: multipart/form-data; boundary=...` header - * that `fetch` derives from `FormData` bodies (sourcemap chunk upload). - * A bare `ReadableStream` is the only single-use body shape, so it's - * drained once to an `ArrayBuffer`. - */ -async function buildAttemptFactory( - input: Request | string | URL, - init: RequestInit | undefined, -): Promise { - if (input instanceof Request) { - // Cast: Bun's `Request` has extras (toJSON/count/getAll) that `.clone()` - // drops from the DOM type — runtime shape is unaffected. - return () => ({ input: input.clone() as unknown as Request, init }); - } - - const body = init?.body; - if (body instanceof ReadableStream) { - const snapshot = await new Response(body).arrayBuffer(); - return () => ({ input, init: { ...init, body: snapshot } }); - } - - return () => ({ input, init }); +/** Wrap a request in an `http.client` span when the CLI traces HTTP by hand. */ +async function withHttpSpan( + { method, path }: { method: string; path: string }, + run: () => Promise, +): Promise { + if (!isManualHttpTracing()) { + return await run(); + } + return await withTracingSpan( + `${method} ${path}`, + "http.client", + async (span) => { + const response = await run(); + span.setAttribute("http.response.status_code", response.status); + if (!response.ok) { + span.setStatus({ code: 2, message: `${response.status}` }); + } + return response; + }, + { "http.request.method": method, "url.path": path }, + ); } /** - * Authenticate and execute a request with retry logic. - * - * Refreshes the auth token, then retries the request up to - * `RETRY_POLICY.maxRetries` times on transient errors - * (`@sentry/toolkit-core/api-retry` decides which and how long). + * The CLI's transport options: stored credentials, host-scope checks, the + * response cache, and the CLI's error classes. Retries, timeouts, the 401 + * refresh, and redirect validation are in `@sentry/toolkit-core/api-transport`. */ -type RetryContext = { - input: Request | string | URL; - init: RequestInit | undefined; - method: string; - fullUrl: string; - credential: CredentialContext; - validatedRedirects: boolean; - responseBodyMode: ResponseBodyMode; - idempotent: boolean; - /** Validators for a stale cache entry, such as `If-None-Match`. */ - conditionalHeaders?: Record; -}; - -async function fetchWithRetry({ - input, - init, - method, - fullUrl, - credential, - validatedRedirects, - responseBodyMode, - idempotent, - conditionalHeaders = {}, -}: RetryContext): Promise { - const shouldRefresh = Boolean( - credential.refreshable && - credential.expiresAt && - getRemainingLifetimeRatio( - credential.expiresAt, - credential.issuedAt, - Date.now(), - ) <= REFRESH_THRESHOLD, - ); - const refreshed = shouldRefresh - ? await refreshToken({ expectedCredential: credential }) - : { token: credential.token }; - const effectiveCredential: CredentialContext = Object.freeze({ - ...credential, - token: refreshed.token, - }); - const headers = prepareHeaders(input, init, effectiveCredential); - for (const [name, value] of Object.entries(conditionalHeaders)) { - headers.set(name, value); - } - const attemptFactory = await buildAttemptFactory(input, init); - const timeoutMs = resolveTimeoutMs(fullUrl); - const operationIdempotent = - idempotent || isIdempotentOperation(method, fullUrl); - const urlPath = extractUrlPath(fullUrl); - let waitedMs = 0; - - for (let attempt = 0; attempt <= RETRY_POLICY.maxRetries; attempt++) { - const { input: attemptInput, init: attemptInit } = attemptFactory(); - const result = await executeAttempt({ - input: attemptInput, - init: attemptInit, - headers, - credential: effectiveCredential, - retryState: { - method, - idempotent: operationIdempotent, - attempt, - waitedMs, - urlPath, +function createCliTransport(credential: CredentialContext | undefined) { + return createApiTransport({ + credentials: { + current: async () => { + const current = credential ?? getCredentialContext(); + if (!current) { + await refreshToken(); + throw new Error( + "Authentication state was not available after refresh", + ); + } + return current; }, - timeoutMs, - validatedRedirects, - responseBodyMode, - }); - - if (result.action === "done") { - cacheResponse( - { - method, - fullUrl, - requestHeaders: { authorization: headers.get("Authorization") ?? "" }, - identity: credential.identity, - }, - result.response, - ); - await invalidateAfterMutation( - method, - fullUrl, - result.response, - credential.identity, - ); - return result.response; - } - if (result.action === "throw") { - throw result.error; - } - - log.debug( - `${method} ${urlPath} → retry ${attempt + 1}/${RETRY_POLICY.maxRetries} after ${Math.round(result.delayMs)}ms`, - ); - await sleepMs(result.delayMs); - waitedMs += result.delayMs; - } - - // Unreachable: the last attempt always returns 'done' or 'throw' - throw new Error("Exhausted all retry attempts"); + prepare: prepareCredential, + refresh: refreshAfterUnauthorized, + authorization: (current) => formatAuthHeader(current.token), + }, + retry: RETRY_POLICY, + timeoutMs: resolveTimeoutMs, + fetch: fetchWithCustomTls, + userAgent: getUserAgent(), + prepareHeaders, + isIdempotent: isIdempotentOperation, + refreshedRequestHeader: RETRY_MARKER_HEADER, + origins: { + trusts: (url, current) => isRequestOriginTrustedForContext(url, current), + reject: (destination, current) => + new HostScopeError("Redirect destination", destination, current.host), + }, + errors: { + timeout: ({ timeoutMs, idempotent }) => + new TimeoutError( + `Request timed out after ${Math.round(timeoutMs / 1000)}s.`, + idempotent + ? "The Sentry API did not respond in time. Try again; if the problem persists, check https://status.sentry.io." + : "The Sentry API did not respond in time. The change may still have been applied; check before trying again. If the problem persists, check https://status.sentry.io.", + ), + // TLS certificate errors are deterministic — don't retry + fatal: (error) => + isTlsCertError(error) + ? new ApiError( + "TLS certificate error", + 0, + buildTlsErrorDetail(error as Error), + ) + : undefined, + rateLimited: async (response, { path, retryAfterMs }) => + new RateLimitError(path, retryAfterMs, await readErrorDetail(response)), + }, + cache: responseCache, + logger: log, + span: withHttpSpan, + }); } /** * Create a fetch function with authentication, timeout, retry, caching, and 401 refresh. * - * This wraps the native fetch with: + * This wraps the native fetch with `@sentry/toolkit-core/api-transport`: * - **Response caching** for GET requests (checked before hitting the network) * - Auth token injection (Bearer token) * - Request timeout via AbortController @@ -828,6 +428,7 @@ type AuthenticatedFetchOptions = { function createAuthenticatedFetch( options: AuthenticatedFetchOptions = {}, ): (input: Request | string | URL, init?: RequestInit) => Promise { + const transport = createCliTransport(options.credential); const authenticatedFetch = async ( input: Request | string | URL, init?: RequestInit, @@ -845,89 +446,17 @@ function createAuthenticatedFetch( const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - const urlPath = extractUrlPath(input); - - const request = async (): Promise => { - const fullUrl = extractInputUrl(input); - const startTime = performance.now(); - - // Check cache before auth/retry for GET requests. - // Uses current token (no refresh) so lookups are fast but Vary-correct. - const credential = options.credential ?? getCredentialContext(); - if (!credential) { - await refreshToken(); - throw new Error("Authentication state was not available after refresh"); - } - // A synthetic HTTP cache response has no validated final-origin or - // credential provenance. Discovery must obtain both from the network. - const lookup = options.validatedRedirects - ? undefined - : await tryCacheLookup( - method, - fullUrl, - authHeaders(credential.token), - credential.identity, - ); - if (lookup?.kind === "hit") { - log.debug( - `${method} ${urlPath} → ${lookup.response.status} (cache hit, ${(performance.now() - startTime).toFixed(0)}ms)`, - ); - return lookup.response; - } - - const retryContext: RetryContext = { - input, - init, - method, - fullUrl, - credential, - validatedRedirects: options.validatedRedirects === true, - idempotent: options.idempotent === true, - responseBodyMode: - method.toUpperCase() !== "GET" - ? "stream" - : ((input instanceof Request - ? sdkResponseBodyModes.get(input) - : undefined) ?? - (options.bufferResponseBody ? "buffer" : "stream")), - }; - let response = await fetchWithRetry({ - ...retryContext, - conditionalHeaders: lookup?.conditionalHeaders, - }); - if (lookup?.kind === "stale" && response.status === 304) { - const revalidated = await lookup.revalidate(response); - if (revalidated) { - log.debug( - `${method} ${urlPath} → 304 (revalidated cache entry, ${(performance.now() - startTime).toFixed(0)}ms)`, - ); - return revalidated; - } - // A 304 the entry cannot use has no body; fetch it unconditionally. - response = await fetchWithRetry(retryContext); - } - log.debug( - `${method} ${urlPath} → ${response.status} (${(performance.now() - startTime).toFixed(0)}ms)`, - ); - return response; - }; - - if (!isManualHttpTracing()) { - return await request(); - } - return await withTracingSpan( - `${method} ${urlPath}`, - "http.client", - async (span) => { - const response = await request(); - span.setAttribute("http.response.status_code", response.status); - if (!response.ok) { - span.setStatus({ code: 2, message: `${response.status}` }); - } - return response; - }, - { "http.request.method": method, "url.path": urlPath }, - ); + return await transport.request(input, init, { + validateRedirects: options.validatedRedirects === true, + idempotent: options.idempotent === true, + responseBody: + method.toUpperCase() !== "GET" + ? "stream" + : ((input instanceof Request + ? sdkResponseBodyModes.get(input) + : undefined) ?? + (options.bufferResponseBody ? "buffer" : "stream")), + }); }; // Every API response, fresh or cached, seeds shell completion, so no API // function has to remember to. Awaited so a command that exits right @@ -939,245 +468,6 @@ function createAuthenticatedFetch( ); } -/** - * Execute a single fetch attempt and classify the outcome. - */ -type ExecuteAttemptArgs = { - input: Request | string | URL; - init: RequestInit | undefined; - headers: Headers; - credential: CredentialContext; - retryState: AttemptRetryState; - timeoutMs: number; - validatedRedirects: boolean; - responseBodyMode: ResponseBodyMode; -}; - -async function cancelResponseBody(response: Response): Promise { - if (response.body && !response.bodyUsed) { - try { - await response.body.cancel(); - } catch (error) { - log.debug("Failed to cancel unused response body", error); - } - } -} - -function isRedirectStatus(status: number): boolean { - return ( - status === 301 || - status === 302 || - status === 303 || - status === 307 || - status === 308 - ); -} - -function redirectedMethod(method: string, status: number): string { - if (status === 303 && method !== "HEAD") { - return "GET"; - } - if ((status === 301 || status === 302) && method === "POST") { - return "GET"; - } - return method; -} - -function redirectLoopKey(url: URL): string { - const key = new URL(url); - key.hash = ""; - return key.href; -} - -async function rejectRedirect( - response: Response, - destination: string, - credential: CredentialContext, -): Promise { - await cancelResponseBody(response); - throw new HostScopeError( - "Redirect destination", - destination, - credential.host, - ); -} - -async function validatedRedirectTarget({ - response, - request, - credential, - visited, - hops, -}: { - response: Response; - request: Request; - credential: CredentialContext; - visited: Set; - hops: number; -}): Promise { - const location = response.headers.get("location"); - if (!location) { - return await rejectRedirect(response, "", credential); - } - if (hops >= MAX_REDIRECTS) { - return await rejectRedirect(response, "", credential); - } - let next: URL; - try { - next = new URL(location, request.url); - } catch (error) { - log.debug( - "Rejected malformed redirect location", - error instanceof Error ? error.name : "unknown error", - ); - return await rejectRedirect(response, "", credential); - } - if (!isRequestOriginTrustedForContext(next, credential)) { - return await rejectRedirect( - response, - normalizeOrigin(next) ?? "", - credential, - ); - } - const key = redirectLoopKey(next); - if (visited.has(key)) { - return await rejectRedirect(response, "", credential); - } - visited.add(key); - return next; -} - -async function buildRedirectRequest( - request: Request, - requestHeaders: Headers, - next: URL, - status: number, -): Promise<{ request: Request; headers: Headers }> { - const method = redirectedMethod(request.method.toUpperCase(), status); - const headers = new Headers(requestHeaders); - if (method !== request.method.toUpperCase()) { - headers.delete("content-length"); - headers.delete("content-type"); - headers.delete("transfer-encoding"); - } - const body = - method === request.method.toUpperCase() && request.body - ? await request.clone().arrayBuffer() - : undefined; - return { request: new Request(next, { method, headers, body }), headers }; -} - -/** Manually validate every hop before forwarding the pinned bearer and headers. */ -async function fetchFollowingValidatedRedirects({ - input, - init, - headers, - credential, - timeoutMs, - responseBodyMode, -}: Omit< - ExecuteAttemptArgs, - "retryState" | "validatedRedirects" ->): Promise { - const original = new Request(input, init); - const visited = new Set([redirectLoopKey(new URL(original.url))]); - - async function follow( - request: Request, - requestHeaders: Headers, - hops: number, - ): Promise { - if (!isRequestOriginTrustedForContext(request, credential)) { - throw new HostScopeError( - "Credentials", - normalizeOrigin(request) ?? "", - credential.host, - ); - } - const response = await fetchWithTimeout({ - input: request.clone() as unknown as Request, - init: { headers: requestHeaders, redirect: "manual" }, - headers: requestHeaders, - externalSignal: init?.signal ?? original.signal, - timeoutMs, - responseBodyMode, - }); - if (!isRedirectStatus(response.status)) { - const origin = normalizeHttpOrigin(request.url); - if (origin) { - responseRequestOrigins.set(response, origin); - responseCredentialIdentities.set(response, credential.identity); - } - return response; - } - const next = await validatedRedirectTarget({ - response, - request, - credential, - visited, - hops, - }); - - try { - const redirected = await buildRedirectRequest( - request, - requestHeaders, - next, - response.status, - ); - await cancelResponseBody(response); - return await follow(redirected.request, redirected.headers, hops + 1); - } finally { - await cancelResponseBody(response); - } - } - - return await follow(original, new Headers(headers), 0); -} - -async function executeAttempt({ - input, - init, - headers, - credential, - retryState, - timeoutMs, - validatedRedirects, - responseBodyMode, -}: ExecuteAttemptArgs): Promise { - const signal = - init?.signal !== undefined - ? init.signal - : input instanceof Request - ? input.signal - : undefined; - let response: Response; - try { - response = validatedRedirects - ? await fetchFollowingValidatedRedirects({ - input, - init, - headers, - credential, - timeoutMs, - responseBodyMode, - }) - : await fetchWithTimeout({ - input, - init, - headers, - externalSignal: signal, - timeoutMs, - responseBodyMode, - }); - } catch (error) { - return handleFetchError(error, signal, retryState); - } - // Outside the try: response handling errors must not be treated as fetch - // failures (and retried). - return await handleResponse(response, headers, credential, retryState); -} - /** Singleton authenticated fetch instance - reused across all requests */ let cachedFetch: typeof fetch | null = null; diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index d0e22ffc6..448bf62ce 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -12,8 +12,28 @@ field expressions the way the Sentry frontend does, parses search queries with the Sentry frontend grammar, resolves Events endpoint sorts so the sort column is always selected, and deduplicates in-flight region lookups. Each product retains its own credential storage, regional cache identity and persistence, -host allowlists, routing, polling -deadline, HTTP transport, response validation, and user-facing errors. +host allowlists, routing, polling deadline, response validation, and +user-facing errors. + +## API transport + +`api-transport` is the authenticated fetch both products send Sentry API +requests through. `createApiTransport` sets the Authorization header, refreshes +the credential once after a 401 and replays the request (any method, since the +endpoint never ran), times out each attempt, and retries through `api-retry`. +Request bodies are replayed on each attempt, including stream bodies. +With `validateRedirects`, it follows redirects by hand, sends the credential +only to origins the host trusts, and records the final origin for +`getValidatedResponseSource`. With a cache, a GET is looked up first (a stale +entry is revalidated with its `ETag`), a successful GET is stored, and a +successful write invalidates what it made stale. + +The host passes in a `CredentialProvider` (the current credential, an optional +refresh ahead of expiry, and the 401 refresh), header rules that may reject a +request, a `TransportCache`, its error classes for timeouts, rate limits, and +fatal fetch errors such as TLS failures, a logger, and a span hook. The CLI's +`packages/cli/src/lib/sentry-client.ts` wires in its stored credentials, +host-scope checks, disk cache, and telemetry. ## Pagination diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index bd2415f63..c1c5ab43b 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -15,6 +15,10 @@ "types": "./src/api-retry.ts", "default": "./src/api-retry.ts" }, + "./api-transport": { + "types": "./src/api-transport.ts", + "default": "./src/api-transport.ts" + }, "./api-path-segment": { "types": "./src/api-path-segment.ts", "default": "./src/api-path-segment.ts" diff --git a/packages/toolkit-core/src/api-transport.test.ts b/packages/toolkit-core/src/api-transport.test.ts new file mode 100644 index 000000000..40ce0b8b3 --- /dev/null +++ b/packages/toolkit-core/src/api-transport.test.ts @@ -0,0 +1,568 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RetryPolicy } from "./api-retry.js"; +import { + ApiTimeoutError, + type ApiTransportOptions, + createApiTransport, + getValidatedResponseSource, + type TransportCache, + type TransportCredential, +} from "./api-transport.js"; +import { + buildCacheKey, + createMemoryStore, + lookupResponse, + storeResponse, +} from "./response-cache/index.js"; +import { createBodyCodec } from "./response-cache/codec.js"; + +const URL_BASE = "https://us.sentry.io/api/0"; +const ISSUES_URL = `${URL_BASE}/organizations/acme/issues/`; + +const policy: RetryPolicy = { + maxRetries: 2, + baseDelayMs: 10, + maxDelayMs: 2000, + maxTotalDelayMs: 3000, +}; + +const credential: TransportCredential = { token: "token-a", identity: "id-a" }; + +type Sent = { method: string; url: string; headers: Headers; body: string }; + +/** A fetch that answers from `responses` in order and records each request. */ +function scriptedFetch( + responses: Array Response | Promise)>, +) { + const sent: Sent[] = []; + const fetch = vi.fn( + async (input: Request | string | URL, init?: RequestInit) => { + const request = new Request(input, init); + sent.push({ + method: request.method, + url: request.url, + headers: request.headers, + body: await request.text(), + }); + const next = responses[Math.min(sent.length, responses.length) - 1]; + if (next instanceof Error) { + throw next; + } + if (typeof next === "function") { + return await next(); + } + // A fresh body per call: cancelling a clone's tee branch would wait for + // the template's branch, which nothing reads. + return new Response( + next.body && (await next.clone().arrayBuffer()), + next, + ); + }, + ); + return { fetch, sent }; +} + +function transport( + fetch: ApiTransportOptions["fetch"], + overrides: Partial> = {}, +) { + return createApiTransport({ + credentials: { current: () => credential }, + retry: policy, + timeoutMs: 1000, + fetch, + ...overrides, + }); +} + +function windowLimited(resetInMs: number, now = Date.now()) { + return new Response('{"detail":"slow down"}', { + status: 429, + headers: { + "X-Sentry-Rate-Limit-Remaining": "0", + "X-Sentry-Rate-Limit-Reset": String(Math.ceil((now + resetInMs) / 1000)), + }, + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("retried statuses", () => { + it("returns a deterministic 5xx without retrying", async () => { + const { fetch, sent } = scriptedFetch([new Response("", { status: 501 })]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(501); + expect(sent).toHaveLength(1); + }); + + it("retries a 503 GET with backoff", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); +}); + +describe("unused bodies", () => { + it("retries without waiting for a teed body to cancel", async () => { + // Interceptors such as MSW hand out one branch of a teed stream; cancelling + // it settles only once the other branch, which nothing reads, is drained. + const [branch] = new Response("busy").body?.tee() ?? []; + const { fetch, sent } = scriptedFetch([ + () => new Response(branch, { status: 503 }), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); +}); + +describe("writes", () => { + it("retries a rate-limited POST once the window resets", async () => { + const { fetch, sent } = scriptedFetch([ + () => windowLimited(0), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request(ISSUES_URL, { + method: "POST", + body: '{"a":1}', + }); + + expect(response.status).toBe(200); + expect(sent.map((request) => request.body)).toEqual(['{"a":1}', '{"a":1}']); + }); + + it.each([ + ["a 503", new Response("", { status: 503 })], + ["a network error", new TypeError("fetch failed")], + ])("does not replay a POST after %s", async (_, failure) => { + const { fetch, sent } = scriptedFetch([failure]); + + const result = await transport(fetch) + .request(ISSUES_URL, { method: "POST", body: "{}" }) + .catch((error: unknown) => error); + + expect(result instanceof Response ? result.status : result).toEqual( + failure instanceof Response ? 503 : failure, + ); + expect(sent).toHaveLength(1); + }); + + it("retries a POST marked idempotent like a GET", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request( + ISSUES_URL, + { method: "POST", body: "chunk" }, + { idempotent: true }, + ); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); + + it("retries a write the host marks idempotent", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 502 }), + new Response(null, { status: 204 }), + ]); + + const response = await transport(fetch, { + isIdempotent: (method) => method === "PUT", + }).request(ISSUES_URL, { method: "PUT", body: "{}" }); + + expect(response.status).toBe(204); + expect(sent).toHaveLength(2); + }); + + it("replays a stream body on each attempt", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + + await transport(fetch).request( + ISSUES_URL, + { + method: "POST", + body: new Response("streamed").body, + duplex: "half", + } as RequestInit, + { idempotent: true }, + ); + + expect(sent.map((request) => request.body)).toEqual([ + "streamed", + "streamed", + ]); + }); +}); + +describe("credential refresh", () => { + it("replays a POST once with the refreshed token", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 401 }), + Response.json({ ok: true }), + ]); + const refresh = vi.fn(async (current: TransportCredential) => ({ + ...current, + token: "token-b", + })); + + const response = await transport(fetch, { + credentials: { current: () => credential, refresh }, + refreshedRequestHeader: "x-refreshed", + }).request(ISSUES_URL, { method: "POST", body: "{}" }); + + expect(response.status).toBe(200); + expect(sent.map((request) => request.headers.get("authorization"))).toEqual( + ["Bearer token-a", "Bearer token-b"], + ); + expect(sent[1]?.headers.get("x-refreshed")).toBe("1"); + }); + + it("returns a second 401 without refreshing again", async () => { + const { fetch, sent } = scriptedFetch([new Response("", { status: 401 })]); + const refresh = vi.fn(async (current: TransportCredential) => current); + + const response = await transport(fetch, { + credentials: { current: () => credential, refresh }, + }).request(ISSUES_URL); + + expect(response.status).toBe(401); + expect(refresh).toHaveBeenCalledOnce(); + expect(sent).toHaveLength(2); + }); + + it("refreshes ahead of expiry before the first attempt", async () => { + const { fetch, sent } = scriptedFetch([Response.json({})]); + + await transport(fetch, { + credentials: { + current: () => credential, + prepare: async (current) => ({ ...current, token: "fresh" }), + }, + }).request(ISSUES_URL); + + expect(sent[0]?.headers.get("authorization")).toBe("Bearer fresh"); + }); +}); + +describe("rate-limit waits", () => { + it("waits for a reset within the cap", async () => { + vi.useFakeTimers({ now: Date.UTC(2026, 0, 1, 12, 0, 0, 0) }); + const { fetch, sent } = scriptedFetch([ + () => windowLimited(1000), + Response.json({ ok: true }), + ]); + + const pending = transport(fetch).request(ISSUES_URL); + await vi.advanceTimersByTimeAsync(900); + expect(sent).toHaveLength(1); + await vi.advanceTimersByTimeAsync(200); + + expect((await pending).status).toBe(200); + expect(sent).toHaveLength(2); + }); + + it("does not wait for a reset beyond the cap and reports the delay", async () => { + const { fetch, sent } = scriptedFetch([() => windowLimited(30_000)]); + const rateLimited = vi.fn(async () => new Error("rate limited")); + + const result = await transport(fetch, { errors: { rateLimited } }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("rate limited")); + expect(sent).toHaveLength(1); + const [, info] = rateLimited.mock.calls[0] as unknown as [ + Response, + { path: string; retryAfterMs: number }, + ]; + expect(info.path).toBe("/api/0/organizations/acme/issues/"); + expect(info.retryAfterMs).toBeGreaterThan(28_000); + }); + + it("sends a still-limited GET at most maxRetries + 1 times", async () => { + const { fetch, sent } = scriptedFetch([() => windowLimited(0)]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(429); + expect(sent).toHaveLength(policy.maxRetries + 1); + }); + + it("ends the wait when the caller aborts", async () => { + const { fetch } = scriptedFetch([() => windowLimited(1500)]); + const controller = new AbortController(); + const reason = new Error("stop"); + + const pending = transport(fetch) + .request(ISSUES_URL, { signal: controller.signal }) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + controller.abort(reason); + + expect(await pending).toBe(reason); + }); +}); + +describe("timeouts and fetch errors", () => { + const hang = (_input: Request | string | URL, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }); + + it("throws the host timeout error for a POST without retrying", async () => { + const fetch = vi.fn(hang); + + const result = await transport(fetch, { timeoutMs: 5 }) + .request(ISSUES_URL, { method: "POST" }) + .catch((error: unknown) => error); + + expect(result).toBeInstanceOf(ApiTimeoutError); + expect(result).toMatchObject({ timeoutMs: 5, idempotent: false }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("retries a timed-out GET, then maps the error", async () => { + const fetch = vi.fn(hang); + const timeout = vi.fn(() => new Error("host timeout")); + + const result = await transport(fetch, { + timeoutMs: (url) => (url.includes("/issues/") ? 5 : 1000), + errors: { timeout }, + }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("host timeout")); + expect(timeout).toHaveBeenCalledWith({ timeoutMs: 5, idempotent: true }); + expect(fetch).toHaveBeenCalledTimes(policy.maxRetries + 1); + }); + + it("throws a fatal fetch error at once", async () => { + const { fetch, sent } = scriptedFetch([new Error("bad certificate")]); + + const result = await transport(fetch, { + errors: { fatal: (error) => new Error("tls", { cause: error }) }, + }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("tls")); + expect(sent).toHaveLength(1); + }); + + it("does not retry an error from the host's header rules", async () => { + const { fetch } = scriptedFetch([Response.json({})]); + + const result = await transport(fetch, { + prepareHeaders: () => { + throw new Error("untrusted host"); + }, + }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("untrusted host")); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("headers", () => { + it("keeps a Request's own headers and adds auth and User-Agent", async () => { + const { fetch, sent } = scriptedFetch([Response.json({})]); + + await transport(fetch, { userAgent: "test-agent" }).request( + new Request(ISSUES_URL, { + method: "POST", + body: "{}", + headers: { "Content-Type": "application/json" }, + }), + ); + + expect(Object.fromEntries(sent[0]?.headers ?? [])).toMatchObject({ + "content-type": "application/json", + authorization: "Bearer token-a", + "user-agent": "test-agent", + }); + }); +}); + +describe("validated redirects", () => { + const redirect = (location: string, status = 302) => + new Response(null, { status, headers: { location } }); + const origins = { + trusts: (url: URL) => url.hostname.endsWith(".sentry.io"), + reject: (destination: string) => new Error(`untrusted ${destination}`), + }; + + it("follows trusted hops and records the final origin", async () => { + const { fetch, sent } = scriptedFetch([ + redirect("https://de.sentry.io/api/0/users/me/regions/"), + Response.json({ regions: [] }), + ]); + + const response = await transport(fetch, { origins }).request( + `${URL_BASE}/users/me/regions/`, + undefined, + { validateRedirects: true }, + ); + + expect(sent.map((request) => request.url)).toEqual([ + `${URL_BASE}/users/me/regions/`, + "https://de.sentry.io/api/0/users/me/regions/", + ]); + expect(getValidatedResponseSource(response)).toEqual({ + origin: "https://de.sentry.io", + identity: "id-a", + }); + }); + + it.each([ + [ + "an untrusted host", + "https://evil.example/", + "untrusted https://evil.example", + ], + ["a loop", `${URL_BASE}/users/me/regions/`, "untrusted "], + ])( + "rejects %s without sending the credential", + async (_, location, message) => { + const { fetch, sent } = scriptedFetch([redirect(location)]); + + const result = await transport(fetch, { origins }) + .request(`${URL_BASE}/users/me/regions/`, undefined, { + validateRedirects: true, + }) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error(message)); + expect(sent).toHaveLength(1); + }, + ); + + it("turns a 303 into a bodiless GET", async () => { + const { fetch, sent } = scriptedFetch([ + redirect(`${URL_BASE}/done/`, 303), + Response.json({}), + ]); + + await transport(fetch, { origins }).request( + ISSUES_URL, + { + method: "POST", + body: "{}", + headers: { "Content-Type": "application/json" }, + }, + { validateRedirects: true }, + ); + + expect(sent[1]).toMatchObject({ method: "GET", body: "" }); + expect(sent[1]?.headers.get("content-type")).toBeNull(); + }); +}); + +describe("cache", () => { + /** A cache on the shared memory store, keyed as the hosts key it. */ + function memoryCache(): TransportCache & { invalidated: string[] } { + const store = createMemoryStore(); + const codec = createBodyCodec(); + const invalidated: string[] = []; + return { + invalidated, + lookup: async ({ url, requestHeaders, identity }) => + await lookupResponse({ + store, + codec, + key: await buildCacheKey("GET", url, identity), + url, + requestHeaders, + }), + store: async ({ url, requestHeaders, identity }, response) => { + await storeResponse({ + store, + codec, + key: await buildCacheKey("GET", url, identity), + identity, + url, + requestHeaders, + response, + }); + }, + invalidate: async (url) => { + invalidated.push(url); + }, + }; + } + + it("serves a repeated GET from the cache", async () => { + const cache = memoryCache(); + const { fetch, sent } = scriptedFetch([Response.json([{ id: "1" }])]); + const api = transport(fetch, { cache }); + + await (await api.request(ISSUES_URL)).json(); + await vi.waitFor(async () => + expect( + await cache.lookup({ + url: ISSUES_URL, + requestHeaders: { authorization: "Bearer token-a" }, + identity: "id-a", + }), + ).toBeDefined(), + ); + const cached = await api.request(ISSUES_URL); + + expect(await cached.json()).toEqual([{ id: "1" }]); + expect(sent).toHaveLength(1); + }); + + it("revalidates a stale entry with its ETag", async () => { + const response = await transport( + scriptedFetch([new Response(null, { status: 304 })]).fetch, + { + cache: { + lookup: async () => ({ + kind: "stale", + conditionalHeaders: { "If-None-Match": '"v1"' }, + revalidate: async () => Response.json({ cached: true }), + }), + store: async () => {}, + invalidate: async () => {}, + }, + }, + ).request(ISSUES_URL); + + expect(await response.json()).toEqual({ cached: true }); + }); + + it("invalidates after a successful write", async () => { + const cache = memoryCache(); + const { fetch } = scriptedFetch([new Response(null, { status: 204 })]); + + await transport(fetch, { cache }).request(`${ISSUES_URL}1/`, { + method: "PUT", + }); + + expect(cache.invalidated).toEqual([`${ISSUES_URL}1/`]); + }); +}); diff --git a/packages/toolkit-core/src/api-transport.ts b/packages/toolkit-core/src/api-transport.ts new file mode 100644 index 000000000..5201097c7 --- /dev/null +++ b/packages/toolkit-core/src/api-transport.ts @@ -0,0 +1,796 @@ +/** + * Authenticated fetch for Sentry API requests, shared by the CLI and MCP. + * Standard Web APIs only, so it runs in Node and Cloudflare Workers. + * + * Per request it sets the Authorization header, refreshes the credential once + * after a 401 (any method), times out each attempt, retries through + * `api-retry`, replays request bodies, optionally follows redirects only to + * trusted origins, and serves and invalidates cached GETs through an injected + * cache. The host supplies credentials, header rules, error classes, logging, + * and spans. + */ + +import { + isIdempotentRequest, + nextRetryDelayMs, + parseRetryDelayMs, + type RetryPolicy, + sleep, +} from "./api-retry.js"; +import { sentryBearerHeader, trimAuthToken } from "./auth-token.js"; +import type { CacheLookup } from "./response-cache/policy.js"; + +type FetchInput = Request | string | URL; + +/** The fields the transport reads; hosts extend it with their own. */ +export type TransportCredential = { + token: string; + /** Stable hash of the login, for cache scoping; never a raw token. */ + identity: string; +}; + +export type CredentialProvider = { + /** The credential for a new request, before any refresh. Cache lookups use it. */ + current(): C | Promise; + /** Called after a cache miss, before the first attempt, to refresh ahead of expiry. */ + prepare?(credential: C): Promise; + /** After a 401: the refreshed credential, or undefined when it cannot refresh. */ + refresh?(credential: C): Promise; + /** The Authorization value. Throws for a malformed token. */ + authorization?(credential: C): string; +}; + +/** How the caller reads a successful body; see {@link ApiTransportRequestOptions.responseBody}. */ +export type ResponseBodyMode = "auto" | "buffer" | "stream"; + +/** The GET cache, keyed by the host. `CacheLookup` comes from `response-cache`. */ +export type TransportCache = { + lookup(request: CachedRequest): Promise; + /** Store a successful GET; `response` is a clone the cache may consume. */ + store(request: CachedRequest, response: Response): Promise; + /** Drop entries a successful write to `url` made stale. */ + invalidate(url: string, identity: string): Promise; +}; + +export type CachedRequest = { + url: string; + /** For `Vary` matching. */ + requestHeaders: Record; + identity: string; +}; + +type TransportLogger = { + debug(message: string, ...args: unknown[]): void; +}; + +export type ApiTransportOptions = { + credentials: CredentialProvider; + retry: RetryPolicy; + /** Per-attempt timeout, or a function of the request URL. */ + timeoutMs: number | ((url: string) => number); + /** Underlying fetch, read on each call. Defaults to `globalThis.fetch`. */ + fetch?: (input: FetchInput, init?: RequestInit) => Promise; + /** Sent when the request sets no `User-Agent`. */ + userAgent?: string; + /** + * Add host headers, such as tracing or proxy headers, before Authorization + * is set. Throw to reject the request, for example for an untrusted host. + */ + prepareHeaders?( + headers: Headers, + request: { input: FetchInput; credential: C }, + ): void; + /** Writes the host knows are safe to replay, such as a catalog of idempotent operations. */ + isIdempotent?(method: string, url: string): boolean; + /** Set to `1` on the request replayed after a credential refresh. */ + refreshedRequestHeader?: string; + /** Which origins may receive the credential when following redirects. */ + origins?: { + trusts(url: URL, credential: C): boolean; + /** The error for an untrusted or unusable redirect destination. */ + reject(destination: string, credential: C): Error; + }; + /** Host error classes. Without them the transport throws its own. */ + errors?: { + /** + * The final attempt timed out. `idempotent` is false when a write may + * have been applied. + */ + timeout?(info: { timeoutMs: number; idempotent: boolean }): Error; + /** A fetch error to throw at once, without retrying, such as a TLS failure. */ + fatal?(error: unknown): Error | undefined; + /** A 429 that is not retried. Undefined returns the response. */ + rateLimited?( + response: Response, + info: { path: string; retryAfterMs: number | undefined }, + ): Promise; + }; + cache?: TransportCache; + logger?: TransportLogger; + /** Wrap each request, including cache lookups, in a host span. */ + span?( + request: { method: string; path: string }, + run: () => Promise, + ): Promise; +}; + +export type ApiTransportRequestOptions = { + /** + * Replaying the request is harmless, so 5xx, timeouts, and network errors + * retry for any method. For writes the server dedupes, such as + * content-addressed chunk uploads. + */ + idempotent?: boolean; + /** + * `buffer` reads a successful body within the attempt's timeout and retry + * budget; `auto` does so only when it has a `Content-Type`, as the + * `@sentry/api` auto parser returns a stream otherwise. Default `stream`. + */ + responseBody?: ResponseBodyMode; + /** + * Follow redirects manually, checking each destination with `origins` + * and recording where the response came from; see + * {@link getValidatedResponseSource}. Skips the cache. + */ + validateRedirects?: boolean; +}; + +export type ApiTransport = { + request( + input: FetchInput, + init?: RequestInit, + options?: ApiTransportRequestOptions, + ): Promise; +}; + +/** The request's last attempt hit the transport's own timeout. */ +export class ApiTimeoutError extends Error { + readonly timeoutMs: number; + readonly idempotent: boolean; + + constructor(timeoutMs: number, idempotent: boolean) { + super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); + this.name = "ApiTimeoutError"; + this.timeoutMs = timeoutMs; + this.idempotent = idempotent; + } +} + +const MAX_REDIRECTS = 20; + +/** Where a response fetched with `validateRedirects` came from. */ +export type ValidatedResponseSource = { origin: string; identity: string }; + +const validatedSources = new WeakMap(); + +/** The final origin and credential identity of a `validateRedirects` response. */ +export function getValidatedResponseSource( + response: Response, +): ValidatedResponseSource | undefined { + return validatedSources.get(response); +} + +/** Extract the full URL from a fetch input; `String(request)` is not its URL. */ +export function extractInputUrl(input: FetchInput): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input.url; +} + +/** The URL pathname for logs and spans, or the raw input when it does not parse. */ +export function extractUrlPath(input: FetchInput): string { + const raw = extractInputUrl(input); + try { + return new URL(raw).pathname; + } catch { + return raw; + } +} + +/** Thrown inside an attempt for a host rejection that must not be retried. */ +class Rejection { + constructor(readonly error: unknown) {} +} + +/** One attempt: a response, or a thrown error and whether our timeout caused it. */ +type Outcome = { response: Response } | { failed: unknown; timedOut: boolean }; + +type AttemptResult = + | { action: "done"; response: Response } + | { action: "retry"; delayMs: number } + | { action: "throw"; error: unknown }; + +type Send = { + input: FetchInput; + init: RequestInit | undefined; + method: string; + url: string; + path: string; + /** Before `prepare`; its identity scopes the cache. */ + credential: C; + options: ApiTransportRequestOptions; + conditionalHeaders?: Record; +}; + +/** Create a transport; see {@link ApiTransportOptions}. */ +export function createApiTransport( + config: ApiTransportOptions, +): ApiTransport { + const { credentials, retry, cache, logger } = config; + + const baseFetch = (input: FetchInput, init?: RequestInit) => + (config.fetch ?? globalThis.fetch)(input, init); + + function authorization(credential: C): string { + if (credentials.authorization) { + return credentials.authorization(credential); + } + const header = sentryBearerHeader(credential.token); + if (header === null) { + throw new Error("Malformed authentication token"); + } + return header; + } + + function timeoutFor(url: string): number { + return typeof config.timeoutMs === "number" + ? config.timeoutMs + : config.timeoutMs(url); + } + + /** + * When the SDK calls fetch(request) with no init, read headers from the + * Request: fetch(request, { headers }) replaces them per spec, which would + * drop the SDK's Content-Type. The returned Headers are shared across + * attempts; a 401 refresh updates them. + */ + function buildHeaders( + input: FetchInput, + init: RequestInit | undefined, + credential: C, + ): Headers { + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + config.prepareHeaders?.(headers, { input, credential }); + headers.set("Authorization", authorization(credential)); + if (config.userAgent && !headers.has("User-Agent")) { + headers.set("User-Agent", config.userAgent); + } + return headers; + } + + /** + * Release an unused body without waiting: cancelling one branch of a teed + * stream, as interceptors such as MSW hand out, settles only once the other + * branch is read, which may never happen. + */ + function cancelBody(response: Response): void { + if (response.body && !response.bodyUsed) { + response.body.cancel().catch((error: unknown) => { + logger?.debug("Failed to cancel unused response body", error); + }); + } + } + + /** + * One fetch with a timeout. A buffered body is drained from a clone within + * the attempt, so a body failure can retry without consuming the caller's + * copy. + */ + async function fetchOnce( + input: FetchInput, + init: RequestInit, + signal: AbortSignal | null | undefined, + timeoutMs: number, + mode: ResponseBodyMode, + ): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + const onAbort = () => controller.abort(); + if (signal?.aborted) { + controller.abort(); + } else { + signal?.addEventListener("abort", onAbort, { once: true }); + } + try { + const response = await baseFetch(input, { + ...init, + signal: controller.signal, + }); + if (shouldBuffer(response, mode)) { + await response.clone().body?.pipeTo(new WritableStream()); + } + return { response }; + } catch (error) { + if (timedOut) { + return { failed: error, timedOut: true }; + } + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } + + async function rejectRedirect( + response: Response, + destination: string, + credential: C, + ): Promise { + cancelBody(response); + throw new Rejection( + config.origins?.reject(destination, credential) ?? + new Error(`Refusing to follow a redirect to ${destination}`), + ); + } + + function trusts(url: URL, credential: C, original: URL): boolean { + return config.origins + ? config.origins.trusts(url, credential) + : url.origin === original.origin; + } + + async function nextRedirect( + response: Response, + request: Request, + credential: C, + visited: Set, + original: URL, + ): Promise { + const location = response.headers.get("location"); + if (!location) { + return await rejectRedirect(response, "", credential); + } + if (visited.size > MAX_REDIRECTS) { + return await rejectRedirect(response, "", credential); + } + let next: URL; + try { + next = new URL(location, request.url); + } catch (error) { + logger?.debug( + "Rejected malformed redirect location", + error instanceof Error ? error.name : "unknown error", + ); + return await rejectRedirect(response, "", credential); + } + if (!trusts(next, credential, original)) { + return await rejectRedirect(response, next.origin, credential); + } + const key = redirectKey(next); + if (visited.has(key)) { + return await rejectRedirect(response, "", credential); + } + visited.add(key); + return next; + } + + /** Follow each redirect by hand so the credential only reaches trusted origins. */ + async function fetchValidated( + input: FetchInput, + init: RequestInit | undefined, + headers: Headers, + credential: C, + timeoutMs: number, + mode: ResponseBodyMode, + ): Promise { + const original = new Request(input, init); + const originalUrl = new URL(original.url); + const visited = new Set([redirectKey(originalUrl)]); + let request = original; + let requestHeaders = new Headers(headers); + + // The host's prepareHeaders vets the first URL; nextRedirect vets each hop. + for (;;) { + const result = await fetchOnce( + request.clone(), + { headers: requestHeaders, redirect: "manual" }, + init?.signal ?? original.signal, + timeoutMs, + mode, + ); + if (!("response" in result) || !isRedirect(result.response.status)) { + const origin = httpOrigin(request.url); + if ("response" in result && origin) { + validatedSources.set(result.response, { + origin, + identity: credential.identity, + }); + } + return result; + } + const { response } = result; + try { + const next = await nextRedirect( + response, + request, + credential, + visited, + originalUrl, + ); + ({ request, headers: requestHeaders } = await redirectRequest( + request, + requestHeaders, + next, + response.status, + )); + } finally { + cancelBody(response); + } + } + } + + /** Send with refresh, timeouts, and retries; the cache is handled by the caller. */ + async function send(sendArgs: Send): Promise { + const { input, init, method, url, path, options } = sendArgs; + let credential = credentials.prepare + ? await credentials.prepare(sendArgs.credential) + : sendArgs.credential; + const headers = buildHeaders(input, init, credential); + for (const [name, value] of Object.entries( + sendArgs.conditionalHeaders ?? {}, + )) { + headers.set(name, value); + } + const nextInput = await replayableInput(input, init); + const timeoutMs = timeoutFor(url); + const idempotent = + options.idempotent === true || + (config.isIdempotent?.(method, url) ?? false); + const mode = options.responseBody ?? "stream"; + const signal = + init?.signal !== undefined + ? init.signal + : input instanceof Request + ? input.signal + : undefined; + let refreshed = false; + let waitedMs = 0; + + const retryDelay = ( + attempt: number, + status: number | undefined, + serverDelayMs: number | undefined, + ) => + nextRetryDelayMs({ + policy: retry, + method, + idempotent, + status, + serverDelayMs, + attempt, + waitedMs, + }); + + async function onResponse( + response: Response, + attempt: number, + ): Promise { + if (response.status === 401 && attempt < retry.maxRetries) { + const next = + refreshed || !credentials.refresh + ? undefined + : await credentials.refresh(credential); + if (!next) { + return { action: "done", response }; + } + refreshed = true; + credential = next; + headers.set("Authorization", authorization(next)); + if (config.refreshedRequestHeader) { + headers.set(config.refreshedRequestHeader, "1"); + } + cancelBody(response); + return { action: "retry", delayMs: 0 }; + } + + const serverDelayMs = parseRetryDelayMs( + response.status, + response.headers, + ); + const delayMs = retryDelay(attempt, response.status, serverDelayMs); + if (delayMs !== undefined) { + cancelBody(response); + return { action: "retry", delayMs }; + } + if (response.status === 429 && config.errors?.rateLimited) { + const error = await config.errors.rateLimited(response, { + path, + retryAfterMs: serverDelayMs, + }); + if (error) { + return { action: "throw", error }; + } + } + return { action: "done", response }; + } + + function onFailure( + { failed, timedOut }: Extract, + attempt: number, + ): AttemptResult { + if (failed instanceof Rejection) { + return { action: "throw", error: failed.error }; + } + if (isAbort(failed, signal)) { + return { action: "throw", error: failed }; + } + const fatal = config.errors?.fatal?.(failed); + if (fatal) { + return { action: "throw", error: fatal }; + } + const delayMs = retryDelay(attempt, undefined, undefined); + if (delayMs !== undefined) { + return { action: "retry", delayMs }; + } + if (!timedOut) { + return { action: "throw", error: failed }; + } + // Whether the endpoint may have run, for the host's error wording. + const replayable = isIdempotentRequest(method, idempotent); + return { + action: "throw", + error: + config.errors?.timeout?.({ timeoutMs, idempotent: replayable }) ?? + new ApiTimeoutError(timeoutMs, replayable), + }; + } + + for (let attempt = 0; ; attempt++) { + const { input: attemptInput, init: attemptInit } = nextInput(); + let outcome: Outcome; + try { + outcome = options.validateRedirects + ? await fetchValidated( + attemptInput, + attemptInit, + headers, + credential, + timeoutMs, + mode, + ) + : await fetchOnce( + attemptInput, + { ...attemptInit, headers }, + signal, + timeoutMs, + mode, + ); + } catch (error) { + outcome = { failed: error, timedOut: false }; + } + // Outside the try: handling a response is not a fetch failure. + const result = + "response" in outcome + ? await onResponse(outcome.response, attempt) + : onFailure(outcome, attempt); + + if (result.action === "done") { + storeInCache( + method, + url, + sendArgs.credential.identity, + headers, + result.response, + ); + await invalidateAfterWrite( + method, + url, + sendArgs.credential.identity, + result.response, + ); + return result.response; + } + if (result.action === "throw") { + throw result.error; + } + logger?.debug( + `${method} ${path} → retry ${attempt + 1}/${retry.maxRetries} after ${Math.round(result.delayMs)}ms`, + ); + await sleep(result.delayMs, signal); + waitedMs += result.delayMs; + } + } + + /** Fire-and-forget: a cache write never delays or fails the response. */ + function storeInCache( + method: string, + url: string, + identity: string, + headers: Headers, + response: Response, + ): void { + if (!cache || method !== "GET" || !response.ok) { + return; + } + cache + .store( + { + url, + requestHeaders: { authorization: headers.get("Authorization") ?? "" }, + identity, + }, + response.clone(), + ) + .catch((error: unknown) => { + logger?.debug("Response cache write failed", error); + }); + } + + /** + * Awaited so a later read in the same task sees fresh data, and never + * throws: the write already succeeded. + */ + async function invalidateAfterWrite( + method: string, + url: string, + identity: string, + response: Response, + ): Promise { + if (!cache || method === "GET" || !response.ok) { + return; + } + try { + await cache.invalidate(url, identity); + } catch (error) { + logger?.debug("Post-mutation cache invalidation failed", error); + } + } + + async function request( + input: FetchInput, + init?: RequestInit, + options: ApiTransportRequestOptions = {}, + ): Promise { + const method = + init?.method ?? (input instanceof Request ? input.method : "GET"); + const url = extractInputUrl(input); + const path = extractUrlPath(input); + + const run = async (): Promise => { + const startTime = performance.now(); + const elapsed = () => (performance.now() - startTime).toFixed(0); + const credential = await credentials.current(); + // A cached response has no validated origin, so discovery always + // goes to the network. + const lookup = + cache && method === "GET" && !options.validateRedirects + ? await cache.lookup({ + url, + // Before a refresh picks the token, so validate nothing yet. + requestHeaders: credential.token + ? { authorization: `Bearer ${trimAuthToken(credential.token)}` } + : {}, + identity: credential.identity, + }) + : undefined; + if (lookup?.kind === "hit") { + logger?.debug( + `${method} ${path} → ${lookup.response.status} (cache hit, ${elapsed()}ms)`, + ); + return lookup.response; + } + + const sendArgs = { input, init, method, url, path, credential, options }; + let response = await send({ + ...sendArgs, + conditionalHeaders: lookup?.conditionalHeaders, + }); + if (lookup?.kind === "stale" && response.status === 304) { + const revalidated = await lookup.revalidate(response); + if (revalidated) { + logger?.debug( + `${method} ${path} → 304 (revalidated cache entry, ${elapsed()}ms)`, + ); + return revalidated; + } + // A 304 the entry cannot use has no body; fetch it unconditionally. + response = await send(sendArgs); + } + logger?.debug(`${method} ${path} → ${response.status} (${elapsed()}ms)`); + return response; + }; + + return config.span ? await config.span({ method, path }, run) : await run(); + } + + return { request }; +} + +function isAbort(error: unknown, signal: AbortSignal | null | undefined) { + return ( + error instanceof DOMException && + error.name === "AbortError" && + Boolean(signal?.aborted) + ); +} + +function shouldBuffer(response: Response, mode: ResponseBodyMode): boolean { + const contentType = response.headers.get("Content-Type"); + // SSE reconnects and streaming parsers own consumption of their bodies. + if ( + contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream" + ) { + return false; + } + return ( + response.ok && (mode === "buffer" || (mode === "auto" && !!contentType)) + ); +} + +/** + * A fresh `(input, init)` pair per attempt: `fetch` consumes a body, so a + * retry would otherwise throw `Request body already used`. A `Request` is + * cloned; a `ReadableStream` body, the only single-use body type, is read + * once into a buffer. Other bodies pass through, which keeps the + * `multipart/form-data` boundary `fetch` derives from `FormData`. + */ +async function replayableInput( + input: FetchInput, + init: RequestInit | undefined, +): Promise<() => { input: FetchInput; init: RequestInit | undefined }> { + if (input instanceof Request) { + return () => ({ input: input.clone(), init }); + } + const body = init?.body; + if (body instanceof ReadableStream) { + const snapshot = await new Response(body).arrayBuffer(); + return () => ({ input, init: { ...init, body: snapshot } }); + } + return () => ({ input, init }); +} + +/** The origin of a credential-free HTTP(S) URL, or undefined. */ +function httpOrigin(raw: string): string | undefined { + const url = new URL(raw); + return (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password + ? url.origin + : undefined; +} + +function isRedirect(status: number): boolean { + return [301, 302, 303, 307, 308].includes(status); +} + +function redirectKey(url: URL): string { + const key = new URL(url); + key.hash = ""; + return key.href; +} + +/** The next hop's request; a 303, or a 301/302 after a POST, becomes a bodiless GET. */ +async function redirectRequest( + request: Request, + requestHeaders: Headers, + next: URL, + status: number, +): Promise<{ request: Request; headers: Headers }> { + const current = request.method.toUpperCase(); + const method = + (status === 303 && current !== "HEAD") || + ((status === 301 || status === 302) && current === "POST") + ? "GET" + : current; + const headers = new Headers(requestHeaders); + if (method !== current) { + headers.delete("content-length"); + headers.delete("content-type"); + headers.delete("transfer-encoding"); + } + const body = + method === current && request.body + ? await request.clone().arrayBuffer() + : undefined; + return { request: new Request(next, { method, headers, body }), headers }; +}