contree-client 0.3.0 → 0.4.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/README.md CHANGED
@@ -63,9 +63,11 @@ const client = new ContreeClient(token, { retry: new RetryPolicy() });
63
63
  const stored = await client.ensureFile(bytes);
64
64
  ```
65
65
 
66
- Only idempotent requests are retried (410/425/5xx and transient
67
- network errors, honoring `Retry-After`); pass
68
- `new RetryPolicy({ retryUnsafe: true })` to opt POSTs in.
66
+ Idempotent requests retry after connection errors, request
67
+ timeouts, and retryable responses (410/425/429/5xx). The client honors
68
+ `Retry-After`. Pass `new RetryPolicy({ retryUnsafe: true })` to retry
69
+ POSTs. Responses 425 and 429 are always safe to retry because they mean
70
+ the backend did not process the request.
69
71
 
70
72
  ## Bring your own fetch
71
73
 
@@ -93,7 +95,7 @@ const client = new ContreeClient(token, {
93
95
  (`ReadableStream` uploads) are Node-only.
94
96
  - A truncated compressed download ends the stream short instead of
95
97
  throwing: `fetch` is lenient about a missing gzip trailer (the
96
- Python client raises `DecompressionError` there).
98
+ Python client raises `EOFError` there).
97
99
 
98
100
  ## Testing
99
101
 
package/lib/client.js CHANGED
@@ -1,14 +1,14 @@
1
1
  // Generated by codegen from the OpenAPI spec - do not edit.
2
2
 
3
3
  import {
4
- ContreeAPIError,
5
- ContreeError,
4
+ APIConnectionError,
5
+ APIStatusError,
6
+ ERROR_CLASSES,
7
+ ServerError,
6
8
  NotFoundError,
7
- SSEStreamError,
8
9
  } from "./errors.js";
9
10
  import { AUTH_TYPE_IAM, ProfileError, resolveProfile } from "./profiles.js";
10
11
  import {
11
- EVENTS_UNAVAILABLE_STATUSES,
12
12
  IS_NODE,
13
13
  SSEParser,
14
14
  TIGHT_LOOP_FLOOR,
@@ -17,20 +17,15 @@ import {
17
17
  UA_RUNTIME,
18
18
  decodeFramePayload,
19
19
  encodeQuery,
20
- errorForResponse,
21
20
  monotonic,
22
- retryAfterDelay,
21
+ responseErrorDetails,
23
22
  retryDelays,
24
23
  sha256,
25
24
  isUuid,
26
25
  sleep,
27
26
  } from "./runtime.js";
28
27
  import { DEFAULT_BASE_URL } from "./specInfo.js";
29
- import {
30
- EventDataCompletion,
31
- OperationEvent,
32
- isTerminalStatus,
33
- } from "./models.js";
28
+ import { OperationEvent, isTerminalStatus } from "./models.js";
34
29
  import * as operations from "./operations.js";
35
30
 
36
31
  /** Contree API client on top of the platform fetch.
@@ -63,6 +58,9 @@ export class ContreeClient {
63
58
  `unsupported baseUrl scheme ${parsed.protocol} in ${baseUrl}: use http:// or https://`,
64
59
  );
65
60
  }
61
+ if (parsed.username || parsed.password) {
62
+ throw new RangeError("baseUrl must not include credentials");
63
+ }
66
64
  this.token = token ?? null;
67
65
  this.baseUrl = baseUrl.replace(/\/+$/, "");
68
66
  this.project = project;
@@ -133,6 +131,11 @@ export class ContreeClient {
133
131
  if (IS_NODE && !("User-Agent" in headers)) {
134
132
  headers["User-Agent"] = this.userAgent();
135
133
  }
134
+ try {
135
+ new Headers(headers);
136
+ } catch {
137
+ throw new RangeError("invalid HTTP header name or value");
138
+ }
136
139
  return headers;
137
140
  }
138
141
 
@@ -155,33 +158,91 @@ export class ContreeClient {
155
158
  return options;
156
159
  }
157
160
 
158
- /** Execute the request and return the buffered response. */
161
+ /** Execute one request and map its transport and HTTP errors. */
159
162
  async request(spec) {
160
163
  const controller = new AbortController();
164
+ const deadline = spec.deadline ?? null;
165
+ const remaining = deadline === null ? null : deadline - monotonic();
166
+ if (remaining !== null && remaining <= 0) {
167
+ const cause = new DOMException("request timed out", "TimeoutError");
168
+ throw new APIConnectionError(cause.message, { cause, timedOut: true });
169
+ }
170
+ const url = this.buildUrl(spec);
171
+ const options = this._fetchOptions(spec, controller.signal);
172
+ const timeout =
173
+ this.timeout === null
174
+ ? remaining
175
+ : remaining === null
176
+ ? this.timeout
177
+ : Math.min(this.timeout, remaining);
161
178
  // an explicit timer, not AbortSignal.timeout(): Node unrefs that
162
179
  // timer, so it can never fire when nothing else keeps the event
163
180
  // loop alive (custom fetch transports, for one)
164
181
  const timer =
165
- this.timeout !== null
182
+ timeout !== null
166
183
  ? setTimeout(
167
184
  () =>
168
185
  controller.abort(
169
186
  new DOMException("request timed out", "TimeoutError"),
170
187
  ),
171
- this.timeout * 1000,
188
+ Math.ceil(Math.max(0, timeout) * 1000),
172
189
  )
173
190
  : null;
174
191
  try {
175
- const response = await this._fetch(
176
- this.buildUrl(spec),
177
- this._fetchOptions(spec, controller.signal),
178
- );
179
- const body = new Uint8Array(await response.arrayBuffer());
192
+ let response;
193
+ try {
194
+ response = await this._fetch(url, options);
195
+ } catch (cause) {
196
+ if (cause?.name === "AbortError" && !controller.signal.aborted) {
197
+ throw cause;
198
+ }
199
+ throw new APIConnectionError(String(cause?.message ?? cause), {
200
+ cause,
201
+ timedOut:
202
+ cause?.name === "TimeoutError" ||
203
+ controller.signal.reason?.name === "TimeoutError",
204
+ });
205
+ }
206
+ if (deadline !== null && monotonic() >= deadline) {
207
+ const cause = new DOMException("request timed out", "TimeoutError");
208
+ throw new APIConnectionError(cause.message, { cause, timedOut: true });
209
+ }
210
+ let body;
211
+ try {
212
+ body = new Uint8Array(await response.arrayBuffer());
213
+ } catch (cause) {
214
+ if (cause?.name === "AbortError" && !controller.signal.aborted) {
215
+ throw cause;
216
+ }
217
+ throw new APIConnectionError(String(cause?.message ?? cause), {
218
+ cause,
219
+ timedOut:
220
+ cause?.name === "TimeoutError" ||
221
+ controller.signal.reason?.name === "TimeoutError",
222
+ });
223
+ }
224
+ if (deadline !== null && monotonic() >= deadline) {
225
+ const cause = new DOMException("request timed out", "TimeoutError");
226
+ throw new APIConnectionError(cause.message, { cause, timedOut: true });
227
+ }
180
228
  const headers = {};
181
229
  response.headers.forEach((value, key) => {
182
230
  headers[key.toLowerCase()] = value;
183
231
  });
184
- return { status: response.status, headers, body, url: response.url };
232
+ const data = {
233
+ status: response.status,
234
+ headers,
235
+ body,
236
+ url: response.url,
237
+ };
238
+ if (response.status >= 400) {
239
+ const { error, traceback, retryAfter } = responseErrorDetails(data);
240
+ const ErrorClass =
241
+ ERROR_CLASSES.get(response.status) ??
242
+ (response.status >= 500 ? ServerError : APIStatusError);
243
+ throw new ErrorClass(response.status, error, { traceback, retryAfter });
244
+ }
245
+ return data;
185
246
  } finally {
186
247
  if (timer !== null) {
187
248
  clearTimeout(timer);
@@ -189,19 +250,7 @@ export class ContreeClient {
189
250
  }
190
251
  }
191
252
 
192
- /** fetch reports network failures as TypeError; timeouts abort. */
193
- _transportRetryable(error) {
194
- return error instanceof TypeError;
195
- }
196
-
197
- _transportNonretryable(error) {
198
- return (
199
- error instanceof DOMException &&
200
- (error.name === "AbortError" || error.name === "TimeoutError")
201
- );
202
- }
203
-
204
- /** One transparent reconnect for a replayable idempotent request:
253
+ /** One transparent reconnect for a replayable connection error:
205
254
  * fetch pools keep-alive sockets and cannot pre-check them the way
206
255
  * the Python adapters do, so the first request on a socket the
207
256
  * server already closed fails without ever reaching the server. */
@@ -213,8 +262,8 @@ export class ContreeClient {
213
262
  !spec.idempotent ||
214
263
  (typeof ReadableStream !== "undefined" &&
215
264
  spec.body instanceof ReadableStream) ||
216
- !this._transportRetryable(error) ||
217
- this._transportNonretryable(error)
265
+ !(error instanceof APIConnectionError) ||
266
+ error.cause?.name !== "TypeError"
218
267
  ) {
219
268
  throw error;
220
269
  }
@@ -242,41 +291,45 @@ export class ContreeClient {
242
291
  // contract guarantees both mean the request was rejected before
243
292
  // any processing, so replaying is always safe.
244
293
  const replaySafe = spec.idempotent || policy.retryUnsafe;
294
+ const deadline = spec.deadline ?? null;
245
295
  const delays = retryDelays(policy.delays);
246
296
  let attempts = 0;
247
297
  for (;;) {
248
298
  attempts += 1;
249
299
  const exhausted =
250
300
  policy.maxAttempts !== null && attempts >= policy.maxAttempts;
251
- let response;
252
301
  try {
253
- response = await this.request(spec);
302
+ return await this.request(spec);
254
303
  } catch (error) {
304
+ const retryableStatus =
305
+ error instanceof APIStatusError &&
306
+ policy.retryableStatus(error.status);
307
+ const rejectedBeforeProcessing =
308
+ error instanceof APIStatusError &&
309
+ (error.status === 425 || error.status === 429);
255
310
  if (
256
- !replaySafe ||
257
- !this._transportRetryable(error) ||
258
- this._transportNonretryable(error) ||
259
- exhausted
311
+ (!(error instanceof APIConnectionError) && !retryableStatus) ||
312
+ (!replaySafe && !rejectedBeforeProcessing) ||
313
+ exhausted ||
314
+ (deadline !== null && monotonic() >= deadline)
260
315
  ) {
261
316
  throw error;
262
317
  }
263
- await sleep(delays.next().value);
264
- continue;
265
- }
266
- if (!policy.retryableStatus(response.status) || exhausted) {
267
- return response;
268
- }
269
- if (!replaySafe && response.status !== 425 && response.status !== 429) {
270
- return response;
318
+ let delay =
319
+ error instanceof APIStatusError && error.retryAfter !== null
320
+ ? error.retryAfter
321
+ : delays.next().value;
322
+ if (deadline !== null) {
323
+ delay = Math.min(delay, Math.max(0, deadline - monotonic()));
324
+ }
325
+ await sleep(delay);
271
326
  }
272
- const retryAfter = retryAfterDelay(response);
273
- await sleep(retryAfter !== null ? retryAfter : delays.next().value);
274
327
  }
275
328
  }
276
329
 
277
330
  /** Execute the request and yield response body chunks. */
278
331
  async *stream(spec) {
279
- const controller = new AbortController();
332
+ let controller;
280
333
  let timer = null;
281
334
  const disarm = () => {
282
335
  if (timer !== null) {
@@ -294,21 +347,33 @@ export class ContreeClient {
294
347
  controller.abort(
295
348
  new DOMException("stream read timed out", "TimeoutError"),
296
349
  ),
297
- Math.max(0, seconds) * 1000,
350
+ Math.ceil(Math.max(0, seconds) * 1000),
298
351
  );
299
352
  };
300
353
  const sse = spec.accept === "text/event-stream";
301
354
  const deadline = spec.deadline ?? null; // monotonic seconds
302
355
  // the budget for the next transport wait: downloads use the
303
- // client timeout (idle cap, re-armed per chunk); SSE is unbounded
304
- // unless the caller set an absolute deadline - keepalive frames
305
- // re-enter abortIn with a shrinking remainder, so the bound stays
306
- // absolute no matter how often the server keeps the stream warm
356
+ // client timeout as an idle cap, bounded by the caller's absolute
357
+ // deadline; SSE is unbounded unless the caller set a deadline.
358
+ // Keepalive frames re-enter abortIn with a shrinking remainder, so
359
+ // the bound stays absolute while the server keeps the stream warm.
307
360
  const nextBudget = () => {
308
- if (!sse) {
309
- return this.timeout;
361
+ const remaining = deadline === null ? null : deadline - monotonic();
362
+ if (sse || this.timeout === null) {
363
+ return remaining;
310
364
  }
311
- return deadline === null ? null : deadline - monotonic();
365
+ return remaining === null
366
+ ? this.timeout
367
+ : Math.min(this.timeout, remaining);
368
+ };
369
+ const connectBudget = () => {
370
+ const remaining = deadline === null ? null : deadline - monotonic();
371
+ if (this.timeout === null) {
372
+ return remaining;
373
+ }
374
+ return remaining === null
375
+ ? this.timeout
376
+ : Math.min(this.timeout, remaining);
312
377
  };
313
378
  // the server may close a pooled keep-alive socket between
314
379
  // requests and fetch exposes no pool to pre-check (the Python
@@ -322,57 +387,68 @@ export class ContreeClient {
322
387
  typeof ReadableStream !== "undefined" &&
323
388
  spec.body instanceof ReadableStream
324
389
  );
325
- const policy = replayable ? this.retry : null;
390
+ // SSE reconnection belongs to followOperationEvents(), which
391
+ // checks terminal status after each failed stream.
392
+ const policy = replayable && !sse ? this.retry : null;
326
393
  const maxAttempts =
327
394
  policy !== null ? policy.maxAttempts : replayable ? 2 : 1;
328
395
  const delays = policy === null ? null : retryDelays(policy.delays);
329
396
  let attempts = 0;
330
397
  let response;
331
398
  for (;;) {
399
+ if (deadline !== null && monotonic() >= deadline) {
400
+ throw new DOMException("stream read timed out", "TimeoutError");
401
+ }
332
402
  attempts += 1;
333
- abortIn(this.timeout); // the connect phase always has a bound
403
+ controller = new AbortController();
404
+ abortIn(connectBudget());
334
405
  try {
335
406
  response = await this._fetch(
336
407
  this.buildUrl(spec),
337
408
  this._fetchOptions(spec, controller.signal),
338
409
  );
410
+ if (deadline !== null && monotonic() >= deadline) {
411
+ throw new DOMException("stream read timed out", "TimeoutError");
412
+ }
339
413
  break;
340
414
  } catch (error) {
341
415
  disarm();
416
+ const reconnectable =
417
+ error?.name === "TypeError" ||
418
+ (policy !== null && error?.name === "TimeoutError");
342
419
  if (
343
- !this._transportRetryable(error) ||
344
- this._transportNonretryable(error) ||
345
- (maxAttempts !== null && attempts >= maxAttempts)
420
+ !reconnectable ||
421
+ (maxAttempts !== null && attempts >= maxAttempts) ||
422
+ (deadline !== null && monotonic() >= deadline)
346
423
  ) {
347
424
  throw error;
348
425
  }
349
- await sleep(delays === null ? 0 : delays.next().value);
350
- }
351
- }
352
- if (!(response.status >= 200 && response.status < 300)) {
353
- // the error body is read under the same timer: a 500 with an
354
- // endless body must not hang the caller
355
- let body;
356
- try {
357
- body = new Uint8Array(await response.arrayBuffer());
358
- } finally {
359
- disarm();
426
+ let delay = delays === null ? 0 : delays.next().value;
427
+ if (deadline !== null) {
428
+ delay = Math.min(delay, Math.max(0, deadline - monotonic()));
429
+ }
430
+ await sleep(delay);
360
431
  }
361
- const headers = {};
362
- response.headers.forEach((value, key) => {
363
- headers[key.toLowerCase()] = value;
364
- });
365
- throw errorForResponse({ status: response.status, headers, body });
366
432
  }
367
433
  disarm();
368
434
  const reader = response.body.getReader();
369
435
  try {
436
+ if (response.status >= 400) {
437
+ throw new RangeError(`HTTP ${response.status}`);
438
+ }
370
439
  for (;;) {
371
- abortIn(nextBudget());
440
+ const budget = nextBudget();
441
+ if (budget !== null && budget <= 0) {
442
+ throw new DOMException("stream read timed out", "TimeoutError");
443
+ }
444
+ abortIn(budget);
372
445
  const { done, value } = await reader.read();
373
446
  // the timer covers only the transport wait, never the
374
447
  // consumer's processing of the yielded chunk
375
448
  disarm();
449
+ if (deadline !== null && monotonic() >= deadline) {
450
+ throw new DOMException("stream read timed out", "TimeoutError");
451
+ }
376
452
  if (done) {
377
453
  return;
378
454
  }
@@ -393,14 +469,42 @@ export class ContreeClient {
393
469
  async close() {}
394
470
 
395
471
  /** Best-effort check that the operation reached a terminal state. */
396
- async operationTerminal(operationId) {
472
+ async operationTerminal(operationId, deadline = null) {
397
473
  let status;
474
+ const deadlineLimited =
475
+ deadline !== null &&
476
+ (this.timeout === null || deadline - monotonic() <= this.timeout);
398
477
  try {
399
- status = (await this.getOperationStatus(operationId)).status;
478
+ // The outer event loop owns retries. Each status probe uses one
479
+ // transport attempt and stays inside the caller's deadline.
480
+ const spec = operations.buildGetOperationStatus(operationId);
481
+ spec.deadline = deadline;
482
+ status = operations.parseGetOperationStatus(
483
+ await this.request(spec),
484
+ ).status;
400
485
  } catch (error) {
401
- if (error instanceof ContreeError || this._transportRetryable(error)) {
486
+ if (error instanceof APIConnectionError) {
487
+ if (
488
+ deadline !== null &&
489
+ (monotonic() >= deadline || (deadlineLimited && error.timedOut))
490
+ ) {
491
+ throw new DOMException(
492
+ `operation ${operationId} status probe exceeded its deadline`,
493
+ "TimeoutError",
494
+ );
495
+ }
402
496
  return false;
403
497
  }
498
+ if (error instanceof APIStatusError) {
499
+ if (
500
+ error.status === 410 ||
501
+ error.status === 425 ||
502
+ error.status === 429 ||
503
+ error.status >= 500
504
+ ) {
505
+ return false;
506
+ }
507
+ }
404
508
  throw error;
405
509
  }
406
510
  return status !== undefined && isTerminalStatus(status);
@@ -410,36 +514,49 @@ export class ContreeClient {
410
514
  * follows the SSE log until `completion`, then fetches and returns
411
515
  * the terminal OperationResponse. */
412
516
  async waitOperation(operationId, { timeout = null } = {}) {
517
+ const deadline = timeout === null ? null : monotonic() + timeout;
413
518
  // eslint-disable-next-line no-unused-vars
414
519
  for await (const event of this.followOperationEvents(operationId, {
415
520
  timeout,
416
521
  })) {
417
522
  // draining the stream is the wait
418
523
  }
419
- return await this.getOperationStatus(operationId);
524
+ const spec = operations.buildGetOperationStatus(operationId);
525
+ spec.deadline = deadline;
526
+ const deadlineLimited =
527
+ deadline !== null &&
528
+ (this.timeout === null || deadline - monotonic() <= this.timeout);
529
+ try {
530
+ return operations.parseGetOperationStatus(await this.call(spec));
531
+ } catch (error) {
532
+ if (
533
+ deadline !== null &&
534
+ error instanceof APIConnectionError &&
535
+ (monotonic() >= deadline || (deadlineLimited && error.timedOut))
536
+ ) {
537
+ throw new DOMException(
538
+ `operation ${operationId} did not complete within ${timeout}s`,
539
+ "TimeoutError",
540
+ );
541
+ }
542
+ throw error;
543
+ }
420
544
  }
421
545
 
422
- /** Stream operation events with transparent reconnection: network
423
- * drops, in-band SSE error frames and retryable API statuses
424
- * (410/425/5xx) reconnect from the last received event id. A status
425
- * meaning the events endpoint itself doesn't exist for this
426
- * operation/server (400/404/405/406) stops reconnecting and instead
427
- * polls getOperationStatus() until the operation is terminal, then
428
- * yields a synthesized `completion` event built from that status
429
- * (there is no real event log to relay). Other API errors
430
- * propagate. Ends after the `completion` event. */
546
+ /** Stream operation events with transparent reconnection.
547
+ * Native stream failures trigger a terminal-status probe and a
548
+ * reconnect from the last event id. */
431
549
  async *followOperationEvents(
432
550
  operationId,
433
551
  { last_event_id = null, spid = null, since = null, timeout = null } = {},
434
552
  ) {
435
553
  let lastId = last_event_id;
436
- const delays = retryDelays();
437
554
  const deadline = timeout === null ? null : monotonic() + timeout;
438
555
  const checkDeadline = () => {
439
556
  if (deadline !== null && monotonic() >= deadline) {
440
- throw new ContreeAPIError(
441
- 0,
557
+ throw new DOMException(
442
558
  `operation ${operationId} events did not complete within ${timeout}s`,
559
+ "TimeoutError",
443
560
  );
444
561
  }
445
562
  };
@@ -462,101 +579,25 @@ export class ContreeClient {
462
579
  checkDeadline();
463
580
  }
464
581
  } catch (error) {
465
- if (error instanceof SSEStreamError) {
466
- if (error.lastEventId !== null) {
467
- lastId = error.lastEventId;
468
- }
469
- } else if (error instanceof ContreeAPIError) {
470
- if (EVENTS_UNAVAILABLE_STATUSES.has(error.status)) {
471
- // the endpoint is gone, not just failing: reconnecting the
472
- // same request will never work, but the operation may
473
- // still finish - stop touching /events and poll status
474
- // instead for the rest of this wait
475
- for (;;) {
476
- checkDeadline();
477
- let response;
478
- try {
479
- response = await this.getOperationStatus(operationId);
480
- } catch (pollError) {
481
- if (
482
- !(pollError instanceof ContreeError) &&
483
- !this._transportRetryable(pollError)
484
- ) {
485
- throw pollError;
486
- }
487
- response = null;
488
- }
489
- if (
490
- response !== null &&
491
- response.status !== undefined &&
492
- isTerminalStatus(response.status)
493
- ) {
494
- // no event log to relay, but a caller of
495
- // followOperationEvents must still observe a terminal
496
- // completion rather than nothing at all
497
- lastId = lastId === null ? 0 : lastId + 1;
498
- yield new OperationEvent({
499
- id: lastId,
500
- ts: new Date(),
501
- type: "completion",
502
- data: new EventDataCompletion({
503
- status: response.status,
504
- duration_ms:
505
- typeof response.duration === "number"
506
- ? Math.round(response.duration * 1000)
507
- : 0,
508
- result_image_uuid: response.result_image_uuid,
509
- error: response.error,
510
- image_size_bytes:
511
- typeof response.image_size === "number"
512
- ? response.image_size
513
- : undefined,
514
- }),
515
- });
516
- return;
517
- }
518
- let pollDelay = delays.next().value;
519
- if (deadline !== null) {
520
- pollDelay = Math.min(
521
- pollDelay,
522
- Math.max(0, deadline - monotonic()),
523
- );
524
- }
525
- await sleep(pollDelay);
526
- }
527
- }
528
- const retryable =
529
- error.status === 410 ||
530
- error.status === 425 ||
531
- (error.status >= 500 && error.status < 600);
532
- if (!retryable) {
533
- throw error;
534
- }
535
- if (await this.operationTerminal(operationId)) {
536
- return;
537
- }
538
- let delay =
539
- error.retryAfter !== null ? error.retryAfter : delays.next().value;
540
- if (deadline !== null) {
541
- // a Retry-After must not sleep past the caller's deadline
542
- delay = Math.min(delay, Math.max(0, deadline - monotonic()));
543
- }
544
- await sleep(delay);
545
- continue;
546
- } else if (
547
- !this._transportRetryable(error) ||
548
- this._transportNonretryable(error)
549
- ) {
582
+ if (error?.name === "AbortError") {
550
583
  throw error;
551
584
  }
585
+ checkDeadline();
586
+ if (Number.isInteger(error?.lastEventId)) {
587
+ lastId = error.lastEventId;
588
+ }
552
589
  }
553
590
  // the stream ended or broke without a completion frame: the
554
591
  // retry must not outlive the operation itself
555
- if (await this.operationTerminal(operationId)) {
592
+ if (await this.operationTerminal(operationId, deadline)) {
556
593
  return;
557
594
  }
558
595
  if (lastId === eventsBefore) {
559
- await sleep(TIGHT_LOOP_FLOOR);
596
+ let delay = TIGHT_LOOP_FLOOR;
597
+ if (deadline !== null) {
598
+ delay = Math.min(delay, Math.max(0, deadline - monotonic()));
599
+ }
600
+ await sleep(delay);
560
601
  }
561
602
  }
562
603
  }
@@ -717,7 +758,14 @@ export class ContreeClient {
717
758
  /** Check if a file exists for the current namespace (HEAD /files/{sha256}) */
718
759
  async checkFileExists(sha256) {
719
760
  const spec = operations.buildCheckFileExists(sha256);
720
- return operations.parseCheckFileExists(await this.call(spec));
761
+ try {
762
+ return operations.parseCheckFileExists(await this.call(spec));
763
+ } catch (error) {
764
+ if (error instanceof NotFoundError) {
765
+ return false;
766
+ }
767
+ throw error;
768
+ }
721
769
  }
722
770
 
723
771
  /** Spawn a new container instance (POST /instances) */
@@ -797,12 +845,10 @@ export class ContreeClient {
797
845
  let lastSeen = options.last_event_id ?? null;
798
846
  for await (const chunk of this.stream(spec)) {
799
847
  for (const frame of parser.feed(chunk)) {
800
- const payload = decodeFramePayload(frame, lastSeen);
801
- // id-only frames advance the resume cursor even
802
- // though they carry no payload
803
848
  if (frame.id !== null) {
804
849
  lastSeen = frame.id;
805
850
  }
851
+ const payload = decodeFramePayload(frame, lastSeen);
806
852
  if (payload === null) {
807
853
  continue;
808
854
  }
@@ -875,7 +921,14 @@ export class ContreeClient {
875
921
  /** Check if a file exists in image (HEAD /inspect/{image_uuid}/download) */
876
922
  async checkImageFile(imageUuid, path) {
877
923
  const spec = operations.buildCheckImageFile(imageUuid, path);
878
- return operations.parseCheckImageFile(await this.call(spec));
924
+ try {
925
+ return operations.parseCheckImageFile(await this.call(spec));
926
+ } catch (error) {
927
+ if (error instanceof NotFoundError) {
928
+ return false;
929
+ }
930
+ throw error;
931
+ }
879
932
  }
880
933
 
881
934
  /** Download a file or a directory from image as tar archive (GET /inspect/{image_uuid}/archive) */
@@ -887,7 +940,14 @@ export class ContreeClient {
887
940
  /** Check if a path can be archived from image (HEAD /inspect/{image_uuid}/archive) */
888
941
  async checkImageArchive(imageUuid, path) {
889
942
  const spec = operations.buildCheckImageArchive(imageUuid, path);
890
- return operations.parseCheckImageArchive(await this.call(spec));
943
+ try {
944
+ return operations.parseCheckImageArchive(await this.call(spec));
945
+ } catch (error) {
946
+ if (error instanceof NotFoundError) {
947
+ return false;
948
+ }
949
+ throw error;
950
+ }
891
951
  }
892
952
 
893
953
  /** List files in image (GET /inspect/{image_uuid}/list) */
package/lib/errors.d.ts CHANGED
@@ -1,11 +1,16 @@
1
- export declare class ContreeError extends Error {}
1
+ export declare class ContreeError extends Error {
2
+ constructor(message: string, options?: ErrorOptions);
3
+ }
2
4
 
3
- export declare class SSEStreamError extends ContreeError {
4
- lastEventId: number | null;
5
- constructor(message: string, options?: { lastEventId?: number | null });
5
+ export declare class APIConnectionError extends ContreeError {
6
+ timedOut: boolean;
7
+ constructor(
8
+ message: string,
9
+ options?: { cause?: unknown; timedOut?: boolean },
10
+ );
6
11
  }
7
12
 
8
- export declare class ContreeAPIError extends ContreeError {
13
+ export declare class APIStatusError extends ContreeError {
9
14
  status: number;
10
15
  error: unknown;
11
16
  traceback: string[] | null;
@@ -17,14 +22,15 @@ export declare class ContreeAPIError extends ContreeError {
17
22
  );
18
23
  }
19
24
 
20
- export declare class BadRequestError extends ContreeAPIError {}
21
- export declare class UnauthorizedError extends ContreeAPIError {}
22
- export declare class ForbiddenError extends ContreeAPIError {}
23
- export declare class NotFoundError extends ContreeAPIError {}
24
- export declare class ConflictError extends ContreeAPIError {}
25
- export declare class GoneError extends ContreeAPIError {}
26
- export declare class UnprocessableEntityError extends ContreeAPIError {}
27
- export declare class TooEarlyError extends ContreeAPIError {}
28
- export declare class ServerError extends ContreeAPIError {}
25
+ export declare class BadRequestError extends APIStatusError {}
26
+ export declare class AuthenticationError extends APIStatusError {}
27
+ export declare class PermissionDeniedError extends APIStatusError {}
28
+ export declare class NotFoundError extends APIStatusError {}
29
+ export declare class ConflictError extends APIStatusError {}
30
+ export declare class GoneError extends APIStatusError {}
31
+ export declare class UnprocessableEntityError extends APIStatusError {}
32
+ export declare class TooEarlyError extends APIStatusError {}
33
+ export declare class RateLimitError extends APIStatusError {}
34
+ export declare class ServerError extends APIStatusError {}
29
35
 
30
- export declare const ERROR_CLASSES: Map<number, typeof ContreeAPIError>;
36
+ export declare const ERROR_CLASSES: Map<number, typeof APIStatusError>;
package/lib/errors.js CHANGED
@@ -1,20 +1,20 @@
1
1
  /** Exception hierarchy for the Contree API client. */
2
2
 
3
3
  export class ContreeError extends Error {
4
- constructor(message) {
5
- super(message);
4
+ constructor(message, options) {
5
+ super(message, options);
6
6
  this.name = new.target.name;
7
7
  }
8
8
  }
9
9
 
10
- export class SSEStreamError extends ContreeError {
11
- constructor(message, { lastEventId = null } = {}) {
12
- super(message);
13
- this.lastEventId = lastEventId;
10
+ export class APIConnectionError extends ContreeError {
11
+ constructor(message, { cause, timedOut = false } = {}) {
12
+ super(message, { cause });
13
+ this.timedOut = timedOut;
14
14
  }
15
15
  }
16
16
 
17
- export class ContreeAPIError extends ContreeError {
17
+ export class APIStatusError extends ContreeError {
18
18
  constructor(status, error, { traceback = null, retryAfter = null } = {}) {
19
19
  super(`HTTP ${status}: ${error}`);
20
20
  this.status = status;
@@ -24,23 +24,25 @@ export class ContreeAPIError extends ContreeError {
24
24
  }
25
25
  }
26
26
 
27
- export class BadRequestError extends ContreeAPIError {}
28
- export class UnauthorizedError extends ContreeAPIError {}
29
- export class ForbiddenError extends ContreeAPIError {}
30
- export class NotFoundError extends ContreeAPIError {}
31
- export class ConflictError extends ContreeAPIError {}
32
- export class GoneError extends ContreeAPIError {}
33
- export class UnprocessableEntityError extends ContreeAPIError {}
34
- export class TooEarlyError extends ContreeAPIError {}
35
- export class ServerError extends ContreeAPIError {}
27
+ export class BadRequestError extends APIStatusError {}
28
+ export class AuthenticationError extends APIStatusError {}
29
+ export class PermissionDeniedError extends APIStatusError {}
30
+ export class NotFoundError extends APIStatusError {}
31
+ export class ConflictError extends APIStatusError {}
32
+ export class GoneError extends APIStatusError {}
33
+ export class UnprocessableEntityError extends APIStatusError {}
34
+ export class TooEarlyError extends APIStatusError {}
35
+ export class RateLimitError extends APIStatusError {}
36
+ export class ServerError extends APIStatusError {}
36
37
 
37
38
  export const ERROR_CLASSES = new Map([
38
39
  [400, BadRequestError],
39
- [401, UnauthorizedError],
40
- [403, ForbiddenError],
40
+ [401, AuthenticationError],
41
+ [403, PermissionDeniedError],
41
42
  [404, NotFoundError],
42
43
  [409, ConflictError],
43
44
  [410, GoneError],
44
45
  [422, UnprocessableEntityError],
45
46
  [425, TooEarlyError],
47
+ [429, RateLimitError],
46
48
  ]);
package/lib/index.d.ts CHANGED
@@ -8,9 +8,13 @@ export {
8
8
  RETRY_DELAYS,
9
9
  RetryPolicy,
10
10
  SSEParser,
11
+ base64ToBytes,
12
+ bytesToBase64,
13
+ bytesToText,
11
14
  parseDatetime,
12
15
  parseRetryAfter,
13
16
  sha256,
17
+ textToBytes,
14
18
  } from "./runtime.js";
15
19
  export type { RequestSpec, ResponseData, RequestBody } from "./runtime.js";
16
20
  export { ContreeClient, ContreeClientOptions } from "./client.js";
package/lib/index.js CHANGED
@@ -8,9 +8,13 @@ export {
8
8
  RETRY_DELAYS,
9
9
  RetryPolicy,
10
10
  SSEParser,
11
+ base64ToBytes,
12
+ bytesToBase64,
13
+ bytesToText,
11
14
  parseDatetime,
12
15
  parseRetryAfter,
13
16
  sha256,
17
+ textToBytes,
14
18
  } from "./runtime.js";
15
19
  export { ContreeClient } from "./client.js";
16
20
  export * as operations from "./operations.js";
package/lib/operations.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Generated by codegen from the OpenAPI spec - do not edit.
2
2
 
3
3
  import {
4
- errorForResponse,
5
4
  formatTimeParam,
6
5
  jsonArray,
7
6
  jsonObject,
@@ -67,7 +66,7 @@ export function parseListImages(response) {
67
66
  if (response.status >= 200 && response.status < 300) {
68
67
  return ImageListResponse.fromWire(jsonObject(response));
69
68
  }
70
- throw errorForResponse(response);
69
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
71
70
  }
72
71
 
73
72
  /** Build the request for `DELETE /images/{imageUUID}/tag`. */
@@ -89,7 +88,7 @@ export function parseDeleteImageTag(response) {
89
88
  if (response.status >= 200 && response.status < 300) {
90
89
  return null;
91
90
  }
92
- throw errorForResponse(response);
91
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
93
92
  }
94
93
 
95
94
  /** Build the request for `PATCH /images/{imageUUID}/tag`. */
@@ -110,7 +109,7 @@ export function parseUpdateImageTag(response) {
110
109
  if (response.status >= 200 && response.status < 300) {
111
110
  return Image.fromWire(jsonObject(response));
112
111
  }
113
- throw errorForResponse(response);
112
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
114
113
  }
115
114
 
116
115
  /** Build the request for `POST /images/import`. */
@@ -130,7 +129,7 @@ export function parseImportImage(response) {
130
129
  if (response.status >= 200 && response.status < 300) {
131
130
  return String(jsonObject(response)["uuid"]);
132
131
  }
133
- throw errorForResponse(response);
132
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
134
133
  }
135
134
 
136
135
  /** Build the request for `GET /files`. */
@@ -161,7 +160,7 @@ export function parseListFiles(response) {
161
160
  if (response.status >= 200 && response.status < 300) {
162
161
  return FilesListResponse.fromWire(jsonObject(response));
163
162
  }
164
- throw errorForResponse(response);
163
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
165
164
  }
166
165
 
167
166
  /** Build the request for `POST /files`. */
@@ -180,7 +179,7 @@ export function parseUploadFile(response) {
180
179
  if (response.status >= 200 && response.status < 300) {
181
180
  return FileResponse.fromWire(jsonObject(response));
182
181
  }
183
- throw errorForResponse(response);
182
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
184
183
  }
185
184
 
186
185
  /** Build the request for `GET /files/{sha256}`. */
@@ -197,7 +196,7 @@ export function parseGetFile(response) {
197
196
  if (response.status >= 200 && response.status < 300) {
198
197
  return File.fromWire(jsonObject(response));
199
198
  }
200
- throw errorForResponse(response);
199
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
201
200
  }
202
201
 
203
202
  /** Build the request for `HEAD /files/{sha256}`. */
@@ -214,10 +213,7 @@ export function parseCheckFileExists(response) {
214
213
  if (response.status >= 200 && response.status < 300) {
215
214
  return true;
216
215
  }
217
- if (response.status === 404) {
218
- return false;
219
- }
220
- throw errorForResponse(response);
216
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
221
217
  }
222
218
 
223
219
  /** Build the request for `POST /instances`. */
@@ -275,7 +271,7 @@ export function parseSpawnInstance(response) {
275
271
  if (response.status >= 200 && response.status < 300) {
276
272
  return InstanceSpawnResponse.fromWire(jsonObject(response));
277
273
  }
278
- throw errorForResponse(response);
274
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
279
275
  }
280
276
 
281
277
  /** Build the request for `GET /operations`. */
@@ -314,7 +310,7 @@ export function parseListOperations(response) {
314
310
  if (response.status >= 200 && response.status < 300) {
315
311
  return jsonArray(response).map((item) => OperationSummary.fromWire(item));
316
312
  }
317
- throw errorForResponse(response);
313
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
318
314
  }
319
315
 
320
316
  /** Build the request for `GET /operations/{operationId}`. */
@@ -339,7 +335,7 @@ export function parseGetOperationStatus(response) {
339
335
  if (response.status >= 200 && response.status < 300) {
340
336
  return OperationResponse.fromWire(jsonObject(response));
341
337
  }
342
- throw errorForResponse(response);
338
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
343
339
  }
344
340
 
345
341
  /** Build the request for `DELETE /operations/{operationId}`. */
@@ -356,7 +352,7 @@ export function parseCancelOperation(response) {
356
352
  if (response.status >= 200 && response.status < 300) {
357
353
  return null;
358
354
  }
359
- throw errorForResponse(response);
355
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
360
356
  }
361
357
 
362
358
  /** Build the request for `GET /operations/{operationId}/events`. */
@@ -420,7 +416,7 @@ export function parseOperationSubprocessCreate(response) {
420
416
  if (response.status >= 200 && response.status < 300) {
421
417
  return Number(jsonObject(response)["spid"]);
422
418
  }
423
- throw errorForResponse(response);
419
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
424
420
  }
425
421
 
426
422
  /** Build the request for `GET /operations/{operationId}/subprocesses/{spid}`. */
@@ -437,7 +433,7 @@ export function parseOperationSubprocess(response) {
437
433
  if (response.status >= 200 && response.status < 300) {
438
434
  return InstanceResult.fromWire(jsonObject(response));
439
435
  }
440
- throw errorForResponse(response);
436
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
441
437
  }
442
438
 
443
439
  /** Build the request for `DELETE /operations/{operationId}/subprocesses/{spid}`. */
@@ -463,7 +459,7 @@ export function parseOperationSubprocessKill(response) {
463
459
  if (response.status >= 200 && response.status < 300) {
464
460
  return null;
465
461
  }
466
- throw errorForResponse(response);
462
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
467
463
  }
468
464
 
469
465
  /** Build the request for `POST /operations/{operationId}/subprocesses/{spid}/stdin`. */
@@ -494,7 +490,7 @@ export function parseOperationSubprocessStdin(response) {
494
490
  if (response.status >= 200 && response.status < 300) {
495
491
  return null;
496
492
  }
497
- throw errorForResponse(response);
493
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
498
494
  }
499
495
 
500
496
  /** Build the request for `GET /inspect/`. */
@@ -522,7 +518,7 @@ export function parseInspectFindImageByTag(response) {
522
518
  const path = new URL(response.url).pathname;
523
519
  return path.replace(/\/+$/, "").split("/").pop();
524
520
  }
525
- throw errorForResponse(response);
521
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
526
522
  }
527
523
 
528
524
  /** Build the request for `GET /inspect/{image_uuid}/`. */
@@ -539,7 +535,7 @@ export function parseInspectImage(response) {
539
535
  if (response.status >= 200 && response.status < 300) {
540
536
  return Image.fromWire(jsonObject(response));
541
537
  }
542
- throw errorForResponse(response);
538
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
543
539
  }
544
540
 
545
541
  /** Build the request for `GET /inspect/{image_uuid}/download`. */
@@ -559,7 +555,7 @@ export function parseInspectImageDownload(response) {
559
555
  if (response.status >= 200 && response.status < 300) {
560
556
  return response.body;
561
557
  }
562
- throw errorForResponse(response);
558
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
563
559
  }
564
560
 
565
561
  /** Build the request for `HEAD /inspect/{image_uuid}/download`. */
@@ -579,10 +575,7 @@ export function parseCheckImageFile(response) {
579
575
  if (response.status >= 200 && response.status < 300) {
580
576
  return true;
581
577
  }
582
- if (response.status === 404) {
583
- return false;
584
- }
585
- throw errorForResponse(response);
578
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
586
579
  }
587
580
 
588
581
  /** Build the request for `GET /inspect/{image_uuid}/archive`. */
@@ -614,10 +607,7 @@ export function parseCheckImageArchive(response) {
614
607
  if (response.status >= 200 && response.status < 300) {
615
608
  return true;
616
609
  }
617
- if (response.status === 404) {
618
- return false;
619
- }
620
- throw errorForResponse(response);
610
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
621
611
  }
622
612
 
623
613
  /** Build the request for `GET /inspect/{image_uuid}/list`. */
@@ -637,7 +627,7 @@ export function parseInspectImageList(response) {
637
627
  if (response.status >= 200 && response.status < 300) {
638
628
  return DirectoryList.fromWire(jsonObject(response));
639
629
  }
640
- throw errorForResponse(response);
630
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
641
631
  }
642
632
 
643
633
  /** Build the request for `GET /inspect/{image_uuid}/grep`. */
@@ -690,7 +680,7 @@ export function parseInspectImageGrep(response) {
690
680
  if (response.status >= 200 && response.status < 300) {
691
681
  return GrepResult.fromWire(jsonObject(response));
692
682
  }
693
- throw errorForResponse(response);
683
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
694
684
  }
695
685
 
696
686
  /** Build the request for `GET /whoami`. */
@@ -703,5 +693,5 @@ export function parseWhoami(response) {
703
693
  if (response.status >= 200 && response.status < 300) {
704
694
  return WhoAmIResponse.fromWire(jsonObject(response));
705
695
  }
706
- throw errorForResponse(response);
696
+ throw new RangeError(`unexpected HTTP status ${response.status}`);
707
697
  }
package/lib/profiles.d.ts CHANGED
@@ -1,11 +1,9 @@
1
- import { ContreeError } from "./errors.js";
2
-
3
1
  export declare const AUTH_TYPE_IAM: "iam";
4
2
  export declare const AUTH_TYPE_JWT: "jwt";
5
3
  export declare const PROFILE_PREFIX: "profile:";
6
4
  export declare const DEFAULT_PROFILE: "default";
7
5
 
8
- export declare class ProfileError extends ContreeError {}
6
+ export declare class ProfileError extends Error {}
9
7
 
10
8
  export interface Profile {
11
9
  name: string;
package/lib/profiles.js CHANGED
@@ -7,7 +7,6 @@
7
7
  * loaded lazily so browser bundlers never pull them in.
8
8
  */
9
9
 
10
- import { ContreeError } from "./errors.js";
11
10
  import { DEFAULT_BASE_URL } from "./specInfo.js";
12
11
 
13
12
  export const AUTH_TYPE_IAM = "iam";
@@ -15,7 +14,7 @@ export const AUTH_TYPE_JWT = "jwt";
15
14
  export const PROFILE_PREFIX = "profile:";
16
15
  export const DEFAULT_PROFILE = "default";
17
16
 
18
- export class ProfileError extends ContreeError {}
17
+ export class ProfileError extends Error {}
19
18
 
20
19
  function record() {
21
20
  // a null-prototype object: keys come from an external file, and a
package/lib/runtime.d.ts CHANGED
@@ -28,7 +28,7 @@ export interface RequestSpec {
28
28
  accept?: string | null;
29
29
  idempotent?: boolean;
30
30
  redirect?: "manual" | "follow";
31
- /** absolute deadline in monotonic seconds; bounds SSE idle waits */
31
+ /** absolute deadline in monotonic seconds; bounds transport waits */
32
32
  deadline?: number | null;
33
33
  }
34
34
 
@@ -53,7 +53,6 @@ export declare class RetryPolicy {
53
53
  export declare function parseRetryAfter(
54
54
  value: string | null | undefined,
55
55
  ): number | null;
56
- export declare function retryAfterDelay(response: ResponseData): number | null;
57
56
  export declare function sleep(seconds: number): Promise<void>;
58
57
  export declare function monotonic(): number;
59
58
  export declare function isUuid(ref: string): boolean;
@@ -75,7 +74,11 @@ export declare function jsonObject(
75
74
  response: ResponseData,
76
75
  ): Record<string, unknown>;
77
76
  export declare function jsonArray(response: ResponseData): unknown[];
78
- export declare function errorForResponse(response: ResponseData): Error;
77
+ export declare function responseErrorDetails(response: ResponseData): {
78
+ error: unknown;
79
+ traceback: string[] | null;
80
+ retryAfter: number | null;
81
+ };
79
82
 
80
83
  export interface SSEFrame {
81
84
  id: number | null;
package/lib/runtime.js CHANGED
@@ -5,16 +5,9 @@
5
5
  * fetch itself, so only the protocol logic lives here.
6
6
  */
7
7
 
8
- import {
9
- ContreeAPIError,
10
- ERROR_CLASSES,
11
- ServerError,
12
- SSEStreamError,
13
- } from "./errors.js";
14
-
15
8
  export const CHUNK_SIZE = 65536;
16
9
 
17
- export const PACKAGE_VERSION = "0.3.0";
10
+ export const PACKAGE_VERSION = "0.4.0";
18
11
  export const UA_PRODUCT = `contree-client-js/${PACKAGE_VERSION}`;
19
12
 
20
13
  const NODE_VERSION =
@@ -38,17 +31,6 @@ export const RETRY_DELAYS = Object.freeze([0.1, 0.2, 0.5, 1.0, 2.0, 5.0]);
38
31
  // returning immediate empty streams does not spin the client
39
32
  export const TIGHT_LOOP_FLOOR = 0.5;
40
33
 
41
- // the events route itself doesn't exist for this operation/server
42
- // (malformed request an older backend rejects, or a reverse proxy
43
- // that never forwards it) rather than merely being down: reconnecting
44
- // will never succeed, but the operation itself may still complete -
45
- // degrade to polling instead of failing the whole wait. 401/403 are
46
- // deliberately excluded: an auth/permission failure here likely means
47
- // the whole client is broken, not just this route, so it still throws
48
- export const EVENTS_UNAVAILABLE_STATUSES = Object.freeze(
49
- new Set([400, 404, 405, 406]),
50
- );
51
-
52
34
  /** An endless ladder of backoff delays: the ladder is walked once and
53
35
  * then the tail delay repeats forever. */
54
36
  export function* retryDelays(delays = RETRY_DELAYS) {
@@ -125,10 +107,6 @@ export function parseRetryAfter(value) {
125
107
  return Math.max(0, (moment - Date.now()) / 1000);
126
108
  }
127
109
 
128
- export function retryAfterDelay(response) {
129
- return parseRetryAfter(response.headers["retry-after"] ?? null);
130
- }
131
-
132
110
  export function sleep(seconds) {
133
111
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
134
112
  }
@@ -266,7 +244,9 @@ export function jsonBody(response) {
266
244
  export function jsonObject(response) {
267
245
  const data = jsonBody(response);
268
246
  if (data === null || typeof data !== "object" || Array.isArray(data)) {
269
- throw new ContreeAPIError(response.status, "expected a JSON object");
247
+ const type =
248
+ data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
249
+ throw new TypeError(`expected a JSON object, got ${type}`);
270
250
  }
271
251
  return data;
272
252
  }
@@ -274,13 +254,14 @@ export function jsonObject(response) {
274
254
  export function jsonArray(response) {
275
255
  const data = jsonBody(response);
276
256
  if (!Array.isArray(data)) {
277
- throw new ContreeAPIError(response.status, "expected a JSON array");
257
+ const type = data === null ? "null" : typeof data;
258
+ throw new TypeError(`expected a JSON array, got ${type}`);
278
259
  }
279
260
  return data;
280
261
  }
281
262
 
282
- /** Build the exception matching an error response. */
283
- export function errorForResponse(response) {
263
+ /** Extract diagnostic fields from an unsuccessful response. */
264
+ export function responseErrorDetails(response) {
284
265
  let error = bytesToText(response.body);
285
266
  let traceback = null;
286
267
  let payload = null;
@@ -301,11 +282,7 @@ export function errorForResponse(response) {
301
282
  }
302
283
  const parsedRetry = parseRetryAfter(response.headers["retry-after"] ?? null);
303
284
  const retryAfter = parsedRetry === null ? null : Math.trunc(parsedRetry);
304
- let cls = ERROR_CLASSES.get(response.status);
305
- if (cls === undefined) {
306
- cls = response.status >= 500 ? ServerError : ContreeAPIError;
307
- }
308
- return new cls(response.status, error, { traceback, retryAfter });
285
+ return { error, traceback, retryAfter };
309
286
  }
310
287
 
311
288
  /** Incremental sans-io parser for `text/event-stream` bytes.
@@ -334,7 +311,7 @@ export class SSEParser {
334
311
  // accumulated so far: many short data lines must not grow the
335
312
  // pending event unboundedly
336
313
  if (this.buffer.length + this.pendingSize > SSEParser.MAX_BUFFER) {
337
- throw new SSEStreamError(
314
+ throw new RangeError(
338
315
  `SSE frame exceeds ${SSEParser.MAX_BUFFER} bytes before completion`,
339
316
  );
340
317
  }
@@ -398,30 +375,15 @@ export class SSEParser {
398
375
  }
399
376
  }
400
377
 
401
- /** Decode an SSE frame's JSON payload.
402
- *
403
- * Raises SSEStreamError for in-band `sse_error` frames, carrying
404
- * *lastEventId* so the caller can reconnect from that point; returns
405
- * null for frames that carry no event payload. The caller turns the
406
- * plain object into a typed OperationEvent.
407
- */
378
+ /** Decode an SSE frame's JSON payload. */
408
379
  export function decodeFramePayload(frame, lastEventId = null) {
409
380
  if (frame.event === "sse_error") {
410
- throw new SSEStreamError(frame.data, { lastEventId });
381
+ throw Object.assign(new Error(frame.data), { lastEventId });
411
382
  }
412
383
  if (!frame.data) {
413
384
  return null;
414
385
  }
415
- let payload;
416
- try {
417
- payload = JSON.parse(frame.data);
418
- } catch (error) {
419
- // surface protocol corruption through the SSE error channel so
420
- // followOperationEvents reconnects instead of crashing
421
- throw new SSEStreamError(`malformed SSE event payload: ${error}`, {
422
- lastEventId,
423
- });
424
- }
386
+ const payload = JSON.parse(frame.data);
425
387
  if (
426
388
  payload === null ||
427
389
  typeof payload !== "object" ||
package/lib/testing.js CHANGED
@@ -7,7 +7,6 @@
7
7
  */
8
8
 
9
9
  import { ContreeClient as GeneratedClient } from "./client.js";
10
- import { ContreeError } from "./errors.js";
11
10
 
12
11
  export const RESERVED = new Set([
13
12
  "constructor",
@@ -31,7 +30,7 @@ function apiMethodNames() {
31
30
  }
32
31
 
33
32
  function unmocked(operation) {
34
- return new ContreeError(
33
+ return new Error(
35
34
  `no mock configured for ${operation}(); arm it with` +
36
35
  ` client.mock(${JSON.stringify(operation)}, result)`,
37
36
  );
@@ -94,9 +93,7 @@ export class ContreeClient extends GeneratedClient {
94
93
  /** Queue an outcome for *operation*; the last one queued is sticky. */
95
94
  mock(operation, result = null, { error = null } = {}) {
96
95
  if (typeof this[operation] !== "function" || RESERVED.has(operation)) {
97
- throw new ContreeError(
98
- `unknown API operation ${JSON.stringify(operation)}`,
99
- );
96
+ throw new TypeError(`unknown API operation ${JSON.stringify(operation)}`);
100
97
  }
101
98
  const queue = this.mocks.get(operation) ?? [];
102
99
  queue.push({ result, error });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contree-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "JavaScript client for the Contree API, generated from the OpenAPI spec",
5
5
  "homepage": "https://contree.dev/",
6
6
  "repository": {