@ziffer-io/client 0.1.1 → 0.2.1

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.
package/dist/client.js CHANGED
@@ -43,7 +43,30 @@
43
43
  * - A poll that outlives its deadline throws {@link WaitTimeout}. A timeout
44
44
  * is "no answer yet", never "the answer was no" (ingress-low's rule for
45
45
  * its own dependency, one layer out).
46
+ * - A call that was still retrying when the caller's deadline arrived throws
47
+ * {@link DeadlineExceeded}, carrying the last failure's name and status.
48
+ * Inside {@link ZifferClient.wait} it is caught and re-thrown as
49
+ * {@link WaitTimeout} with that `DeadlineExceeded` as its `cause`: `wait`
50
+ * has ONE name for "no answer yet" and a caller already catches it.
51
+ *
52
+ * # Timeouts and retries
53
+ *
54
+ * Every round trip carries an abort signal set to {@link REQUEST_TIMEOUT_MS}.
55
+ * **That timeout covers ONE ROUND TRIP** — the send and the reading of the
56
+ * answer's body — and is not a budget for the call: a call that retries may
57
+ * take several times as long and is not wrong for doing so. Waiting for a
58
+ * human to approve is not a round trip at all; that is {@link
59
+ * ZifferClient.wait}, which polls, and each poll is its own round trip under
60
+ * its own timeout.
61
+ *
62
+ * There is ONE request path and the retry loop is inside it, so `propose`,
63
+ * `decision` and every poll of `wait` retry under exactly the same rules
64
+ * (`retry.ts` holds them, `retry.test.ts` replays the corpus both SDKs share).
65
+ * The bytes are serialised once, above the loop, and the same bytes are
66
+ * resent: the gateway keys a pending hold on the hash of what it received, so
67
+ * identical bytes land on the same hold instead of opening a second one.
46
68
  */
69
+ import { DeadlineExceeded, newCall, parseRetryAfterSeconds, REQUEST_TIMEOUT_MS, RETRY_AFTER_HEADER, RetryPolicy, } from './retry.js';
47
70
  // ---------------------------------------------------------------- key expiry
48
71
  /**
49
72
  * The header every SUCCESSFUL answer carries (ACP-256 §5): the instant the key
@@ -98,6 +121,39 @@ function noteKeyExpiry(value) {
98
121
  "(tools/mint-api-key.py --rotate <this key's key_hash>), deploy it, then have this one revoked");
99
122
  }
100
123
  }
124
+ const REFUSAL_CATEGORIES = [
125
+ 'PolicyRefused',
126
+ 'PolicyBasisMoved',
127
+ 'RateBounded',
128
+ 'ProposalMalformed',
129
+ ];
130
+ function isRefusalCategory(v) {
131
+ return REFUSAL_CATEGORIES.some((c) => c === v);
132
+ }
133
+ /**
134
+ * The two optional members ACP-402 added to a decision and to a list item,
135
+ * read once: `refusal_category` (RF-4) and `held_until` (DR-15/DR-16: the
136
+ * action is held and no receipt is readable until it releases). An absent
137
+ * member is ABSENT, never `null`.
138
+ */
139
+ function refusalAndHold(raw, where) {
140
+ let out = {};
141
+ if ('refusal_category' in raw) {
142
+ const category = raw['refusal_category'];
143
+ if (!isRefusalCategory(category)) {
144
+ throw new ResponseMalformed(`${where} refusal_category is not one of RF-4's four`);
145
+ }
146
+ out = { ...out, refusal_category: category };
147
+ }
148
+ if ('held_until' in raw) {
149
+ const heldUntil = raw['held_until'];
150
+ if (typeof heldUntil !== 'string') {
151
+ throw new ResponseMalformed(`${where} held_until is not a string`);
152
+ }
153
+ out = { ...out, held_until: heldUntil };
154
+ }
155
+ return out;
156
+ }
101
157
  // ------------------------------------------------------------ error surface
102
158
  /** §1 names, exported so callers and tests never retype the strings. The set
103
159
  * is OPEN — the gateway may name more; {@link ApiRefusal} carries any name
@@ -107,6 +163,22 @@ export const ERROR_TENANT_MISMATCH = 'TenantMismatch';
107
163
  export const ERROR_PROPOSAL_MALFORMED = 'ProposalMalformed';
108
164
  export const ERROR_DECISION_UNKNOWN = 'DecisionUnknown';
109
165
  export const ERROR_ADMISSION_UNAVAILABLE = 'AdmissionUnavailable';
166
+ /** ACP-356. ONE name for every way the list's query string is not a legal
167
+ * one — which parameter, and why, is deliberately not said. */
168
+ export const ERROR_LIST_QUERY_MALFORMED = 'ListQueryMalformed';
169
+ /** ACP-392. `POST /v1/feedback`'s four, in the gateway's own spelling
170
+ * (`services/gateway/src/gateway.rs::error_name`). They are exported for the
171
+ * same reason the five above are: a caller branching on a refusal should
172
+ * import the string rather than type it. */
173
+ export const ERROR_FEEDBACK_MALFORMED = 'FeedbackMalformed';
174
+ export const ERROR_FEEDBACK_TOO_LARGE = 'FeedbackTooLarge';
175
+ export const ERROR_FEEDBACK_RATE_LIMITED = 'FeedbackRateLimited';
176
+ export const ERROR_FEEDBACK_UNAVAILABLE = 'FeedbackUnavailable';
177
+ /** ACP-422. This key's quota on `propose`, `decision`, `list` and `whoami` is
178
+ * spent: 429 with `Retry-After`, which the retry policy already obeys
179
+ * (ACP-355). A different name from {@link ERROR_FEEDBACK_RATE_LIMITED}
180
+ * because it is a different allowance. */
181
+ export const ERROR_RATE_LIMITED = 'RateLimited';
110
182
  /**
111
183
  * The gateway answered, and the answer was a named refusal. `error` is the
112
184
  * gateway's name, verbatim — the machine-readable half, as `Refusal.clause`
@@ -138,11 +210,19 @@ export class ResponseMalformed extends Error {
138
210
  }
139
211
  }
140
212
  /** The deadline passed with the decision still `pending`. Not a refusal and
141
- * not an answer — the decision may still decide; the id remains fetchable. */
213
+ * not an answer — the decision may still decide; the id remains fetchable.
214
+ *
215
+ * `options.cause` is how the reason survives the rename. A poll that ran out
216
+ * of retry budget produced a {@link DeadlineExceeded} naming the last failure
217
+ * and its status; `wait` reports the event under the name its caller catches
218
+ * and hands the original through as `cause`, so nothing the caller could have
219
+ * learned from the poll is thrown away to keep one name at the surface. A
220
+ * rename that dropped the reason would make "no answer yet" indistinguishable
221
+ * from "the gateway was shedding for thirty seconds". */
142
222
  export class WaitTimeout extends Error {
143
223
  decisionId;
144
- constructor(decisionId, timeoutMs) {
145
- super(`decision ${decisionId} still pending after ${timeoutMs}ms`);
224
+ constructor(decisionId, timeoutMs, options) {
225
+ super(`decision ${decisionId} still pending after ${timeoutMs}ms`, options);
146
226
  this.name = 'WaitTimeout';
147
227
  this.decisionId = decisionId;
148
228
  }
@@ -166,6 +246,23 @@ function isRecord(v) {
166
246
  * is NEVER in the POST response — one place serves receipts — and a client
167
247
  * that tolerated one there would quietly stand up a second serving place.
168
248
  */
249
+ /** The feedback answer, narrowed with no cast (ACP-392). A 200 whose body is
250
+ * not this shape is NOT reported as stored: "the gateway answered something"
251
+ * and "your message is on a disk an operator reads" are different facts, and
252
+ * only the second is what the caller asked for. */
253
+ function feedbackFromBody(body) {
254
+ if (!isRecord(body)) {
255
+ throw new ResponseMalformed('feedback response is not a JSON object');
256
+ }
257
+ if (body['stored'] !== true) {
258
+ throw new ResponseMalformed('feedback response does not say the message was stored');
259
+ }
260
+ const tenant = body['tenant'];
261
+ if (typeof tenant !== 'string' || tenant.length === 0) {
262
+ throw new ResponseMalformed('feedback response names no tenant');
263
+ }
264
+ return { stored: true, tenant };
265
+ }
169
266
  function decisionFromBody(body, receiptAllowed) {
170
267
  if (!isRecord(body)) {
171
268
  throw new ResponseMalformed('decision response is not a JSON object');
@@ -189,15 +286,7 @@ function decisionFromBody(body, receiptAllowed) {
189
286
  }
190
287
  decision = { ...decision, outcome };
191
288
  }
192
- if ('clause' in body) {
193
- const clause = body['clause'];
194
- if (typeof clause !== 'string') {
195
- // Includes null: an absent clause is spelled ABSENT (§1's parity with
196
- // ingress-low) — accepting null here would admit the second encoding.
197
- throw new ResponseMalformed('decision clause is not a string');
198
- }
199
- decision = { ...decision, clause };
200
- }
289
+ decision = { ...decision, ...refusalAndHold(body, 'decision') };
201
290
  if ('receipt' in body) {
202
291
  if (!receiptAllowed) {
203
292
  throw new ResponseMalformed('receipt in a POST response: one place serves receipts (GET)');
@@ -206,6 +295,120 @@ function decisionFromBody(body, receiptAllowed) {
206
295
  }
207
296
  return decision;
208
297
  }
298
+ /**
299
+ * Narrow the `GET /v1/whoami` body, refusing by name on any departure.
300
+ *
301
+ * BOTH MEMBERS ARE REQUIRED. There is no optional half of an identity, and a
302
+ * tolerated missing `key_expires_at` would surface as `undefined` in whatever
303
+ * a caller prints — which reads as "no expiry" and is the one wrong reading of
304
+ * a credential that ends.
305
+ */
306
+ function identityFromBody(body) {
307
+ if (!isRecord(body)) {
308
+ throw new ResponseMalformed('whoami response is not a JSON object');
309
+ }
310
+ const tenant = body['tenant_id'];
311
+ if (typeof tenant !== 'string' || tenant.length === 0) {
312
+ throw new ResponseMalformed('whoami response carries no tenant_id');
313
+ }
314
+ const expires = body['key_expires_at'];
315
+ if (typeof expires !== 'string' || expires.length === 0) {
316
+ throw new ResponseMalformed('whoami response carries no key_expires_at');
317
+ }
318
+ return { tenant_id: tenant, key_expires_at: expires };
319
+ }
320
+ function isReceiptPresence(v) {
321
+ return v === 'attached' || v === 'absent';
322
+ }
323
+ /**
324
+ * Narrow one list body, refusing by name on any departure — never repairing
325
+ * one. `next_cursor` must be PRESENT: `null` is the end, an absent member is a
326
+ * server that did not say, and a client that read the two as one would stop
327
+ * paging early and report a customer's list as shorter than it is.
328
+ */
329
+ function pageFromBody(body) {
330
+ if (!isRecord(body)) {
331
+ throw new ResponseMalformed('list response is not a JSON object');
332
+ }
333
+ const rawItems = body['items'];
334
+ if (!Array.isArray(rawItems)) {
335
+ throw new ResponseMalformed('list response carries no items array');
336
+ }
337
+ if (!('next_cursor' in body)) {
338
+ throw new ResponseMalformed('list response carries no next_cursor: null is the end, absent is silence');
339
+ }
340
+ const rawCursor = body['next_cursor'];
341
+ if (rawCursor !== null && typeof rawCursor !== 'string') {
342
+ throw new ResponseMalformed('next_cursor is neither a string nor null');
343
+ }
344
+ const items = rawItems.map((raw) => {
345
+ if (!isRecord(raw)) {
346
+ throw new ResponseMalformed('list item is not a JSON object');
347
+ }
348
+ const id = raw['decision_id'];
349
+ const status = raw['status'];
350
+ const receipt = raw['receipt'];
351
+ const createdAt = raw['created_at'];
352
+ const waiting = raw['waiting'];
353
+ if (typeof id !== 'string' || id.length === 0) {
354
+ throw new ResponseMalformed('list item carries no decision_id');
355
+ }
356
+ if (!isStatus(status)) {
357
+ throw new ResponseMalformed('list item status is not "pending" or "decided"');
358
+ }
359
+ if (!isReceiptPresence(receipt)) {
360
+ throw new ResponseMalformed('list item receipt is not "attached" or "absent"');
361
+ }
362
+ if (typeof createdAt !== 'string') {
363
+ throw new ResponseMalformed('list item created_at is not a string');
364
+ }
365
+ if (typeof waiting !== 'boolean') {
366
+ throw new ResponseMalformed('list item waiting is not a boolean');
367
+ }
368
+ let item = {
369
+ decision_id: id,
370
+ status,
371
+ receipt,
372
+ created_at: createdAt,
373
+ waiting,
374
+ };
375
+ if ('outcome' in raw) {
376
+ const outcome = raw['outcome'];
377
+ if (!isOutcome(outcome)) {
378
+ throw new ResponseMalformed('list item outcome is not ALLOW, ATTEST or DENY');
379
+ }
380
+ item = { ...item, outcome };
381
+ }
382
+ if ('expires_at' in raw) {
383
+ const expiresAt = raw['expires_at'];
384
+ if (typeof expiresAt !== 'string') {
385
+ // Includes null: an absent window is spelled ABSENT.
386
+ throw new ResponseMalformed('list item expires_at is not a string');
387
+ }
388
+ item = { ...item, expires_at: expiresAt };
389
+ }
390
+ return { ...item, ...refusalAndHold(raw, 'list item') };
391
+ });
392
+ return { items, next_cursor: rawCursor };
393
+ }
394
+ /** The global `fetch`, behind {@link FetchLike}. Not `globalThis.fetch`
395
+ * captured at module load: a test that replaces the global would then be
396
+ * replacing something this module already copied. */
397
+ const globalFetch = (url, init) => fetch(url, init);
398
+ /** The real sleep. */
399
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
400
+ /**
401
+ * Whatever `fetch` (or the body stream) threw, as an `Error`. A thrown
402
+ * non-Error is wrapped rather than re-thrown as-is, because {@link
403
+ * DeadlineExceeded} reports the last failure's NAME and a thrown string has
404
+ * none — the operator would be told the deadline passed and nothing else.
405
+ */
406
+ function transportError(thrown, method, path) {
407
+ if (thrown instanceof Error) {
408
+ return thrown;
409
+ }
410
+ return new Error(`${method} ${path} failed with a non-Error: ${String(thrown)}`);
411
+ }
209
412
  /**
210
413
  * The client. One instance per (gateway, key); the KEY determines the tenant
211
414
  * server-side (§1), so there is nothing tenant-shaped to configure here —
@@ -214,7 +417,15 @@ function decisionFromBody(body, receiptAllowed) {
214
417
  export class ZifferClient {
215
418
  baseUrl;
216
419
  apiKey;
217
- constructor(baseUrl, apiKey) {
420
+ timeoutMs;
421
+ fetchImpl;
422
+ now;
423
+ sleep;
424
+ random;
425
+ /** R6/R9: per CLIENT INSTANCE, as the rules say. Two clients do not share a
426
+ * retry budget, and one client's `propose` and `wait` do. */
427
+ retry;
428
+ constructor(baseUrl, apiKey, options) {
218
429
  if (baseUrl.length === 0) {
219
430
  throw new TypeError('ZifferClient: baseUrl is empty');
220
431
  }
@@ -224,28 +435,102 @@ export class ZifferClient {
224
435
  // whose log line points at the wrong component.
225
436
  throw new TypeError('ZifferClient: apiKey is empty');
226
437
  }
438
+ const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
439
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
440
+ throw new TypeError('ZifferClient: timeoutMs must be a positive number of milliseconds');
441
+ }
227
442
  this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
228
443
  this.apiKey = apiKey;
444
+ this.timeoutMs = timeoutMs;
445
+ this.fetchImpl = options?.fetch ?? globalFetch;
446
+ this.now = options?.now ?? Date.now;
447
+ this.sleep = options?.sleep ?? realSleep;
448
+ this.random = options?.random ?? Math.random;
449
+ this.retry = new RetryPolicy(options?.retryBucketInitial);
450
+ }
451
+ /**
452
+ * R9. The two retry counters and the bucket gauge, as a plain object taken
453
+ * at this instant. Cumulative over the client's life — a caller that wants
454
+ * a delta over one call reads it before and after.
455
+ */
456
+ get retryCounters() {
457
+ return this.retry.counters;
229
458
  }
230
459
  /**
231
460
  * POST /v1/proposals. The proposal is serialised as given — the caller's
232
461
  * values, no edits — and the answer never carries a receipt (§1): fetch it
233
462
  * with {@link decision} once decided.
463
+ *
464
+ * `opts.deadlineMs` bounds the RETRYING, not the round trip (R5).
465
+ */
466
+ async propose(proposal, opts) {
467
+ // R7: serialised ONCE, here, above the retry loop. `request` resends these
468
+ // same bytes on every attempt, so a resend lands on the hold the gateway
469
+ // already keyed on their hash rather than opening a second one. Moving
470
+ // this inside the loop is the defect, not a tidier place for it.
471
+ const body = JSON.stringify(proposal);
472
+ const parsed = await this.request('POST', '/v1/proposals', body, this.deadlineAt(opts?.deadlineMs));
473
+ return decisionFromBody(parsed, false);
474
+ }
475
+ /**
476
+ * POST /v1/feedback — tell ZIFFER what our documentation did not answer
477
+ * (ACP-392).
478
+ *
479
+ * THE TEXT LEAVES THE MACHINE. It is stored under the tenant this API key
480
+ * carries and read by an operator; nothing is filtered on the way, so
481
+ * anything put in `question` or `context` is anything an operator will
482
+ * read. The gateway refuses a member this shape does not define, which is
483
+ * what stops the object growing a field that carries more than a sentence.
484
+ *
485
+ * # ONE round trip and NO retry, unlike every other call on this client
486
+ *
487
+ * `request` retries a 429 and honours its `Retry-After`, which is right for
488
+ * work: a proposal that was refused for backpressure still has to happen.
489
+ * This is not work. A rate-limited feedback message is a message that will
490
+ * not be stored, and riding out a 60-second bucket would block the coding
491
+ * agent that called it for a minute to deliver a sentence. So the failure
492
+ * is raised as it arrives — [`ApiRefusal`] with the gateway's own name —
493
+ * and the caller decides.
234
494
  */
235
- async propose(proposal) {
236
- const body = await this.request('POST', '/v1/proposals', JSON.stringify(proposal));
237
- return decisionFromBody(body, false);
495
+ async feedback(message) {
496
+ if (message.tool.length === 0 || message.question.length === 0) {
497
+ // Refused here rather than sent, `apiKey`'s rule in the constructor: an
498
+ // empty question is a caller bug, and mailing it converts a local
499
+ // defect into a remote 400.
500
+ throw new TypeError('ZifferClient.feedback: tool and question must both be non-empty');
501
+ }
502
+ const body = {
503
+ tool: message.tool,
504
+ question: message.question,
505
+ };
506
+ // Absent means ABSENT, never present-and-undefined: `JSON.stringify`
507
+ // would drop an undefined member anyway, and relying on that would make
508
+ // the wire shape depend on a serialiser's behaviour rather than on this
509
+ // object.
510
+ if (message.context !== undefined) {
511
+ body['context'] = message.context;
512
+ }
513
+ const trip = await this.roundTrip('POST', '/v1/feedback', JSON.stringify(body));
514
+ if (trip.kind !== 'ok') {
515
+ throw trip.error;
516
+ }
517
+ return feedbackFromBody(trip.value);
238
518
  }
239
519
  /**
240
520
  * GET /v1/decisions/{id}. `receipt` is present iff a signed receipt
241
521
  * exists; hand it to `verifyReceipt` with your OWN copy of the proposal
242
522
  * bytes — the id proves nothing (T), the recomputed hash is the binding.
243
523
  */
244
- async decision(id) {
524
+ async decision(id, opts) {
245
525
  if (id.length === 0) {
246
526
  throw new TypeError('ZifferClient.decision: id is empty');
247
527
  }
248
- const body = await this.request('GET', `/v1/decisions/${encodeURIComponent(id)}`, null);
528
+ return this.fetchDecision(id, this.deadlineAt(opts?.deadlineMs));
529
+ }
530
+ /** {@link decision}, with the deadline already resolved to an instant —
531
+ * which is what {@link wait} has and a caller does not. */
532
+ async fetchDecision(id, deadlineAtMs) {
533
+ const body = await this.request('GET', `/v1/decisions/${encodeURIComponent(id)}`, null, deadlineAtMs);
249
534
  const d = decisionFromBody(body, true);
250
535
  if (d.decision_id !== id) {
251
536
  // The id is a locator, but an answer ABOUT A DIFFERENT LOCATOR is not
@@ -255,11 +540,59 @@ export class ZifferClient {
255
540
  }
256
541
  return d;
257
542
  }
543
+ /**
544
+ * GET /v1/whoami — which customer this key is bound to, and when it ends.
545
+ *
546
+ * The one call that asks about the CREDENTIAL rather than about a decision.
547
+ * It sends no body and takes no argument: there is nothing to name, because
548
+ * the key is the question. A dead key — expired, revoked or never minted —
549
+ * is one `ApiRefusal` (`ApiKeyUnknown`, 401) and the three cannot be told
550
+ * apart, which is deliberate at the gateway and is not this client's to
551
+ * undo.
552
+ */
553
+ async whoami(opts) {
554
+ const body = await this.request('GET', '/v1/whoami', null, this.deadlineAt(opts?.deadlineMs));
555
+ return identityFromBody(body);
556
+ }
557
+ /**
558
+ * GET /v1/decisions — what ZIFFER holds and decided for THIS key.
559
+ *
560
+ * Every request waiting for approval (with when its hold ends) and every
561
+ * decision in the window, newest first. The tenant is the API key's and
562
+ * cannot be named any other way: there is no parameter for it and the
563
+ * gateway refuses one by name.
564
+ *
565
+ * Every bad parameter is one refusal, `ListQueryMalformed` (400). The API
566
+ * does not say which one, for the same reason an item carries no clause.
567
+ */
568
+ async list(opts) {
569
+ const params = new URLSearchParams();
570
+ if (opts?.since !== undefined) {
571
+ params.set('since', opts.since);
572
+ }
573
+ if (opts?.limit !== undefined) {
574
+ params.set('limit', String(opts.limit));
575
+ }
576
+ if (opts?.cursor !== undefined) {
577
+ params.set('cursor', opts.cursor);
578
+ }
579
+ const query = params.toString();
580
+ const body = await this.request('GET', query.length === 0 ? '/v1/decisions' : `/v1/decisions?${query}`, null, this.deadlineAt(opts?.deadlineMs));
581
+ return pageFromBody(body);
582
+ }
258
583
  /**
259
584
  * Poll {@link decision} until `decided` or the deadline. A `WaitTimeout`
260
585
  * is "no answer yet", never a verdict; every named refusal (404 included)
261
586
  * propagates immediately — retrying `DecisionUnknown` would be the client
262
587
  * deciding the server was wrong.
588
+ *
589
+ * A poll that runs out of budget surfaces as `WaitTimeout` too, and NOT as
590
+ * the `DeadlineExceeded` the request path raised. Both mean "the wait's own
591
+ * deadline arrived with the decision still pending"; which of the two a
592
+ * caller saw depended on whether the last poll happened to be mid-retry,
593
+ * which is a detail of the gateway's load and not of this API. The
594
+ * `DeadlineExceeded` rides along as `cause`, so the last failure's name and
595
+ * status are still there for whoever wants them.
263
596
  */
264
597
  async wait(id, opts) {
265
598
  const timeoutMs = opts?.timeoutMs ?? 30_000;
@@ -267,57 +600,167 @@ export class ZifferClient {
267
600
  if (timeoutMs <= 0 || intervalMs <= 0) {
268
601
  throw new TypeError('ZifferClient.wait: timeoutMs and intervalMs must be positive');
269
602
  }
270
- const deadline = Date.now() + timeoutMs;
603
+ const deadline = this.now() + timeoutMs;
271
604
  for (;;) {
272
- const d = await this.decision(id);
605
+ // R8: each poll is a request like any other, so R1..R6 hold per poll —
606
+ // including R5, under this wait's OWN deadline. Without that a gateway
607
+ // answering `Retry-After: 3600` on one poll would park a 30-second wait
608
+ // for an hour, and the caller's timeout would have meant nothing.
609
+ let d;
610
+ try {
611
+ d = await this.fetchDecision(id, deadline);
612
+ }
613
+ catch (thrown) {
614
+ if (thrown instanceof DeadlineExceeded) {
615
+ // The deadline the poll hit IS this wait's deadline — it is the only
616
+ // one `fetchDecision` was given above. So this is the event the loop
617
+ // below already throws `WaitTimeout` for, reached one branch earlier,
618
+ // and giving it a second name would make a caller catch two.
619
+ throw new WaitTimeout(id, timeoutMs, { cause: thrown });
620
+ }
621
+ throw thrown;
622
+ }
273
623
  if (d.status === 'decided') {
274
624
  return d;
275
625
  }
276
- if (Date.now() + intervalMs > deadline) {
626
+ if (this.now() + intervalMs > deadline) {
277
627
  throw new WaitTimeout(id, timeoutMs);
278
628
  }
279
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
629
+ await this.sleep(intervalMs);
630
+ }
631
+ }
632
+ /** R5: the caller's duration becomes an instant on this client's clock,
633
+ * once, at the start of the call. `undefined` in, `undefined` out — no
634
+ * deadline means no check, not a check against infinity. */
635
+ deadlineAt(deadlineMs) {
636
+ if (deadlineMs === undefined) {
637
+ return undefined;
638
+ }
639
+ if (!Number.isFinite(deadlineMs) || deadlineMs < 0) {
640
+ throw new TypeError('ZifferClient: deadlineMs must be a non-negative number of milliseconds');
641
+ }
642
+ return this.now() + deadlineMs;
643
+ }
644
+ /**
645
+ * THE request path: one loop, R1..R6, for every call this client makes.
646
+ *
647
+ * `body` arrives already serialised and is resent unchanged (R7). The loop
648
+ * owns nothing about the rules themselves — `retry.ts` decides, this
649
+ * sleeps, sends again, or throws what the last attempt produced.
650
+ */
651
+ async request(method, path, body, deadlineAtMs) {
652
+ const call = newCall();
653
+ for (;;) {
654
+ call.attempts += 1;
655
+ const trip = await this.roundTrip(method, path, body);
656
+ if (trip.kind === 'ok') {
657
+ return trip.value;
658
+ }
659
+ const step = this.retry.decide(trip.status, trip.retryAfterS, call, this.now(), deadlineAtMs, this.random);
660
+ if (step.kind === 'stop') {
661
+ // Nothing invented: the failure this attempt already produced, as it is.
662
+ throw trip.error;
663
+ }
664
+ if (step.kind === 'deadline') {
665
+ throw new DeadlineExceeded(trip.error.name, trip.status);
666
+ }
667
+ await this.sleep(step.ms);
280
668
  }
281
669
  }
282
670
  /**
283
- * One request, one parse, one narrowing. 2xx returns the parsed body for
284
- * the caller's guard; anything else must be §1's `{"error": name}` and
285
- * throws {@link ApiRefusal} with the name verbatim. A non-JSON or unnamed
286
- * error body throws {@link ResponseMalformed} — an intermediary's HTML 502
287
- * is not the gateway's answer and is never dressed up as one.
671
+ * ONE round trip: one request, one parse, one narrowing. 2xx returns the
672
+ * parsed body for the caller's guard; anything else must be §1's
673
+ * `{"error": name}` and yields {@link ApiRefusal} with the name verbatim. A
674
+ * non-JSON or unnamed error body yields {@link ResponseMalformed} — an
675
+ * intermediary's HTML 502 is not the gateway's answer and is never dressed
676
+ * up as one, though it IS retried, because 502 is retryable whoever wrote it.
677
+ *
678
+ * Nothing is thrown from here: the failure is RETURNED, because whether it
679
+ * becomes the caller's error is the retry loop's question and not this
680
+ * method's.
288
681
  */
289
- async request(method, path, body) {
682
+ async roundTrip(method, path, body) {
290
683
  const headers = {
291
684
  authorization: `Bearer ${this.apiKey}`,
292
685
  };
293
686
  if (body !== null) {
294
687
  headers['content-type'] = 'application/json';
295
688
  }
296
- const res = await fetch(`${this.baseUrl}${path}`, {
297
- method,
298
- headers,
299
- ...(body !== null ? { body } : {}),
300
- });
301
- const text = await res.text();
689
+ // R8: the signal covers the send AND the reading of the body — one round
690
+ // trip, not one call.
691
+ //
692
+ // An AbortController with a timer this method owns, and NOT
693
+ // `AbortSignal.timeout`: that helper's timer is held weakly, so a signal
694
+ // nothing else strongly references can be collected and the abort then
695
+ // never fires — a timeout that silently is not one. It was observed here,
696
+ // once in about fifteen runs of this package's own suite, which is exactly
697
+ // the shape of defect that reaches a customer as "the SDK hung". The timer
698
+ // below is cleared on every path.
699
+ const controller = new AbortController();
700
+ const timedOut = new Error(`${method} ${path} did not answer within ${this.timeoutMs}ms`);
701
+ timedOut.name = 'TimeoutError';
702
+ const timer = setTimeout(() => controller.abort(timedOut), this.timeoutMs);
703
+ let res;
704
+ let text;
705
+ try {
706
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
707
+ method,
708
+ headers,
709
+ ...(body !== null ? { body } : {}),
710
+ signal: controller.signal,
711
+ });
712
+ text = await res.text();
713
+ }
714
+ catch (thrown) {
715
+ // R1: no HTTP answer at all — refused, DNS, or the round trip outlived
716
+ // its timeout. Status 0, and retryable.
717
+ return {
718
+ kind: 'failed',
719
+ status: 0,
720
+ retryAfterS: null,
721
+ error: transportError(thrown, method, path),
722
+ };
723
+ }
724
+ finally {
725
+ clearTimeout(timer);
726
+ }
727
+ const status = res.status;
728
+ const retryAfterS = parseRetryAfterSeconds(res.headers.get(RETRY_AFTER_HEADER));
729
+ if (res.ok) {
730
+ // R6: a successful call adds a token. Counted here, on the STATUS, and
731
+ // not after the parse: R6 is about the call the gateway answered, and a
732
+ // 2xx whose body this client then refuses was still a call that worked.
733
+ this.retry.refill();
734
+ // Only here, on a 2xx (see API_KEY_EXPIRES_HEADER).
735
+ noteKeyExpiry(res.headers.get(API_KEY_EXPIRES_HEADER));
736
+ }
302
737
  let parsed;
303
738
  try {
304
739
  parsed = JSON.parse(text);
305
740
  }
306
741
  catch {
307
- throw new ResponseMalformed(`HTTP ${res.status} with a non-JSON body from ${method} ${path}`);
742
+ return {
743
+ kind: 'failed',
744
+ status,
745
+ retryAfterS,
746
+ error: new ResponseMalformed(`HTTP ${status} with a non-JSON body from ${method} ${path}`),
747
+ };
308
748
  }
309
749
  if (res.ok) {
310
- // Only here, on a 2xx (see API_KEY_EXPIRES_HEADER).
311
- noteKeyExpiry(res.headers.get(API_KEY_EXPIRES_HEADER));
312
- return parsed;
750
+ return { kind: 'ok', value: parsed };
313
751
  }
314
752
  if (isRecord(parsed)) {
315
753
  const name = parsed['error'];
316
754
  if (typeof name === 'string' && name.length > 0) {
317
- throw new ApiRefusal(res.status, name);
755
+ return { kind: 'failed', status, retryAfterS, error: new ApiRefusal(status, name) };
318
756
  }
319
757
  }
320
- throw new ResponseMalformed(`HTTP ${res.status} from ${method} ${path} names no error`);
758
+ return {
759
+ kind: 'failed',
760
+ status,
761
+ retryAfterS,
762
+ error: new ResponseMalformed(`HTTP ${status} from ${method} ${path} names no error`),
763
+ };
321
764
  }
322
765
  }
323
766
  //# sourceMappingURL=client.js.map