Skip to content

Commit c6c056b

Browse files
author
Transcript-Fetch-Admin
committed
feat: HTTP transport with retries and backoff
1 parent 785b1c1 commit c6c056b

1 file changed

Lines changed: 80 additions & 0 deletions

File tree

‎src/transport.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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

Comments
 (0)