|
| 1 | +/** |
| 2 | + * Transport-agnostic HTTP helpers: header building, retry policy, backoff, and |
| 3 | + * envelope parsing / error mapping. The client owns the fetch call and the |
| 4 | + * retry loop; these are the pure pieces around it. |
| 5 | + */ |
| 6 | + |
| 7 | +import type { ResolvedConfig } from "./config"; |
| 8 | +import { raiseApiError } from "./errors"; |
| 9 | +import { VERSION } from "./version"; |
| 10 | + |
| 11 | +export const USER_AGENT = `transcriptfetch-node/${VERSION}`; |
| 12 | + |
| 13 | +export function buildHeaders( |
| 14 | + config: ResolvedConfig, |
| 15 | + idempotencyKey: string | undefined, |
| 16 | + needsKey = true, |
| 17 | +): Record<string, string> { |
| 18 | + const headers: Record<string, string> = { |
| 19 | + "User-Agent": USER_AGENT, |
| 20 | + Accept: "application/json", |
| 21 | + }; |
| 22 | + if (needsKey) headers["Authorization"] = `Bearer ${config.apiKey}`; |
| 23 | + if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; |
| 24 | + return headers; |
| 25 | +} |
| 26 | + |
| 27 | +export function newIdempotencyKey(): string { |
| 28 | + const uuid = globalThis.crypto?.randomUUID?.(); |
| 29 | + if (uuid) return uuid.replace(/-/g, ""); |
| 30 | + // Fallback for environments without crypto.randomUUID. |
| 31 | + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(""); |
| 32 | +} |
| 33 | + |
| 34 | +export function isRetryable(status: number): boolean { |
| 35 | + return status === 429 || status >= 500; |
| 36 | +} |
| 37 | + |
| 38 | +export function backoffMs(attempt: number, retryAfter?: string | null): number { |
| 39 | + if (retryAfter != null) { |
| 40 | + const seconds = Number(retryAfter); |
| 41 | + if (Number.isFinite(seconds)) return Math.min(seconds, 60) * 1000; |
| 42 | + } |
| 43 | + const base = Math.min(0.5 * 2 ** attempt, 8); |
| 44 | + return (base + Math.random() * 0.25) * 1000; |
| 45 | +} |
| 46 | + |
| 47 | +type Envelope = Record<string, unknown>; |
| 48 | + |
| 49 | +/** |
| 50 | + * Return the parsed JSON body, or throw the mapped APIError. Handles both the |
| 51 | + * `{ ok, request_id, data, usage }` envelope and the bare health-check body |
| 52 | + * (which has no `ok` field). |
| 53 | + */ |
| 54 | +export async function parseEnvelope(response: Response): Promise<Envelope> { |
| 55 | + let payload: unknown = null; |
| 56 | + try { |
| 57 | + payload = await response.json(); |
| 58 | + } catch { |
| 59 | + payload = null; |
| 60 | + } |
| 61 | + |
| 62 | + const isObj = payload != null && typeof payload === "object"; |
| 63 | + const requestId = isObj ? ((payload as Envelope).request_id as string | undefined) ?? null : null; |
| 64 | + |
| 65 | + const isError = response.status >= 400 || (isObj && (payload as Envelope).ok === false); |
| 66 | + if (isError) { |
| 67 | + raiseApiError( |
| 68 | + response.status, |
| 69 | + isObj ? (payload as Envelope) : {}, |
| 70 | + requestId, |
| 71 | + response.headers.get("retry-after"), |
| 72 | + ); |
| 73 | + } |
| 74 | + |
| 75 | + if (!isObj) { |
| 76 | + raiseApiError(response.status, {}, requestId, null); |
| 77 | + } |
| 78 | + |
| 79 | + return payload as Envelope; |
| 80 | +} |
0 commit comments