@clocknext/sdk 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ import type { Transport } from "../http.js";
2
+ import type { Flusher } from "../flusher.js";
3
+ import { newKey } from "../idempotency.js";
4
+ import type {
5
+ CompositeCompletionInput,
6
+ CompositeCompletionResult,
7
+ CreditInput,
8
+ OutcomeInput,
9
+ ResolvedConfig,
10
+ SerializedUnitUsage,
11
+ Signal,
12
+ SignalType,
13
+ TrackResult,
14
+ UnitInput,
15
+ UnitTrackResult,
16
+ UnitUsageList,
17
+ UsageList,
18
+ WalletInput,
19
+ } from "../types.js";
20
+
21
+ /**
22
+ * Which route each signal type posts to.
23
+ *
24
+ * This map IS the migration off `POST /api/v1/usage`. The signal's `type` no
25
+ * longer travels in the body — it picks the URL, and the server reads the
26
+ * meter off the path. That is the server's design, not a convenience: a body
27
+ * can neither omit the meter nor contradict it, so the whole class of "the
28
+ * `type` field said credit but the fields describe a unit" bug is gone.
29
+ *
30
+ * `type` stays on the SDK's own `Signal` objects because it is still the
31
+ * discriminator callers write and TypeScript narrows on. It just stops being
32
+ * something we send.
33
+ */
34
+ const SIGNAL_PATHS: Record<SignalType, string> = {
35
+ credit: "/api/v1/signal/credit",
36
+ outcome: "/api/v1/signal/outcome",
37
+ wallet: "/api/v1/signal/wallet",
38
+ };
39
+
40
+ /** Unit reports and composite completions are not part of the `Signal` union
41
+ * (no model, no tokens), so they carry their paths separately. */
42
+ const UNIT_PATH = "/api/v1/signal/unit";
43
+ const COMPOSITE_COMPLETE_PATH = "/api/v1/signal/composite/complete";
44
+
45
+ /**
46
+ * Builds the request body for a typed Signal.
47
+ *
48
+ * Two grouping changes from the pre-0.10 `POST /api/v1/usage` body, both of
49
+ * which the server now requires:
50
+ *
51
+ * usage: { model, inputTokens, outputTokens, cacheTokens }
52
+ * composite: { "<refId>": "<value>" }
53
+ *
54
+ * `usage` collects the four fields describing one LLM call. `composite` is the
55
+ * more consequential one: the tag used to sit at the TOP LEVEL under its own
56
+ * caller-defined property name, which meant a composite called `model` or
57
+ * `status` shadowed a real ingest field. Nested, the two namespaces are
58
+ * separate and that collision is impossible.
59
+ *
60
+ * The route is NOT part of this body — see {@link SIGNAL_PATHS}.
61
+ */
62
+ export function signalToWire(signal: Signal): Record<string, unknown> {
63
+ const t = signal.tokens;
64
+ const body: Record<string, unknown> = {
65
+ customerId: signal.customerId,
66
+ // `usage` is required on all three model-priced kinds. input/output are
67
+ // always sent; `cacheTokens` only when provided (the server defaults 0).
68
+ usage: {
69
+ model: signal.model,
70
+ inputTokens: t.input,
71
+ outputTokens: t.output,
72
+ ...(t.cache != null ? { cacheTokens: t.cache } : {}),
73
+ },
74
+ };
75
+ if (signal.member) body.member = signal.member;
76
+ if (signal.custom) body.custom = signal.custom;
77
+
78
+ // credit + outcome both resolve their target via the stable `agentKey`.
79
+ // A wallet signal has none: it meters money at the model's cost rather than
80
+ // a named entitlement.
81
+ if (signal.type === "credit" || signal.type === "outcome") {
82
+ body.agentKey = signal.agentKey;
83
+ }
84
+
85
+ // Outcome runs are grouped by `runId`; `complete` ends the run.
86
+ if (signal.type === "outcome") {
87
+ body.runId = signal.runId;
88
+ if (signal.complete != null) body.complete = signal.complete;
89
+ }
90
+
91
+ // Composite tag, nested under its `refId`. Never set for wallet — the server
92
+ // accepts and ignores one there, but sending it would only imply a rollup
93
+ // that will not exist.
94
+ if (signal.type !== "wallet" && signal.composite) {
95
+ body.composite = { [signal.composite.ref]: signal.composite.value };
96
+ }
97
+
98
+ // A caller-supplied idempotency key is forwarded verbatim; `track()` fills in
99
+ // an auto-generated one when none was given.
100
+ if (signal.idempotencyKey) body.idempotencyKey = signal.idempotencyKey;
101
+ return body;
102
+ }
103
+
104
+ /** The fields every `/api/v1/signal/*` response may carry. */
105
+ interface SignalResponse {
106
+ queued?: boolean;
107
+ messageId?: string;
108
+ rawId?: string;
109
+ }
110
+
111
+ /** Copy the two durability handles onto a result, each only when present. */
112
+ function handles(res: SignalResponse) {
113
+ return {
114
+ ...(res.messageId != null ? { messageId: res.messageId } : {}),
115
+ ...(res.rawId != null ? { rawId: res.rawId } : {}),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * The `signals` resource: record usage signals and read them back.
121
+ *
122
+ * In async mode (default), `track()` buffers the signal locally and returns
123
+ * immediately with `{ status: "buffered" }`. Pass `{ wait: true }` (or
124
+ * construct the client in `sync` mode) to await the request instead.
125
+ *
126
+ * Awaiting the request does NOT mean waiting for a price, and deliberately so.
127
+ * The server writes every signal to a durable inbox and hands it to a worker,
128
+ * answering `202` — so `track()` resolves `{ status: "accepted", messageId,
129
+ * rawId, usageLog: null }`: impossible to lose, not yet billed. Only when the
130
+ * server prices inline do you get `{ status: "recorded", usageLog }`. Branch on
131
+ * `status`; never assume `usageLog`.
132
+ *
133
+ * Nothing here ever blocks a request thread on billing. That is the point: a
134
+ * meter that makes the caller wait for pricing has moved your billing system
135
+ * onto your product's critical path.
136
+ *
137
+ * Because of that, a signal the server later REJECTS (unknown `agentKey`, a
138
+ * model the org has not enabled, no active plan) cannot come back through this
139
+ * promise. It surfaces on the `onError` hook, and `list()` is how you confirm
140
+ * one actually landed — ingestion being asynchronous, the only way to.
141
+ */
142
+ export class Signals {
143
+ constructor(
144
+ private readonly transport: Transport,
145
+ private readonly flusher: Flusher,
146
+ private readonly cfg: ResolvedConfig,
147
+ ) {}
148
+
149
+ /**
150
+ * Record one usage signal, to the route its `type` selects:
151
+ * `POST /api/v1/signal/credit` | `/signal/outcome` | `/signal/wallet`.
152
+ *
153
+ * Returns as soon as the signal is buffered (async mode) or the request has
154
+ * been answered (`{ wait: true }` / sync mode). See {@link TrackResult}: a
155
+ * resolved promise means "accepted", which is not the same as "billed".
156
+ */
157
+ async track(
158
+ signal: Signal,
159
+ opts: { wait?: boolean } = {},
160
+ ): Promise<TrackResult> {
161
+ const body = signalToWire(signal);
162
+ // Assign the idempotency key ONCE, here — before the signal is buffered or
163
+ // sent — so the SAME key rides every retry (Transport-level replay AND a
164
+ // re-flush of the buffered body). That is what makes a retried signal
165
+ // dedup server-side instead of double-recording (see idempotency.ts, AD-4).
166
+ const idempotencyKey =
167
+ typeof body.idempotencyKey === "string" ? body.idempotencyKey : newKey();
168
+ body.idempotencyKey = idempotencyKey;
169
+
170
+ // The key is stamped back onto the SIGNAL, not just the body, because the
171
+ // signal is what `onError`/`onDrop` receive. Without this, a caller writing
172
+ // the documented dead-letter path — catch the drop, persist it, resend
173
+ // later — would resend with a freshly generated key, and the server would
174
+ // treat it as a second event and bill it twice. The one mechanism that
175
+ // prevents duplicates has to be reachable from the one hook that handles
176
+ // failures.
177
+ const tracked: Signal = { ...signal, idempotencyKey };
178
+
179
+ const path = SIGNAL_PATHS[signal.type];
180
+
181
+ const sendNow = opts.wait || this.cfg.mode === "sync";
182
+ if (!sendNow) {
183
+ // The path is buffered WITH the body. The flusher used to hardcode one
184
+ // endpoint for everything; now that each meter has its own, a buffered
185
+ // signal has to remember where it was going or async mode would quietly
186
+ // keep posting to a route sync mode has already left behind.
187
+ // A closed client or a full queue discards the signal. Saying
188
+ // "buffered" there would hand back a success-shaped result for a
189
+ // billable event that no longer exists.
190
+ const buffered = this.flusher.enqueue({ path, body, signal: tracked });
191
+ return buffered
192
+ ? { status: "buffered", queued: true }
193
+ : { status: "dropped", queued: false };
194
+ }
195
+
196
+ const res = await this.transport.request<
197
+ SignalResponse & { usageLog?: TrackResult["usageLog"] }
198
+ >({
199
+ method: "POST",
200
+ path,
201
+ body,
202
+ // Best-effort metering opts into retries on transient failures.
203
+ retry: true,
204
+ idempotencyKey,
205
+ });
206
+ // The server answers one of two ways for a real record, and we must not
207
+ // flatten them: `202 { queued: true, messageId, rawId }` when it hands the
208
+ // signal to its durable queue (priced later by a worker — no log yet), or
209
+ // `200 { usageLog, rawId }` when it prices inline. Either way `rawId` is
210
+ // the handle on a written, unlosable row.
211
+ const queued = res.queued === true;
212
+ return {
213
+ status: queued ? "accepted" : "recorded",
214
+ queued,
215
+ usageLog: res.usageLog ?? null,
216
+ ...handles(res),
217
+ };
218
+ }
219
+
220
+ /** Meter a named credit. `POST /api/v1/signal/credit`. */
221
+ credit(input: CreditInput, opts?: { wait?: boolean }): Promise<TrackResult> {
222
+ return this.track({ ...input, type: "credit" }, opts);
223
+ }
224
+
225
+ /** Debit the customer's wallet at model cost. `POST /api/v1/signal/wallet`. */
226
+ wallet(input: WalletInput, opts?: { wait?: boolean }): Promise<TrackResult> {
227
+ return this.track({ ...input, type: "wallet" }, opts);
228
+ }
229
+
230
+ /** Advance one step of an outcome workflow. `POST /api/v1/signal/outcome`. */
231
+ outcome(
232
+ input: OutcomeInput,
233
+ opts?: { wait?: boolean },
234
+ ): Promise<TrackResult> {
235
+ return this.track({ ...input, type: "outcome" }, opts);
236
+ }
237
+
238
+ /**
239
+ * Recent usage logs + totals for a customer. `GET /api/v1/usage`.
240
+ *
241
+ * Still the legacy path, deliberately. The `/api/v1/signal/*` family is
242
+ * POST-only — it replaced how usage is INGESTED, not how it is read — so
243
+ * `GET /api/v1/usage` has no successor and is not deprecated. It is also the
244
+ * only way to prove a signal landed, which matters more now that ingestion
245
+ * answers `202` for everything.
246
+ */
247
+ async list(params: {
248
+ customerId: string;
249
+ limit?: number;
250
+ }): Promise<UsageList> {
251
+ const res = await this.transport.request<UsageList>({
252
+ method: "GET",
253
+ path: "/api/v1/usage",
254
+ query: { customerId: params.customerId, limit: params.limit },
255
+ });
256
+ return { customer: res.customer, totals: res.totals, logs: res.logs };
257
+ }
258
+
259
+ /**
260
+ * Record one unit-metered event (a fixed-price meter with no tokens),
261
+ * addressed to a catalog unit by its `agentKey`. Sent immediately — unit
262
+ * events are not buffered. `POST /api/v1/signal/unit`.
263
+ *
264
+ * Two things changed with the move off `POST /api/v1/units`, and both show up
265
+ * in the return type. The route is now durable (the report lands in an inbox
266
+ * before it is priced, so a failure parks as Pending and can be retried
267
+ * instead of losing a billable event) and it answers `202` when it queues —
268
+ * so this resolves {@link UnitTrackResult}, not a bare `{ unitUsage }`.
269
+ * Branch on `queued`.
270
+ */
271
+ async unit(input: UnitInput): Promise<UnitTrackResult> {
272
+ const idempotencyKey = input.idempotencyKey ?? newKey();
273
+ const res = await this.transport.request<
274
+ SignalResponse & { unitUsage?: SerializedUnitUsage }
275
+ >({
276
+ method: "POST",
277
+ path: UNIT_PATH,
278
+ idempotencyKey,
279
+ body: {
280
+ customerId: input.customerId,
281
+ agentKey: input.agentKey,
282
+ ...(input.quantity != null ? { quantity: input.quantity } : {}),
283
+ ...(input.member ? { member: input.member } : {}),
284
+ ...(input.composite
285
+ ? { composite: { [input.composite.ref]: input.composite.value } }
286
+ : {}),
287
+ // Assigned HERE, before the send, so the SAME key rides every
288
+ // transport-level retry below. Without it a single 500 followed by a
289
+ // success is TWO un-keyed reports, and the server dedups on the key —
290
+ // so it bills the allowance down twice, up to `retry.maxAttempts`
291
+ // times. The route gained idempotency precisely to stop that; not
292
+ // using it by default would leave the double-charge reachable from the
293
+ // client instead of the server.
294
+ idempotencyKey,
295
+ },
296
+ // Best-effort metering opts into retries on transient failures.
297
+ retry: true,
298
+ });
299
+ const queued = res.queued === true;
300
+ return {
301
+ status: queued ? "accepted" : "recorded",
302
+ queued,
303
+ unitUsage: res.unitUsage ?? null,
304
+ ...handles(res),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Close ONE occurrence of a composite — a distinct tag value.
310
+ * `POST /api/v1/signal/composite/complete`.
311
+ *
312
+ * A composite bundles several credits / outcomes / units and is billed as one
313
+ * thing. The occurrence opens on the first tagged signal and costs nothing;
314
+ * this call is the moment it is billed. Only your code knows when the
315
+ * workflow ended, so this is a DECLARATION, not a hint: tagged signals may
316
+ * arrive in any order and the occurrence stays open and unbilled until you
317
+ * say otherwise. Put it on the line before your own `return`.
318
+ *
319
+ * EVERY composite needs this, outcome-backed ones included. An outcome's own
320
+ * `complete: true` closes its RUN and nothing else — it does not close the
321
+ * composite occurrence the run was tagged into, whatever mix of credits,
322
+ * outcomes and units that composite wraps. (Earlier versions of this SDK
323
+ * documented the opposite. Believing it means the occurrence stays ONGOING
324
+ * forever and is never billed, so if you tag outcome signals with a
325
+ * `composite`, you must still call this.)
326
+ *
327
+ * Closing twice is safe: the second call answers `ALREADY_COMPLETED` and
328
+ * cannot bill again. Calling it EARLY is also safe — before every entitlement
329
+ * the composite is restricted to has reported, or while an ADVANCE pool is
330
+ * full — the server parks the ping and applies it when the blocker clears,
331
+ * rather than failing it.
332
+ */
333
+ async completeComposite(
334
+ input: CompositeCompletionInput,
335
+ ): Promise<CompositeCompletionResult> {
336
+ const idempotencyKey = input.idempotencyKey ?? newKey();
337
+ const res = await this.transport.request<
338
+ SignalResponse & {
339
+ status?: "COMPLETED" | "ALREADY_COMPLETED";
340
+ composite?: CompositeCompletionResult["composite"];
341
+ value?: string | null;
342
+ }
343
+ >({
344
+ method: "POST",
345
+ path: COMPOSITE_COMPLETE_PATH,
346
+ idempotencyKey,
347
+ body: {
348
+ customerId: input.customerId,
349
+ composite: { [input.composite.ref]: input.composite.value },
350
+ // Auto-assigned like the others. A retry here could not double-BILL
351
+ // even without it — closing a closed occurrence is a server-side
352
+ // no-op — but that safety comes from occurrence-level dedup, not from
353
+ // idempotency, and an un-keyed replay still writes a redundant inbox
354
+ // row. Keying it collapses the retry into the one attempt it is.
355
+ idempotencyKey,
356
+ },
357
+ retry: true,
358
+ });
359
+ const queued = res.queued === true;
360
+ return {
361
+ delivery: queued ? "accepted" : "recorded",
362
+ queued,
363
+ ...(res.status != null ? { status: res.status } : {}),
364
+ ...(res.composite !== undefined ? { composite: res.composite } : {}),
365
+ ...(res.value !== undefined ? { value: res.value } : {}),
366
+ ...handles(res),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * A customer's unit balances + recent unit events. `GET /api/v1/units`.
372
+ *
373
+ * Legacy path for the same reason as {@link list}: the signal family is
374
+ * POST-only, so the read side has no successor and is not deprecated.
375
+ */
376
+ unitUsage(params: {
377
+ customerId: string;
378
+ limit?: number;
379
+ }): Promise<UnitUsageList> {
380
+ return this.transport.request<UnitUsageList>({
381
+ method: "GET",
382
+ path: "/api/v1/units",
383
+ query: { customerId: params.customerId, limit: params.limit },
384
+ });
385
+ }
386
+ }
@@ -0,0 +1,76 @@
1
+ import { activeQuery, filterByActive } from "../filters.js";
2
+ import type { Transport } from "../http.js";
3
+ import type {
4
+ CreateUnitInput,
5
+ Unit,
6
+ UnitDetail,
7
+ UpdateUnitInput,
8
+ } from "../types.js";
9
+
10
+ /**
11
+ * The `units` resource — manage the org's unit *types* (the catalog): named,
12
+ * quantity-priced meters (FLAT / SLAB / VOLUME). Record consumption against a
13
+ * unit with `signals.unit()`. `/api/v1/units-catalog`.
14
+ */
15
+ export class Units {
16
+ constructor(private readonly transport: Transport) {}
17
+
18
+ /** List unit types. Pass `{ active: true }` to only return active ones.
19
+ * `{ active: false }` returns only archived ones; omitting it returns
20
+ * both. See `activeQuery` — the API itself only supports `true`. */
21
+ async list(params: { active?: boolean } = {}): Promise<Unit[]> {
22
+ const res = await this.transport.request<{ units: Unit[] }>({
23
+ method: "GET",
24
+ path: "/api/v1/units-catalog",
25
+ query: { active: activeQuery(params.active) },
26
+ });
27
+ return filterByActive(res.units, params.active);
28
+ }
29
+
30
+ /** Create a unit type. `POST /api/v1/units-catalog`. */
31
+ async create(input: CreateUnitInput): Promise<Unit> {
32
+ const res = await this.transport.request<{ unit: Unit }>({
33
+ method: "POST",
34
+ path: "/api/v1/units-catalog",
35
+ body: input,
36
+ });
37
+ return res.unit;
38
+ }
39
+
40
+ /** Fetch one unit type with stats. `GET /api/v1/units-catalog/:id`. */
41
+ async get(id: string): Promise<UnitDetail> {
42
+ const res = await this.transport.request<{ unit: UnitDetail }>({
43
+ method: "GET",
44
+ path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
45
+ });
46
+ return res.unit;
47
+ }
48
+
49
+ /** Update a unit type's definition. `PATCH /api/v1/units-catalog/:id`. */
50
+ async update(id: string, input: UpdateUnitInput): Promise<Unit> {
51
+ const res = await this.transport.request<{ unit: Unit }>({
52
+ method: "PATCH",
53
+ path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
54
+ body: input,
55
+ });
56
+ return res.unit;
57
+ }
58
+
59
+ /** Activate or deactivate a unit type. */
60
+ async setActive(id: string, isActive: boolean): Promise<Unit> {
61
+ const res = await this.transport.request<{ unit: Unit }>({
62
+ method: "PATCH",
63
+ path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
64
+ body: { isActive },
65
+ });
66
+ return res.unit;
67
+ }
68
+
69
+ /** Delete a unit type. */
70
+ async delete(id: string): Promise<void> {
71
+ await this.transport.request({
72
+ method: "DELETE",
73
+ path: `/api/v1/units-catalog/${encodeURIComponent(id)}`,
74
+ });
75
+ }
76
+ }
@@ -0,0 +1,37 @@
1
+ import type { Transport } from "../http.js";
2
+ import type { ThresholdKind, UpsertWebhookInput, Webhook } from "../types.js";
3
+
4
+ /**
5
+ * The `webhooks` resource — threshold alerts. At most one webhook per kind
6
+ * (CREDIT / OUTCOME / WALLET); posting a kind again upserts it. `/api/v1/webhooks`.
7
+ */
8
+ export class Webhooks {
9
+ constructor(private readonly transport: Transport) {}
10
+
11
+ /** List configured webhooks. `GET /api/v1/webhooks`. */
12
+ async list(): Promise<Webhook[]> {
13
+ const res = await this.transport.request<{ webhooks: Webhook[] }>({
14
+ method: "GET",
15
+ path: "/api/v1/webhooks",
16
+ });
17
+ return res.webhooks;
18
+ }
19
+
20
+ /** Create or replace the webhook for a kind. `POST /api/v1/webhooks`. */
21
+ async upsert(input: UpsertWebhookInput): Promise<Webhook> {
22
+ const res = await this.transport.request<{ webhook: Webhook }>({
23
+ method: "POST",
24
+ path: "/api/v1/webhooks",
25
+ body: input,
26
+ });
27
+ return res.webhook;
28
+ }
29
+
30
+ /** Delete the webhook for a kind (idempotent). `DELETE /api/v1/webhooks/:kind`. */
31
+ async delete(kind: ThresholdKind): Promise<void> {
32
+ await this.transport.request({
33
+ method: "DELETE",
34
+ path: `/api/v1/webhooks/${encodeURIComponent(kind)}`,
35
+ });
36
+ }
37
+ }
@@ -0,0 +1,33 @@
1
+ import { activeQuery, filterByActive } from "../filters.js";
2
+ import type { Transport } from "../http.js";
3
+ import type { Model, WorkspaceIdentity } from "../types.js";
4
+
5
+ /**
6
+ * The `workspace` resource — read-only introspection of the org behind the API
7
+ * key. Grounds an integration (which workspace, and is it `sandbox` or `live`?)
8
+ * and lists the models it may meter against. `/api/v1/me`, `/api/v1/models`.
9
+ */
10
+ export class Workspace {
11
+ constructor(private readonly transport: Transport) {}
12
+
13
+ /** Identify the org + key behind the request, incl. `sandbox` | `live`. */
14
+ me(): Promise<WorkspaceIdentity> {
15
+ return this.transport.request<WorkspaceIdentity>({
16
+ method: "GET",
17
+ path: "/api/v1/me",
18
+ });
19
+ }
20
+
21
+ /** The org's enabled models with live prices. `{ active: true }` returns only
22
+ * the models that can be metered right now.
23
+ * `{ active: false }` returns only archived ones; omitting it returns
24
+ * both. See `activeQuery` — the API itself only supports `true`. */
25
+ async models(params: { active?: boolean } = {}): Promise<Model[]> {
26
+ const res = await this.transport.request<{ models: Model[] }>({
27
+ method: "GET",
28
+ path: "/api/v1/models",
29
+ query: { active: activeQuery(params.active) },
30
+ });
31
+ return filterByActive(res.models, params.active);
32
+ }
33
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { ClockNextError } from "./errors.js";
2
+
3
+ /**
4
+ * Retry classification + backoff. Mirrors the convergent industry policy
5
+ * (Stripe / Stainless clients): retry transient transport failures
6
+ * (network, timeout, 408/409/429/5xx); never retry deterministic 4xx
7
+ * (400/401/404 and 422 validation/plan/allowance). Exponential backoff with
8
+ * equal jitter, honoring a server `Retry-After`. The Transport additionally
9
+ * gates retries by request safety (GET/DELETE, or a write that explicitly
10
+ * opts in).
11
+ */
12
+
13
+ /** Whether an error should be retried. NetworkError/Server/RateLimit/Conflict
14
+ * carry `retryable: true`; everything else is permanent. */
15
+ export function isRetryable(error: unknown): boolean {
16
+ return error instanceof ClockNextError && error.retryable;
17
+ }
18
+
19
+ /** Parses `Retry-After` (seconds or HTTP-date) and `retry-after-ms` headers. */
20
+ export function parseRetryAfter(headers: Headers): number | undefined {
21
+ const ms = headers.get("retry-after-ms");
22
+ if (ms) {
23
+ const n = Number(ms);
24
+ if (Number.isFinite(n) && n >= 0) return n;
25
+ }
26
+ const ra = headers.get("retry-after");
27
+ if (ra) {
28
+ const secs = Number(ra);
29
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
30
+ const date = Date.parse(ra);
31
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * Backoff for a given attempt (1-based). Exponential `base * 2^(attempt-1)`,
38
+ * capped at `maxDelayMs`, with equal jitter (×[0.5, 1.0]). A server-provided
39
+ * `retryAfterMs` overrides the computed value.
40
+ */
41
+ export function computeBackoff(
42
+ attempt: number,
43
+ opts: { baseDelayMs: number; maxDelayMs: number },
44
+ retryAfterMs?: number,
45
+ rand: () => number = Math.random,
46
+ ): number {
47
+ if (retryAfterMs != null) return Math.min(retryAfterMs, opts.maxDelayMs);
48
+ const raw = opts.baseDelayMs * Math.pow(2, Math.max(0, attempt - 1));
49
+ const capped = Math.min(raw, opts.maxDelayMs);
50
+ // Equal jitter: a random point in [0.5, 1.0] of the capped delay.
51
+ return Math.round(capped * (0.5 + rand() * 0.5));
52
+ }
53
+
54
+ export function sleep(ms: number): Promise<void> {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }