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