@layervai/qurl 0.3.1 → 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 +187 -7
- package/dist/cjs/client.d.ts +17 -10
- package/dist/cjs/client.js +649 -96
- package/dist/cjs/errors.d.ts +11 -11
- package/dist/cjs/errors.js +25 -20
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/internal.d.ts +4 -0
- package/dist/cjs/internal.js +16 -0
- package/dist/cjs/node/deployment.d.ts +44 -0
- package/dist/cjs/node/deployment.js +167 -0
- package/dist/cjs/node/native-udp.d.ts +37 -0
- package/dist/cjs/node/native-udp.js +294 -0
- package/dist/cjs/node/nhp-wire.d.ts +35 -0
- package/dist/cjs/node/nhp-wire.js +261 -0
- package/dist/cjs/node/portal-opener.d.ts +83 -0
- package/dist/cjs/node/portal-opener.js +767 -0
- package/dist/cjs/node/qv2.d.ts +38 -0
- package/dist/cjs/node/qv2.js +292 -0
- package/dist/cjs/node/strict-json.d.ts +12 -0
- package/dist/cjs/node/strict-json.js +174 -0
- package/dist/cjs/node.d.ts +3 -0
- package/dist/cjs/node.js +11 -0
- package/dist/cjs/types.d.ts +7 -18
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +17 -10
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +647 -94
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +11 -11
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +25 -20
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/internal.d.ts +5 -0
- package/dist/esm/internal.d.ts.map +1 -0
- package/dist/esm/internal.js +14 -0
- package/dist/esm/internal.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +45 -0
- package/dist/esm/node/deployment.d.ts.map +1 -0
- package/dist/esm/node/deployment.js +163 -0
- package/dist/esm/node/deployment.js.map +1 -0
- package/dist/esm/node/native-udp.d.ts +38 -0
- package/dist/esm/node/native-udp.d.ts.map +1 -0
- package/dist/esm/node/native-udp.js +291 -0
- package/dist/esm/node/native-udp.js.map +1 -0
- package/dist/esm/node/nhp-wire.d.ts +36 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -0
- package/dist/esm/node/nhp-wire.js +257 -0
- package/dist/esm/node/nhp-wire.js.map +1 -0
- package/dist/esm/node/portal-opener.d.ts +84 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -0
- package/dist/esm/node/portal-opener.js +757 -0
- package/dist/esm/node/portal-opener.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +39 -0
- package/dist/esm/node/qv2.d.ts.map +1 -0
- package/dist/esm/node/qv2.js +286 -0
- package/dist/esm/node/qv2.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +13 -0
- package/dist/esm/node/strict-json.d.ts.map +1 -0
- package/dist/esm/node/strict-json.js +171 -0
- package/dist/esm/node/strict-json.js.map +1 -0
- package/dist/esm/node.d.ts +4 -0
- package/dist/esm/node.d.ts.map +1 -0
- package/dist/esm/node.js +2 -0
- package/dist/esm/node.js.map +1 -0
- package/dist/esm/types.d.ts +7 -18
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +25 -6
package/dist/cjs/client.js
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ProtectedResource = exports.QURLClient = void 0;
|
|
3
|
+
exports.ProtectedResource = exports.QURLClient = exports.SIGNED_FRAGMENT_RE = void 0;
|
|
4
4
|
const errors_js_1 = require("./errors.js");
|
|
5
|
+
const internal_js_1 = require("./internal.js");
|
|
5
6
|
const version_js_1 = require("./version.js");
|
|
6
7
|
const DEFAULT_BASE_URL = "https://api.layerv.ai";
|
|
7
8
|
const DEFAULT_MAX_RETRIES = 3;
|
|
8
9
|
const DEFAULT_TIMEOUT = 30_000;
|
|
10
|
+
// Match qurl-go c4f2f98's exact API response cap. Current qurl-service list
|
|
11
|
+
// routes cap pages at 100 items. A caller can still exceed this cap with a
|
|
12
|
+
// large, heavily escaped webhook-delivery page, so list callers must request a
|
|
13
|
+
// smaller page if the service reports the body-limit error. This cap is fixed:
|
|
14
|
+
// do not add an override or widen one SDK independently from the other.
|
|
15
|
+
// The limit is enforced from Content-Length when usable and again while
|
|
16
|
+
// consuming the response stream.
|
|
17
|
+
const MAX_RESPONSE_BODY_BYTES = 1 << 20;
|
|
18
|
+
const MAX_ERROR_SNIPPET_BYTES = 512;
|
|
19
|
+
// UTF-8 uses at least one byte per UTF-16 code unit. Reading more source units
|
|
20
|
+
// cannot add a byte that fits in a 512-byte retained diagnostic.
|
|
21
|
+
const MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS = MAX_ERROR_SNIPPET_BYTES;
|
|
22
|
+
const MAX_INVALID_FIELD_ENTRIES = 100;
|
|
23
|
+
const MAX_DEBUG_BODY_KEYS = 100;
|
|
24
|
+
// Bound each retained diagnostic collection as well as each string. Without
|
|
25
|
+
// this aggregate cap, 100 valid 512-byte keys and values could turn a small
|
|
26
|
+
// server error into a large caller-visible object. This is a diagnostic budget,
|
|
27
|
+
// not part of the API wire contract.
|
|
28
|
+
const MAX_DIAGNOSTIC_COLLECTION_BYTES = 8 << 10;
|
|
29
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
30
|
+
const ERROR_SNIPPET_ELLIPSIS = "...";
|
|
31
|
+
const ERROR_SNIPPET_ELLIPSIS_BYTES = 3;
|
|
32
|
+
// RFC 8259 JSON exchanged between systems must be valid UTF-8. A non-fatal
|
|
33
|
+
// decoder would replace malformed bytes with U+FFFD before JSON.parse and hide
|
|
34
|
+
// the producer's exact contract violation.
|
|
35
|
+
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
9
36
|
const RETRY_BASE_DELAY_MS = 500;
|
|
10
37
|
// Bounds local exponential backoff (NOT server-asserted Retry-After —
|
|
11
38
|
// see `RETRY_AFTER_HARD_CAP_MS` for that).
|
|
@@ -18,13 +45,33 @@ const RETRY_AFTER_PARSE_LIMIT_S = RETRY_AFTER_HARD_CAP_MS / 1000;
|
|
|
18
45
|
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
19
46
|
// Keep mutating status retries 429-only until the service-side idempotency
|
|
20
47
|
// cache contract is proven across 5xx, TTL, and replay-window edge cases.
|
|
21
|
-
//
|
|
48
|
+
// Fetch-level POST/PATCH retries still reuse Idempotency-Key below.
|
|
22
49
|
const RETRYABLE_STATUS_MUTATING = new Set([429]);
|
|
50
|
+
const REDIRECT_RESPONSE_STATUSES = new Set([300, 301, 302, 303, 305, 307, 308]);
|
|
51
|
+
const NO_RETRYABLE_STATUSES = new Set();
|
|
23
52
|
const IDEMPOTENCY_KEY_METHODS = new Set(["POST", "PATCH"]);
|
|
24
53
|
const MUTATING_RETRY_METHODS = new Set(["POST", "PATCH"]);
|
|
25
54
|
const MAX_IDEMPOTENCY_KEY = 256;
|
|
26
55
|
const IDEMPOTENCY_KEY_VALUE_RE = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/;
|
|
27
56
|
const UUID_HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
57
|
+
class ResponseBodyTooLargeError extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super(`Response body exceeds ${MAX_RESPONSE_BODY_BYTES}-byte limit`);
|
|
60
|
+
this.name = "ResponseBodyTooLargeError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
class InvalidResponseEncodingError extends Error {
|
|
64
|
+
constructor() {
|
|
65
|
+
super("Response body is not valid UTF-8");
|
|
66
|
+
this.name = "InvalidResponseEncodingError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
class ResponseBodyMaterializationError extends Error {
|
|
70
|
+
constructor(message = "Response-like fetch returned a value that cannot be serialized as JSON") {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "ResponseBodyMaterializationError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
28
75
|
const NO_PASSTHROUGH_STATUSES = [];
|
|
29
76
|
const BATCH_PASSTHROUGH_STATUSES = [400];
|
|
30
77
|
/** Allowlist of known query-param keys for the `list()` endpoint. */
|
|
@@ -160,13 +207,6 @@ const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
|
|
|
160
207
|
"session_duration",
|
|
161
208
|
];
|
|
162
209
|
assertExhaustive(true);
|
|
163
|
-
const AGENT_BOOTSTRAP_FIELD_KEYS = [
|
|
164
|
-
"public_key",
|
|
165
|
-
"agent_id",
|
|
166
|
-
"hostname",
|
|
167
|
-
"version",
|
|
168
|
-
];
|
|
169
|
-
assertExhaustive(true);
|
|
170
210
|
const UPDATE_CUSTOMER_FIELD_KEYS = [
|
|
171
211
|
"spending_cap_cents",
|
|
172
212
|
];
|
|
@@ -342,23 +382,330 @@ function fillRandomBytes(bytes) {
|
|
|
342
382
|
* Distinct from {@link clientValidationError} so callers can `.code`-branch
|
|
343
383
|
* between "I passed bad input locally" (`"client_validation"`) and "the
|
|
344
384
|
* server returned a body I can't interpret" (`"unexpected_response"`).
|
|
345
|
-
* Uses `status: 0` because
|
|
346
|
-
* the
|
|
385
|
+
* Uses `status: 0` by default because a logical shape mismatch is not tied to
|
|
386
|
+
* the HTTP classification. Callers that enforce an observed HTTP contract
|
|
387
|
+
* pass that status explicitly.
|
|
347
388
|
*
|
|
348
389
|
* Threads `request_id` through to {@link QURLError.requestId} when the
|
|
349
390
|
* server-side correlation ID is available — operators debugging
|
|
350
391
|
* "unexpected response" tickets need it on the *error* path too, not
|
|
351
392
|
* just on success/passthrough returns.
|
|
352
393
|
*/
|
|
353
|
-
function unexpectedResponseError(detail,
|
|
394
|
+
function unexpectedResponseError(detail, options = {}) {
|
|
395
|
+
// Every detail passed here is an SDK-authored contract message whose dynamic
|
|
396
|
+
// inputs were validated by the caller-side shape guards. Do not pass raw
|
|
397
|
+
// server strings to this helper. Server diagnostics use boundedErrorSnippet.
|
|
398
|
+
const safeRequestId = boundedErrorIdentifier(options.requestId);
|
|
354
399
|
return new errors_js_1.ValidationError({
|
|
355
|
-
status: 0,
|
|
400
|
+
status: options.status ?? 0,
|
|
356
401
|
code: errors_js_1.ERROR_CODE_UNEXPECTED_RESPONSE,
|
|
357
402
|
title: "Unexpected Response",
|
|
358
403
|
detail,
|
|
359
|
-
request_id,
|
|
404
|
+
request_id: safeRequestId,
|
|
360
405
|
});
|
|
361
406
|
}
|
|
407
|
+
function withErrorCause(error, cause) {
|
|
408
|
+
// Match the native Error `cause` descriptor. In particular, do not leak a
|
|
409
|
+
// transport object through Object.keys(), object spread, or JSON logging.
|
|
410
|
+
Object.defineProperty(error, "cause", {
|
|
411
|
+
value: cause,
|
|
412
|
+
writable: true,
|
|
413
|
+
configurable: true,
|
|
414
|
+
});
|
|
415
|
+
return error;
|
|
416
|
+
}
|
|
417
|
+
function isIndependentAbort(error, requestSignal) {
|
|
418
|
+
return (error instanceof Error &&
|
|
419
|
+
(error.name === "AbortError" || error.name === "TimeoutError") &&
|
|
420
|
+
!requestSignal.aborted);
|
|
421
|
+
}
|
|
422
|
+
function isSdkTimeout(error, requestSignal) {
|
|
423
|
+
return (error instanceof Error &&
|
|
424
|
+
requestSignal.aborted &&
|
|
425
|
+
(error.name === "TimeoutError" || error.name === "AbortError"));
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Normalize a server-provided problem title/detail into a single-line,
|
|
429
|
+
* UTF-8-safe snippet. Error messages must stay bounded even when a proxy or
|
|
430
|
+
* compromised upstream returns very large structured problem fields.
|
|
431
|
+
*/
|
|
432
|
+
function normalizedErrorSnippet(value) {
|
|
433
|
+
if (typeof value !== "string")
|
|
434
|
+
return undefined;
|
|
435
|
+
// Bound source work before the Unicode regular expressions allocate
|
|
436
|
+
// normalized copies. The response body cap is much larger than one retained
|
|
437
|
+
// diagnostic, so processing the full server string would waste memory.
|
|
438
|
+
const normalized = value
|
|
439
|
+
.slice(0, MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS)
|
|
440
|
+
// Strip control characters plus bidi override/isolate controls that can
|
|
441
|
+
// visually reorder diagnostics. Preserve other formatting characters
|
|
442
|
+
// such as ZWJ/ZWNJ, which are required by legitimate scripts and emoji.
|
|
443
|
+
.replace(/[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, " ")
|
|
444
|
+
.trim()
|
|
445
|
+
.replace(/\s+/g, " ");
|
|
446
|
+
return normalized === "" ? undefined : normalized;
|
|
447
|
+
}
|
|
448
|
+
function boundedErrorSnippet(value) {
|
|
449
|
+
const sourceTruncated = typeof value === "string" && value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS;
|
|
450
|
+
const normalized = normalizedErrorSnippet(value);
|
|
451
|
+
if (normalized === undefined)
|
|
452
|
+
return undefined;
|
|
453
|
+
const contentLimit = sourceTruncated
|
|
454
|
+
? MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES
|
|
455
|
+
: MAX_ERROR_SNIPPET_BYTES;
|
|
456
|
+
// UTF-8 uses at most three bytes per UTF-16 code unit. Most diagnostics are
|
|
457
|
+
// short ASCII, so avoid an allocation only to prove they fit.
|
|
458
|
+
if (normalized.length * 3 <= contentLimit) {
|
|
459
|
+
return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
|
|
460
|
+
}
|
|
461
|
+
const encoded = TEXT_ENCODER.encode(normalized);
|
|
462
|
+
if (encoded.byteLength <= contentLimit) {
|
|
463
|
+
return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
|
|
464
|
+
}
|
|
465
|
+
let end = MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES;
|
|
466
|
+
// Do not split a multi-byte UTF-8 sequence at the cap.
|
|
467
|
+
while (end > 0 && (encoded[end] & 0xc0) === 0x80)
|
|
468
|
+
end--;
|
|
469
|
+
return `${TEXT_DECODER.decode(encoded.subarray(0, end))}${ERROR_SNIPPET_ELLIPSIS}`;
|
|
470
|
+
}
|
|
471
|
+
/** Keep server identifiers useful as exact machine-readable discriminants. */
|
|
472
|
+
function boundedErrorIdentifier(value) {
|
|
473
|
+
if (typeof value !== "string" || value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
const normalized = normalizedErrorSnippet(value);
|
|
477
|
+
if (normalized === undefined ||
|
|
478
|
+
normalized !== value ||
|
|
479
|
+
TEXT_ENCODER.encode(value).byteLength > MAX_ERROR_SNIPPET_BYTES) {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
return value;
|
|
483
|
+
}
|
|
484
|
+
/** Collect at most the first N enumerable own string keys without a full key-array copy. */
|
|
485
|
+
function firstEnumerableOwnKeys(value, limit) {
|
|
486
|
+
const keys = [];
|
|
487
|
+
for (const key in value) {
|
|
488
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
489
|
+
continue;
|
|
490
|
+
keys.push(key);
|
|
491
|
+
if (keys.length === limit)
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
return keys;
|
|
495
|
+
}
|
|
496
|
+
/** Bound the structured per-field diagnostics exposed on ValidationError. */
|
|
497
|
+
function boundedInvalidFields(value) {
|
|
498
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
499
|
+
return undefined;
|
|
500
|
+
const entries = [];
|
|
501
|
+
const retainedKeys = new Set();
|
|
502
|
+
let retainedBytes = 0;
|
|
503
|
+
// Bound inspected entries as well as retained entries. A hostile envelope
|
|
504
|
+
// whose values all have the wrong type must not make diagnostics scan every
|
|
505
|
+
// key in the response body.
|
|
506
|
+
for (const rawKey of firstEnumerableOwnKeys(value, MAX_INVALID_FIELD_ENTRIES)) {
|
|
507
|
+
const rawValue = value[rawKey];
|
|
508
|
+
const key = boundedErrorSnippet(rawKey);
|
|
509
|
+
const fieldDetail = boundedErrorSnippet(rawValue);
|
|
510
|
+
if (key !== undefined && fieldDetail !== undefined && !retainedKeys.has(key)) {
|
|
511
|
+
const entryBytes = TEXT_ENCODER.encode(key).byteLength + TEXT_ENCODER.encode(fieldDetail).byteLength;
|
|
512
|
+
// A later, smaller entry can still fit even when this one cannot.
|
|
513
|
+
if (retainedBytes + entryBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
|
|
514
|
+
continue;
|
|
515
|
+
retainedKeys.add(key);
|
|
516
|
+
entries.push([key, fieldDetail]);
|
|
517
|
+
retainedBytes += entryBytes;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
521
|
+
}
|
|
522
|
+
/** Bound server-controlled object keys before forwarding them to a debug sink. */
|
|
523
|
+
function boundedObjectKeys(value) {
|
|
524
|
+
// Stop before normalization so a hostile envelope cannot force work over
|
|
525
|
+
// every enumerable own key. Some early keys may normalize away; bounded
|
|
526
|
+
// diagnostics are more important here than filling every available slot.
|
|
527
|
+
const retained = [];
|
|
528
|
+
let retainedBytes = 0;
|
|
529
|
+
for (const rawKey of firstEnumerableOwnKeys(value, MAX_DEBUG_BODY_KEYS)) {
|
|
530
|
+
const key = boundedErrorSnippet(rawKey);
|
|
531
|
+
if (key === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
const keyBytes = TEXT_ENCODER.encode(key).byteLength;
|
|
534
|
+
// A later, smaller key can still fit even when this one cannot.
|
|
535
|
+
if (retainedBytes + keyBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
|
|
536
|
+
continue;
|
|
537
|
+
retained.push(key);
|
|
538
|
+
retainedBytes += keyBytes;
|
|
539
|
+
}
|
|
540
|
+
return retained;
|
|
541
|
+
}
|
|
542
|
+
async function cancelResponseBody(body) {
|
|
543
|
+
if (!body)
|
|
544
|
+
return;
|
|
545
|
+
try {
|
|
546
|
+
await body.cancel();
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// Best-effort resource cleanup must not replace the deterministic SDK error.
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function materializedResponseBodyExceedsLimit(value) {
|
|
553
|
+
if (value.length > MAX_RESPONSE_BODY_BYTES)
|
|
554
|
+
return true;
|
|
555
|
+
// Encoding creates another buffer. UTF-8 uses at most three bytes per
|
|
556
|
+
// UTF-16 code unit, so shorter strings are provably safe without that copy.
|
|
557
|
+
return (value.length * 3 > MAX_RESPONSE_BODY_BYTES &&
|
|
558
|
+
TEXT_ENCODER.encode(value).byteLength > MAX_RESPONSE_BODY_BYTES);
|
|
559
|
+
}
|
|
560
|
+
function contentLengthExceedsLimit(response) {
|
|
561
|
+
// 204/205 never carry content, and a 304 Content-Length describes the
|
|
562
|
+
// selected representation rather than a response body. Do not reject those
|
|
563
|
+
// statuses for metadata about bytes that fetch will not deliver.
|
|
564
|
+
if (response.status === 204 || response.status === 205 || response.status === 304)
|
|
565
|
+
return false;
|
|
566
|
+
const value = response.headers.get("content-length");
|
|
567
|
+
if (value === null || !/^\d+$/.test(value.trim()))
|
|
568
|
+
return false;
|
|
569
|
+
return Number(value) > MAX_RESPONSE_BODY_BYTES;
|
|
570
|
+
}
|
|
571
|
+
/** Detect a followed redirect from fetch implementations that expose only the final URL. */
|
|
572
|
+
function responseUrlState(responseUrl, requestUrl) {
|
|
573
|
+
if (typeof responseUrl !== "string" || responseUrl === "")
|
|
574
|
+
return "same";
|
|
575
|
+
if (responseUrl === requestUrl)
|
|
576
|
+
return "same";
|
|
577
|
+
try {
|
|
578
|
+
// URL serialization normalizes equivalent spellings such as host case and
|
|
579
|
+
// an explicit default port, so they do not create false redirect reports.
|
|
580
|
+
return new URL(responseUrl).href === new URL(requestUrl).href ? "same" : "changed";
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// A non-empty invalid response URL cannot prove that credentials stayed on
|
|
584
|
+
// the requested endpoint. Fail closed without reflecting it in diagnostics.
|
|
585
|
+
return "invalid";
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Read a response body while retaining accepted chunks up to the documented
|
|
590
|
+
* cap. A fetch implementation can still hand us one arbitrarily large,
|
|
591
|
+
* already-materialized chunk before we can reject it. For a compliant body,
|
|
592
|
+
* final assembly transiently holds the chunks plus the combined output buffer
|
|
593
|
+
* (roughly twice the cap) before decoding. Content-Length is an early-rejection
|
|
594
|
+
* optimization only; streaming byte accounting remains authoritative because
|
|
595
|
+
* that header may be absent or false.
|
|
596
|
+
*/
|
|
597
|
+
async function readBoundedResponseBody(response) {
|
|
598
|
+
// `headers.get` is part of the documented injected-fetch contract. Detect a
|
|
599
|
+
// malformed local shim before it can be mistaken for a retryable body-stream
|
|
600
|
+
// failure and replay an otherwise deterministic GET programming error.
|
|
601
|
+
if (typeof response.headers?.get !== "function") {
|
|
602
|
+
throw new ResponseBodyMaterializationError("Response-like fetch must provide headers.get(name)");
|
|
603
|
+
}
|
|
604
|
+
if (contentLengthExceedsLimit(response)) {
|
|
605
|
+
await cancelResponseBody(response.body);
|
|
606
|
+
throw new ResponseBodyTooLargeError();
|
|
607
|
+
}
|
|
608
|
+
const body = response.body;
|
|
609
|
+
if (body === null)
|
|
610
|
+
return "";
|
|
611
|
+
// Standards-compliant fetch implementations always expose a ReadableStream
|
|
612
|
+
// for a non-empty body. Retain compatibility with Response-like test/custom
|
|
613
|
+
// fetch implementations while still checking their materialized text before
|
|
614
|
+
// this SDK parses it as JSON.
|
|
615
|
+
if (body === undefined || typeof body.getReader !== "function") {
|
|
616
|
+
let materializedText = "";
|
|
617
|
+
if (typeof response.text === "function") {
|
|
618
|
+
try {
|
|
619
|
+
materializedText = await response.text();
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// Native fetch exposes a body stream above. A failing text() here is a
|
|
623
|
+
// deterministic custom Response contract error, not a retryable reset.
|
|
624
|
+
throw new ResponseBodyMaterializationError("Response-like fetch text() failed");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const text = typeof materializedText === "string" ? materializedText : "";
|
|
628
|
+
if (materializedResponseBodyExceedsLimit(text)) {
|
|
629
|
+
throw new ResponseBodyTooLargeError();
|
|
630
|
+
}
|
|
631
|
+
if (text !== "" ||
|
|
632
|
+
response.status === 204 ||
|
|
633
|
+
response.status === 205 ||
|
|
634
|
+
response.status === 304) {
|
|
635
|
+
return text;
|
|
636
|
+
}
|
|
637
|
+
// Older injected Response-like objects may implement json() but return an
|
|
638
|
+
// empty placeholder from text(). This path is never used by native fetch.
|
|
639
|
+
let parsed;
|
|
640
|
+
try {
|
|
641
|
+
parsed = await response.json();
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
// A Response-like shim may expose json() but reject on an empty 204
|
|
645
|
+
// body. Native fetch represents that body as null and returns above.
|
|
646
|
+
return "";
|
|
647
|
+
}
|
|
648
|
+
let serialized;
|
|
649
|
+
try {
|
|
650
|
+
serialized = JSON.stringify(parsed);
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
throw new ResponseBodyMaterializationError();
|
|
654
|
+
}
|
|
655
|
+
if (serialized === undefined)
|
|
656
|
+
return "";
|
|
657
|
+
if (materializedResponseBodyExceedsLimit(serialized)) {
|
|
658
|
+
throw new ResponseBodyTooLargeError();
|
|
659
|
+
}
|
|
660
|
+
return serialized;
|
|
661
|
+
}
|
|
662
|
+
const reader = body.getReader();
|
|
663
|
+
const chunks = [];
|
|
664
|
+
let total = 0;
|
|
665
|
+
try {
|
|
666
|
+
while (true) {
|
|
667
|
+
const { done, value } = await reader.read();
|
|
668
|
+
if (done)
|
|
669
|
+
break;
|
|
670
|
+
if (!(value instanceof Uint8Array)) {
|
|
671
|
+
throw new ResponseBodyMaterializationError("Response-like fetch body stream must yield Uint8Array chunks");
|
|
672
|
+
}
|
|
673
|
+
const nextTotal = total + value.byteLength;
|
|
674
|
+
if (nextTotal > MAX_RESPONSE_BODY_BYTES) {
|
|
675
|
+
try {
|
|
676
|
+
await reader.cancel();
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// Preserve the body-limit failure even if stream cancellation fails.
|
|
680
|
+
}
|
|
681
|
+
throw new ResponseBodyTooLargeError();
|
|
682
|
+
}
|
|
683
|
+
chunks.push(value);
|
|
684
|
+
total = nextTotal;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
reader.releaseLock();
|
|
689
|
+
}
|
|
690
|
+
let bytes;
|
|
691
|
+
if (chunks.length === 1) {
|
|
692
|
+
bytes = chunks[0];
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
bytes = new Uint8Array(total);
|
|
696
|
+
let offset = 0;
|
|
697
|
+
for (const chunk of chunks) {
|
|
698
|
+
bytes.set(chunk, offset);
|
|
699
|
+
offset += chunk.byteLength;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
return TEXT_DECODER.decode(bytes);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
throw new InvalidResponseEncodingError();
|
|
707
|
+
}
|
|
708
|
+
}
|
|
362
709
|
// ---- Spec-derived validation helpers ------------------------------------
|
|
363
710
|
// These mirror constraints documented on each request schema in
|
|
364
711
|
// `openapi.yaml` so obvious mistakes fail fast instead of round-tripping
|
|
@@ -928,10 +1275,14 @@ const MIN_PORTAL_VALID_FOR_SECONDS = 60;
|
|
|
928
1275
|
* treated as the server default).
|
|
929
1276
|
*/
|
|
930
1277
|
const MIN_SESSION_DURATION_SECONDS = 1;
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1278
|
+
/**
|
|
1279
|
+
* Shape of qurl-go's offline signed-fragment links: three or more
|
|
1280
|
+
* dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
|
|
1281
|
+
* Detected only to give those links a precise error in extractAccessToken.
|
|
1282
|
+
*
|
|
1283
|
+
* @internal Exported for in-package tests; not re-exported from index.ts.
|
|
1284
|
+
*/
|
|
1285
|
+
exports.SIGNED_FRAGMENT_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$/;
|
|
935
1286
|
// Access-token grammar accepted by enterPortal (base64url charset).
|
|
936
1287
|
const ACCESS_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
|
|
937
1288
|
/**
|
|
@@ -1016,7 +1367,7 @@ function extractAccessToken(qurlLink) {
|
|
|
1016
1367
|
}
|
|
1017
1368
|
const hashIndex = qurlLink.indexOf("#");
|
|
1018
1369
|
const token = hashIndex >= 0 ? qurlLink.slice(hashIndex + 1) : qurlLink;
|
|
1019
|
-
if (SIGNED_FRAGMENT_RE.test(token)) {
|
|
1370
|
+
if (exports.SIGNED_FRAGMENT_RE.test(token)) {
|
|
1020
1371
|
throw clientValidationError("qurlLink: this is a signed qURL link (offline fragment format), which cannot be " +
|
|
1021
1372
|
"opened through the API resolver — enterPortal only accepts platform links " +
|
|
1022
1373
|
"(https://qurl.link/#at_...) or bare access tokens");
|
|
@@ -1120,7 +1471,7 @@ class QURLClient {
|
|
|
1120
1471
|
throw clientValidationError("apiKey: must not contain CR/LF characters");
|
|
1121
1472
|
}
|
|
1122
1473
|
this.apiKey = options.apiKey;
|
|
1123
|
-
const rawBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL)
|
|
1474
|
+
const rawBaseUrl = (0, internal_js_1.stripTrailingSlashes)(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
1124
1475
|
// Parse the URL so the loopback exemption matches on the hostname,
|
|
1125
1476
|
// not a string prefix. `startsWith("http://localhost")` would
|
|
1126
1477
|
// accept `http://localhost.attacker.com` and silently send the
|
|
@@ -1275,7 +1626,7 @@ class QURLClient {
|
|
|
1275
1626
|
// .requestId property AND the message string so a stack trace
|
|
1276
1627
|
// pasted into a support ticket carries the correlation handle
|
|
1277
1628
|
// without a follow-up round-trip.
|
|
1278
|
-
const requestId = envelope.meta?.request_id;
|
|
1629
|
+
const requestId = boundedErrorIdentifier(envelope.meta?.request_id);
|
|
1279
1630
|
const requestIdSuffix = requestId !== undefined ? ` [request_id=${requestId}]` : "";
|
|
1280
1631
|
// `Number.isInteger` rejects NaN, Infinity, and floats. Combined
|
|
1281
1632
|
// with `>= 0`, this rules out pathological proxy bodies (e.g.
|
|
@@ -1288,12 +1639,12 @@ class QURLClient {
|
|
|
1288
1639
|
!Number.isInteger(result.failed) ||
|
|
1289
1640
|
result.failed < 0 ||
|
|
1290
1641
|
!Array.isArray(result.results)) {
|
|
1291
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, requestId);
|
|
1642
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, { requestId });
|
|
1292
1643
|
}
|
|
1293
1644
|
if (result.succeeded + result.failed !== result.results.length) {
|
|
1294
1645
|
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
|
|
1295
1646
|
`counts/results length mismatch (succeeded=${result.succeeded}, ` +
|
|
1296
|
-
`failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, requestId);
|
|
1647
|
+
`failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, { requestId });
|
|
1297
1648
|
}
|
|
1298
1649
|
// OpenAPI contract: one result per input item. Without this check, a
|
|
1299
1650
|
// server that drops items (e.g. sends 2 results for 3 inputs with
|
|
@@ -1304,7 +1655,7 @@ class QURLClient {
|
|
|
1304
1655
|
// exactly [0, requestItemCount).
|
|
1305
1656
|
if (result.results.length !== requestItemCount) {
|
|
1306
1657
|
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
|
|
1307
|
-
`results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, requestId);
|
|
1658
|
+
`results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, { requestId });
|
|
1308
1659
|
}
|
|
1309
1660
|
// Per-entry discriminated-union contract. Reason strings report
|
|
1310
1661
|
// field NAMES only, never values — safe for observability pipelines.
|
|
@@ -1316,14 +1667,14 @@ class QURLClient {
|
|
|
1316
1667
|
for (let i = 0; i < result.results.length; i++) {
|
|
1317
1668
|
const reason = batchItemResultValidationReason(result.results[i]);
|
|
1318
1669
|
if (reason !== null) {
|
|
1319
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, requestId);
|
|
1670
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, { requestId });
|
|
1320
1671
|
}
|
|
1321
1672
|
const entry = result.results[i];
|
|
1322
1673
|
if (entry.index >= requestItemCount) {
|
|
1323
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, requestId);
|
|
1674
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, { requestId });
|
|
1324
1675
|
}
|
|
1325
1676
|
if (seenIndices.has(entry.index)) {
|
|
1326
|
-
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, requestId);
|
|
1677
|
+
throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, { requestId });
|
|
1327
1678
|
}
|
|
1328
1679
|
seenIndices.add(entry.index);
|
|
1329
1680
|
}
|
|
@@ -1349,11 +1700,11 @@ class QURLClient {
|
|
|
1349
1700
|
break;
|
|
1350
1701
|
}
|
|
1351
1702
|
if (seenCursors.has(cursor)) {
|
|
1352
|
-
throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, page.request_id);
|
|
1703
|
+
throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, { requestId: page.request_id });
|
|
1353
1704
|
}
|
|
1354
1705
|
seenCursors.add(cursor);
|
|
1355
1706
|
if (pageCount >= MAX_AUTO_PAGINATION_PAGES) {
|
|
1356
|
-
throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, page.request_id);
|
|
1707
|
+
throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, { requestId: page.request_id });
|
|
1357
1708
|
}
|
|
1358
1709
|
} while (cursor);
|
|
1359
1710
|
}
|
|
@@ -1800,7 +2151,7 @@ class QURLClient {
|
|
|
1800
2151
|
`got an ID starting with "${observedPrefix}". ` +
|
|
1801
2152
|
"To revoke a single access token, use the resource-scoped token endpoint.");
|
|
1802
2153
|
}
|
|
1803
|
-
await this.
|
|
2154
|
+
await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
|
|
1804
2155
|
}
|
|
1805
2156
|
/**
|
|
1806
2157
|
* Extend a qURL's expiration.
|
|
@@ -1952,15 +2303,6 @@ class QURLClient {
|
|
|
1952
2303
|
async getQuota() {
|
|
1953
2304
|
return this.request("GET", "/v1/quota");
|
|
1954
2305
|
}
|
|
1955
|
-
/** Bootstrap a LayerV qURL Connector agent. */
|
|
1956
|
-
async bootstrapAgent(input, options) {
|
|
1957
|
-
requireObjectInput(input, "bootstrapAgent");
|
|
1958
|
-
requireNoUnknownFields(input, AGENT_BOOTSTRAP_FIELD_KEYS, "bootstrapAgent");
|
|
1959
|
-
const normalizedRecord = normalizePatchFields(input, AGENT_BOOTSTRAP_FIELD_KEYS);
|
|
1960
|
-
const normalized = normalizedRecord;
|
|
1961
|
-
requireNonEmptyStringField(normalizedRecord, "public_key", "bootstrapAgent");
|
|
1962
|
-
return this.request("POST", "/v1/agent/bootstrap", normalized, options);
|
|
1963
|
-
}
|
|
1964
2306
|
/** List resources from the `/v1/resources` API. */
|
|
1965
2307
|
async listResources(input = {}) {
|
|
1966
2308
|
const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/resources", input, RESOURCE_LIST_PARAM_KEYS, "listResources"));
|
|
@@ -2014,7 +2356,7 @@ class QURLClient {
|
|
|
2014
2356
|
/** Revoke a resource and all of its qURLs. */
|
|
2015
2357
|
async deleteResource(id) {
|
|
2016
2358
|
requireNonEmptyId(id, "deleteResource");
|
|
2017
|
-
await this.
|
|
2359
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
|
|
2018
2360
|
}
|
|
2019
2361
|
/**
|
|
2020
2362
|
* Mint a qURL against an existing resource.
|
|
@@ -2037,7 +2379,7 @@ class QURLClient {
|
|
|
2037
2379
|
async revokeResourceQurl(id, qurlId) {
|
|
2038
2380
|
requireNonEmptyId(id, "revokeResourceQurl");
|
|
2039
2381
|
requireNonEmptyId(qurlId, "revokeResourceQurl");
|
|
2040
|
-
await this.
|
|
2382
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
|
|
2041
2383
|
}
|
|
2042
2384
|
/** Update a specific qURL token on a resource. */
|
|
2043
2385
|
async updateResourceQurl(id, qurlId, input, options) {
|
|
@@ -2073,13 +2415,7 @@ class QURLClient {
|
|
|
2073
2415
|
page_size: meta?.page_size,
|
|
2074
2416
|
};
|
|
2075
2417
|
}
|
|
2076
|
-
/**
|
|
2077
|
-
* Terminate all active sessions for a resource.
|
|
2078
|
-
*
|
|
2079
|
-
* The returned count is best-effort under retries: if the first DELETE
|
|
2080
|
-
* succeeds server-side but the response is lost, a retried request may
|
|
2081
|
-
* return `0` because there are no sessions left to terminate.
|
|
2082
|
-
*/
|
|
2418
|
+
/** Terminate all active sessions for a resource and return the server count. */
|
|
2083
2419
|
async terminateAllResourceSessions(id) {
|
|
2084
2420
|
requireNonEmptyId(id, "terminateAllResourceSessions");
|
|
2085
2421
|
const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
|
|
@@ -2098,7 +2434,7 @@ class QURLClient {
|
|
|
2098
2434
|
async terminateResourceSession(id, sessionId) {
|
|
2099
2435
|
requireNonEmptyId(id, "terminateResourceSession");
|
|
2100
2436
|
requireNonEmptyId(sessionId, "terminateResourceSession");
|
|
2101
|
-
await this.
|
|
2437
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
|
|
2102
2438
|
}
|
|
2103
2439
|
/** List connector installations. */
|
|
2104
2440
|
async listConnectorInstallations(input = {}) {
|
|
@@ -2206,7 +2542,7 @@ class QURLClient {
|
|
|
2206
2542
|
/** Remove a custom domain. */
|
|
2207
2543
|
async deleteDomain(domain) {
|
|
2208
2544
|
requireNonEmptyId(domain, "deleteDomain", "domain");
|
|
2209
|
-
await this.
|
|
2545
|
+
await this.requestNoContent(`/v1/domains/${encodeURIComponent(domain)}`);
|
|
2210
2546
|
}
|
|
2211
2547
|
/** Trigger DNS verification for a custom domain. */
|
|
2212
2548
|
async verifyDomain(domain, options) {
|
|
@@ -2267,7 +2603,7 @@ class QURLClient {
|
|
|
2267
2603
|
/** Delete a webhook. */
|
|
2268
2604
|
async deleteWebhook(id) {
|
|
2269
2605
|
requireNonEmptyId(id, "deleteWebhook");
|
|
2270
|
-
await this.
|
|
2606
|
+
await this.requestNoContent(`/v1/webhooks/${encodeURIComponent(id)}`);
|
|
2271
2607
|
}
|
|
2272
2608
|
/** Regenerate a webhook signing secret. */
|
|
2273
2609
|
async regenerateWebhookSecret(id, options) {
|
|
@@ -2331,7 +2667,7 @@ class QURLClient {
|
|
|
2331
2667
|
/** Revoke an API key. */
|
|
2332
2668
|
async revokeApiKey(keyId) {
|
|
2333
2669
|
requireNonEmptyId(keyId, "revokeApiKey");
|
|
2334
|
-
await this.
|
|
2670
|
+
await this.requestNoContent(`/v1/api-keys/${encodeURIComponent(keyId)}`);
|
|
2335
2671
|
}
|
|
2336
2672
|
/** Redeem an access code. */
|
|
2337
2673
|
async redeemAccessCode(input, options) {
|
|
@@ -2376,7 +2712,7 @@ class QURLClient {
|
|
|
2376
2712
|
/** Revoke an access code. */
|
|
2377
2713
|
async revokeAccessCode(id) {
|
|
2378
2714
|
requireNonEmptyId(id, "revokeAccessCode");
|
|
2379
|
-
await this.
|
|
2715
|
+
await this.requestNoContent(`/v1/access-codes/${encodeURIComponent(id)}`);
|
|
2380
2716
|
}
|
|
2381
2717
|
// --- Internal HTTP plumbing ---
|
|
2382
2718
|
async request(method, path, body, requestOptions) {
|
|
@@ -2390,6 +2726,22 @@ class QURLClient {
|
|
|
2390
2726
|
}
|
|
2391
2727
|
return data;
|
|
2392
2728
|
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Require the service's exact 204 response with no response bytes exposed by
|
|
2731
|
+
* Fetch. A standards-compliant Fetch implementation discards any forbidden
|
|
2732
|
+
* wire body on 204, so the SDK cannot detect bytes that Fetch did not expose.
|
|
2733
|
+
*/
|
|
2734
|
+
async requestNoContent(path) {
|
|
2735
|
+
const response = await this.rawRequest("DELETE", path, undefined, {
|
|
2736
|
+
allowEmptySuccessBody: true,
|
|
2737
|
+
});
|
|
2738
|
+
if (response.__http_status !== 204 || response.__http_body_empty !== true) {
|
|
2739
|
+
const status = response.__http_status ?? 0;
|
|
2740
|
+
const bodyShape = response.__http_body_empty ? "with an empty body" : "with response bytes";
|
|
2741
|
+
throw unexpectedResponseError(`Unexpected response from DELETE; expected empty HTTP 204, received HTTP ${status} ${bodyShape}. ` +
|
|
2742
|
+
"The delete may already have been applied; reconcile resource state before retrying.", { status });
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2393
2745
|
/**
|
|
2394
2746
|
* Issue an HTTP request and parse the JSON response.
|
|
2395
2747
|
*
|
|
@@ -2401,7 +2753,7 @@ class QURLClient {
|
|
|
2401
2753
|
*/
|
|
2402
2754
|
async rawRequest(method, path, body, rawOptions = {}) {
|
|
2403
2755
|
const url = `${this.baseUrl}${path}`;
|
|
2404
|
-
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions } = rawOptions;
|
|
2756
|
+
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
|
|
2405
2757
|
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
|
|
2406
2758
|
const headers = {
|
|
2407
2759
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -2414,12 +2766,20 @@ class QURLClient {
|
|
|
2414
2766
|
if (idempotencyKey !== undefined) {
|
|
2415
2767
|
headers["Idempotency-Key"] = idempotencyKey;
|
|
2416
2768
|
}
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2421
|
-
|
|
2422
|
-
|
|
2769
|
+
// Reads may retry transient statuses and transport failures. POST/PATCH
|
|
2770
|
+
// keep status-code retries limited to rate limits and reuse the same
|
|
2771
|
+
// Idempotency-Key for fetch-level failures. DELETE is deliberately never
|
|
2772
|
+
// replayed, including after 429: this matches qurl-go's no-hidden-HTTP-
|
|
2773
|
+
// retry rule and avoids SDK pacing on an application request path. The
|
|
2774
|
+
// caller receives retryAfter when supplied and must reconcile resource
|
|
2775
|
+
// state before it chooses to issue another delete.
|
|
2776
|
+
const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
|
|
2777
|
+
const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
|
|
2778
|
+
const retryable = method === "GET"
|
|
2779
|
+
? RETRYABLE_STATUS
|
|
2780
|
+
: idempotencyKeyBackedRetry
|
|
2781
|
+
? RETRYABLE_STATUS_MUTATING
|
|
2782
|
+
: NO_RETRYABLE_STATUSES;
|
|
2423
2783
|
const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
2424
2784
|
let lastError;
|
|
2425
2785
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
@@ -2430,6 +2790,7 @@ class QURLClient {
|
|
|
2430
2790
|
}
|
|
2431
2791
|
this.log(`${method} ${url}`);
|
|
2432
2792
|
let response;
|
|
2793
|
+
const requestSignal = AbortSignal.timeout(this.timeout);
|
|
2433
2794
|
try {
|
|
2434
2795
|
// `timeout` is intentionally a per-attempt budget, not a total-
|
|
2435
2796
|
// request budget. With maxRetries=3 and timeout=30s, a slow
|
|
@@ -2443,20 +2804,151 @@ class QURLClient {
|
|
|
2443
2804
|
method,
|
|
2444
2805
|
headers,
|
|
2445
2806
|
body: serializedBody,
|
|
2446
|
-
|
|
2807
|
+
redirect: "manual",
|
|
2808
|
+
signal: requestSignal,
|
|
2447
2809
|
});
|
|
2448
2810
|
}
|
|
2449
2811
|
catch (err) {
|
|
2450
|
-
lastError = this.classifyFetchError(err);
|
|
2812
|
+
lastError = this.classifyFetchError(err, requestSignal);
|
|
2451
2813
|
this.log(`${method} ${url} ${lastError instanceof errors_js_1.TimeoutError ? "timed out" : "network error"}`, {
|
|
2452
|
-
error: lastError.message,
|
|
2814
|
+
error: boundedErrorSnippet(lastError.message),
|
|
2453
2815
|
});
|
|
2454
|
-
|
|
2816
|
+
// An injected fetch can abort or impose a deadline for its own reason.
|
|
2817
|
+
// That is not this SDK's timeout. Replaying it can defeat caller-side
|
|
2818
|
+
// cancellation or deadline policy.
|
|
2819
|
+
if (retryFetchFailure &&
|
|
2820
|
+
!isIndependentAbort(err, requestSignal) &&
|
|
2821
|
+
attempt < this.maxRetries) {
|
|
2455
2822
|
continue;
|
|
2456
2823
|
}
|
|
2457
2824
|
throw lastError;
|
|
2458
2825
|
}
|
|
2459
2826
|
this.log(`${method} ${url} → ${response.status}`);
|
|
2827
|
+
// Redirects are deterministic protocol violations for SDK API calls.
|
|
2828
|
+
// Refuse them before reading Location or entering retry handling so
|
|
2829
|
+
// Authorization and Idempotency-Key can never reach a follow-up target.
|
|
2830
|
+
const responseUrlStateValue = responseUrlState(response.url, url);
|
|
2831
|
+
if (response.redirected === true ||
|
|
2832
|
+
responseUrlStateValue !== "same" ||
|
|
2833
|
+
REDIRECT_RESPONSE_STATUSES.has(response.status) ||
|
|
2834
|
+
response.type === "opaqueredirect") {
|
|
2835
|
+
await cancelResponseBody(response.body);
|
|
2836
|
+
const redirectKind = response.redirected === true
|
|
2837
|
+
? "followed"
|
|
2838
|
+
: responseUrlStateValue === "invalid"
|
|
2839
|
+
? "invalid custom Response.url"
|
|
2840
|
+
: responseUrlStateValue === "changed"
|
|
2841
|
+
? "changed Response.url"
|
|
2842
|
+
: REDIRECT_RESPONSE_STATUSES.has(response.status)
|
|
2843
|
+
? `HTTP ${response.status}`
|
|
2844
|
+
: "opaque";
|
|
2845
|
+
throw unexpectedResponseError(`Refused ${redirectKind} redirect response for ${method}`, {
|
|
2846
|
+
status: response.status,
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
let responseBody;
|
|
2850
|
+
try {
|
|
2851
|
+
responseBody = await readBoundedResponseBody(response);
|
|
2852
|
+
}
|
|
2853
|
+
catch (err) {
|
|
2854
|
+
if (err instanceof ResponseBodyTooLargeError) {
|
|
2855
|
+
this.log(`rejected oversized response body from ${response.status}`, {
|
|
2856
|
+
status: response.status,
|
|
2857
|
+
});
|
|
2858
|
+
const detail = `Response body exceeds ${MAX_RESPONSE_BODY_BYTES}-byte limit on HTTP ${response.status}; ` +
|
|
2859
|
+
"request a smaller page when listing collections";
|
|
2860
|
+
if (response.ok) {
|
|
2861
|
+
throw unexpectedResponseError(detail, { status: response.status });
|
|
2862
|
+
}
|
|
2863
|
+
const bodyError = (0, errors_js_1.createError)({
|
|
2864
|
+
status: response.status,
|
|
2865
|
+
// The unread body cannot supply a trustworthy server code. Keep
|
|
2866
|
+
// the real HTTP status class (429/5xx) just like other unreadable
|
|
2867
|
+
// error bodies instead of reclassifying it as client validation.
|
|
2868
|
+
code: errors_js_1.ERROR_CODE_UNKNOWN,
|
|
2869
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2870
|
+
detail,
|
|
2871
|
+
retry_after: this.parseRetryAfter(response),
|
|
2872
|
+
});
|
|
2873
|
+
// The unread body does not change operation semantics: retry a GET,
|
|
2874
|
+
// or an idempotency-key-backed mutation on an explicitly retryable
|
|
2875
|
+
// status. Successful oversized responses remain deterministic
|
|
2876
|
+
// contract failures, and DELETE has no retryable statuses.
|
|
2877
|
+
if (retryable.has(response.status) && attempt < this.maxRetries) {
|
|
2878
|
+
lastError = bodyError;
|
|
2879
|
+
continue;
|
|
2880
|
+
}
|
|
2881
|
+
throw bodyError;
|
|
2882
|
+
}
|
|
2883
|
+
if (err instanceof InvalidResponseEncodingError) {
|
|
2884
|
+
this.log(`rejected malformed UTF-8 response body from ${response.status}`, {
|
|
2885
|
+
status: response.status,
|
|
2886
|
+
});
|
|
2887
|
+
const detail = `Response body is not valid UTF-8 on HTTP ${response.status}`;
|
|
2888
|
+
if (response.ok) {
|
|
2889
|
+
// A successful status with an invalid wire body is a deterministic
|
|
2890
|
+
// response-contract failure. Do not replay a GET or a mutation.
|
|
2891
|
+
throw unexpectedResponseError(detail, { status: response.status });
|
|
2892
|
+
}
|
|
2893
|
+
const encodingError = (0, errors_js_1.createError)({
|
|
2894
|
+
status: response.status,
|
|
2895
|
+
code: errors_js_1.ERROR_CODE_UNKNOWN,
|
|
2896
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2897
|
+
detail,
|
|
2898
|
+
retry_after: this.parseRetryAfter(response),
|
|
2899
|
+
});
|
|
2900
|
+
// For an explicit error status, the HTTP classification remains
|
|
2901
|
+
// authoritative even when its body encoding is invalid.
|
|
2902
|
+
if (retryable.has(response.status) && attempt < this.maxRetries) {
|
|
2903
|
+
lastError = encodingError;
|
|
2904
|
+
continue;
|
|
2905
|
+
}
|
|
2906
|
+
throw encodingError;
|
|
2907
|
+
}
|
|
2908
|
+
await cancelResponseBody(response.body);
|
|
2909
|
+
const transportReadError = this.classifyResponseReadError(err, response, requestSignal);
|
|
2910
|
+
if (transportReadError !== undefined) {
|
|
2911
|
+
this.log(`transport failure while reading response body from ${response.status}`, {
|
|
2912
|
+
status: response.status,
|
|
2913
|
+
error: transportReadError.code,
|
|
2914
|
+
});
|
|
2915
|
+
// A successful GET can be replayed when its success body is lost. A
|
|
2916
|
+
// non-success GET retries only the status set that the normal error
|
|
2917
|
+
// path retries. Mutations are never replayed after a body transport
|
|
2918
|
+
// failure because the operation may have applied.
|
|
2919
|
+
if (method === "GET" &&
|
|
2920
|
+
!isIndependentAbort(err, requestSignal) &&
|
|
2921
|
+
(response.ok || retryable.has(response.status)) &&
|
|
2922
|
+
attempt < this.maxRetries) {
|
|
2923
|
+
lastError = transportReadError;
|
|
2924
|
+
continue;
|
|
2925
|
+
}
|
|
2926
|
+
throw transportReadError;
|
|
2927
|
+
}
|
|
2928
|
+
this.log(`failed to read response body from ${response.status}`, {
|
|
2929
|
+
status: response.status,
|
|
2930
|
+
});
|
|
2931
|
+
// ResponseBodyMaterializationError messages are fixed SDK-authored
|
|
2932
|
+
// contract text. The underlying shim rejection is never interpolated.
|
|
2933
|
+
const detail = err instanceof ResponseBodyMaterializationError
|
|
2934
|
+
? err.message
|
|
2935
|
+
: `Failed to read response body on HTTP ${response.status}`;
|
|
2936
|
+
const readError = response.ok
|
|
2937
|
+
? unexpectedResponseError(detail, { status: response.status })
|
|
2938
|
+
: (0, errors_js_1.createError)({
|
|
2939
|
+
status: response.status,
|
|
2940
|
+
code: errors_js_1.ERROR_CODE_UNKNOWN,
|
|
2941
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2942
|
+
detail,
|
|
2943
|
+
retry_after: this.parseRetryAfter(response),
|
|
2944
|
+
});
|
|
2945
|
+
// This arm contains deterministic Response-like shim failures (for
|
|
2946
|
+
// example a BigInt returned by json()) and non-Error throws. Do not
|
|
2947
|
+
// replay them: another attempt will see the same local contract bug.
|
|
2948
|
+
throw err instanceof ResponseBodyMaterializationError
|
|
2949
|
+
? withErrorCause(readError, err)
|
|
2950
|
+
: readError;
|
|
2951
|
+
}
|
|
2460
2952
|
// `response.ok` is true for the entire 200-299 range, so partial-
|
|
2461
2953
|
// success responses like 207 flow through this path naturally —
|
|
2462
2954
|
// `passthroughStatuses` only needs to enumerate non-2xx statuses
|
|
@@ -2479,33 +2971,56 @@ class QURLClient {
|
|
|
2479
2971
|
url,
|
|
2480
2972
|
});
|
|
2481
2973
|
}
|
|
2482
|
-
return {
|
|
2974
|
+
return {
|
|
2975
|
+
data: undefined,
|
|
2976
|
+
__http_status: response.status,
|
|
2977
|
+
__http_body_empty: responseBody.length === 0,
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
// Exact no-content helpers must inspect both axes of the success
|
|
2981
|
+
// contract. Let only those callers observe an empty non-204 response;
|
|
2982
|
+
// body-returning methods still reject it as non-JSON below.
|
|
2983
|
+
if (response.ok && allowEmptySuccessBody && responseBody.length === 0) {
|
|
2984
|
+
return {
|
|
2985
|
+
data: undefined,
|
|
2986
|
+
__http_status: response.status,
|
|
2987
|
+
__http_body_empty: true,
|
|
2988
|
+
};
|
|
2483
2989
|
}
|
|
2484
2990
|
try {
|
|
2485
|
-
const json =
|
|
2486
|
-
return {
|
|
2991
|
+
const json = JSON.parse(responseBody);
|
|
2992
|
+
return {
|
|
2993
|
+
...json,
|
|
2994
|
+
__http_status: response.status,
|
|
2995
|
+
// JSON.parse("") throws, so parsed JSON is necessarily non-empty.
|
|
2996
|
+
__http_body_empty: false,
|
|
2997
|
+
};
|
|
2487
2998
|
}
|
|
2488
2999
|
catch {
|
|
2489
3000
|
// Non-JSON body on a 2xx response (server contract violation)
|
|
2490
3001
|
// or on a passthrough status (e.g. proxy HTML on 400). The
|
|
2491
|
-
// body
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
// miss it.
|
|
3002
|
+
// body was read through the cap before JSON decoding.
|
|
3003
|
+
// Synthesize a typed QURLError directly so consumers catching
|
|
3004
|
+
// by-class don't miss the contract failure.
|
|
2495
3005
|
this.log(`non-JSON body on ${isPassthrough ? "passthrough" : "success"} response ${response.status}`, {
|
|
2496
3006
|
status: response.status,
|
|
2497
|
-
content_type: response.headers.get("content-type")
|
|
3007
|
+
content_type: boundedErrorSnippet(response.headers.get("content-type")),
|
|
2498
3008
|
});
|
|
2499
3009
|
throw (0, errors_js_1.createError)({
|
|
2500
3010
|
status: response.status,
|
|
2501
3011
|
code: errors_js_1.ERROR_CODE_UNEXPECTED_RESPONSE,
|
|
2502
|
-
title: response.statusText || `HTTP ${response.status}`,
|
|
3012
|
+
title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
|
|
2503
3013
|
detail: `Expected JSON response body on HTTP ${response.status} but received non-JSON content`,
|
|
2504
3014
|
});
|
|
2505
3015
|
}
|
|
2506
3016
|
}
|
|
2507
|
-
const errorData =
|
|
3017
|
+
const errorData = this.parseError(response, responseBody);
|
|
2508
3018
|
const err = (0, errors_js_1.createError)(errorData);
|
|
3019
|
+
// Retryability is determined by status even when an intermediary
|
|
3020
|
+
// returned HTML, an empty body, or another non-envelope error. Those
|
|
3021
|
+
// shapes are common for transient 429/502/503/504 responses and do not
|
|
3022
|
+
// prove that a retry cannot succeed. Redirects and oversized bodies
|
|
3023
|
+
// already throw before reaching this check.
|
|
2509
3024
|
if (retryable.has(response.status) && attempt < this.maxRetries) {
|
|
2510
3025
|
lastError = err;
|
|
2511
3026
|
continue;
|
|
@@ -2514,9 +3029,9 @@ class QURLClient {
|
|
|
2514
3029
|
}
|
|
2515
3030
|
throw lastError ?? new Error("Request failed after retries");
|
|
2516
3031
|
}
|
|
2517
|
-
|
|
3032
|
+
parseError(response, responseBody) {
|
|
2518
3033
|
try {
|
|
2519
|
-
const json =
|
|
3034
|
+
const json = JSON.parse(responseBody);
|
|
2520
3035
|
if (json.error) {
|
|
2521
3036
|
const err = json.error;
|
|
2522
3037
|
// Detail fallback chain:
|
|
@@ -2525,18 +3040,27 @@ class QURLClient {
|
|
|
2525
3040
|
// 3. err.title (RFC 7807 required field)
|
|
2526
3041
|
// 4. HTTP status (final safety net)
|
|
2527
3042
|
// This prevents `"Title (403): undefined"` when the API omits detail.
|
|
2528
|
-
const detail = err.detail ??
|
|
3043
|
+
const detail = boundedErrorSnippet(err.detail) ??
|
|
3044
|
+
boundedErrorSnippet(err.message) ??
|
|
3045
|
+
boundedErrorSnippet(err.title) ??
|
|
3046
|
+
`HTTP ${response.status}`;
|
|
2529
3047
|
// HTTP/2 omits reason-phrases — `statusText` may be "".
|
|
2530
|
-
const title = err.title ??
|
|
3048
|
+
const title = boundedErrorSnippet(err.title) ??
|
|
3049
|
+
boundedErrorSnippet(response.statusText) ??
|
|
3050
|
+
`HTTP ${response.status}`;
|
|
3051
|
+
// The transport status is authoritative for error classification. A
|
|
3052
|
+
// conflicting or malformed RFC 7807 body must not turn a real 5xx into
|
|
3053
|
+
// a NotFoundError (or vice versa) in caller reconciliation logic.
|
|
3054
|
+
const status = response.status;
|
|
2531
3055
|
return {
|
|
2532
|
-
status
|
|
2533
|
-
code: err.code ?? errors_js_1.ERROR_CODE_UNKNOWN,
|
|
3056
|
+
status,
|
|
3057
|
+
code: boundedErrorIdentifier(err.code) ?? errors_js_1.ERROR_CODE_UNKNOWN,
|
|
2534
3058
|
title,
|
|
2535
3059
|
detail,
|
|
2536
|
-
type: err.type,
|
|
2537
|
-
instance: err.instance,
|
|
2538
|
-
invalid_fields: err.invalid_fields,
|
|
2539
|
-
request_id: json.meta?.request_id,
|
|
3060
|
+
type: boundedErrorIdentifier(err.type),
|
|
3061
|
+
instance: boundedErrorIdentifier(err.instance),
|
|
3062
|
+
invalid_fields: boundedInvalidFields(err.invalid_fields),
|
|
3063
|
+
request_id: boundedErrorIdentifier(json.meta?.request_id),
|
|
2540
3064
|
retry_after: this.parseRetryAfter(response),
|
|
2541
3065
|
};
|
|
2542
3066
|
}
|
|
@@ -2546,31 +3070,37 @@ class QURLClient {
|
|
|
2546
3070
|
// status-only safety net below.
|
|
2547
3071
|
this.log(`unexpected error response shape from ${response.status}`, {
|
|
2548
3072
|
status: response.status,
|
|
2549
|
-
body_keys:
|
|
3073
|
+
body_keys: typeof json === "object" && json !== null ? boundedObjectKeys(json) : undefined,
|
|
2550
3074
|
});
|
|
2551
3075
|
}
|
|
2552
3076
|
catch {
|
|
2553
|
-
// Body wasn't valid JSON
|
|
2554
|
-
//
|
|
2555
|
-
// envelope, and fall through to the status-only safety net.
|
|
3077
|
+
// Body wasn't valid JSON. Log so operators can distinguish this from a
|
|
3078
|
+
// malformed envelope, and fall through to the status-only safety net.
|
|
2556
3079
|
this.log(`non-JSON error response from ${response.status}`, {
|
|
2557
3080
|
status: response.status,
|
|
2558
|
-
content_type: response.headers.get("content-type")
|
|
3081
|
+
content_type: boundedErrorSnippet(response.headers.get("content-type")),
|
|
2559
3082
|
});
|
|
2560
3083
|
}
|
|
3084
|
+
const statusText = boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`;
|
|
2561
3085
|
return {
|
|
2562
3086
|
status: response.status,
|
|
2563
3087
|
code: errors_js_1.ERROR_CODE_UNKNOWN,
|
|
2564
|
-
title:
|
|
2565
|
-
detail:
|
|
3088
|
+
title: statusText,
|
|
3089
|
+
detail: statusText,
|
|
3090
|
+
retry_after: this.parseRetryAfter(response),
|
|
2566
3091
|
};
|
|
2567
3092
|
}
|
|
2568
3093
|
parseRetryAfter(response) {
|
|
2569
|
-
// RFC 7231 §7.1.3: Retry-After honored on 429 + 503.
|
|
2570
|
-
// also
|
|
2571
|
-
//
|
|
3094
|
+
// RFC 7231 §7.1.3: Retry-After honored on 429 + 503. Although the RFC
|
|
3095
|
+
// also permits it on 3xx responses, rawRequest now refuses redirects
|
|
3096
|
+
// as deterministic errors before retry parsing, so only 429/503 apply.
|
|
2572
3097
|
if (response.status !== 429 && response.status !== 503)
|
|
2573
3098
|
return undefined;
|
|
3099
|
+
// This fallback can run after the body reader rejects a malformed injected
|
|
3100
|
+
// Response-like shim. Preserve the typed, non-retryable SDK error instead
|
|
3101
|
+
// of dereferencing the same missing method and leaking a raw TypeError.
|
|
3102
|
+
if (typeof response.headers?.get !== "function")
|
|
3103
|
+
return undefined;
|
|
2574
3104
|
const header = response.headers.get("Retry-After");
|
|
2575
3105
|
if (!header)
|
|
2576
3106
|
return undefined;
|
|
@@ -2632,13 +3162,36 @@ class QURLClient {
|
|
|
2632
3162
|
const jitter = Math.random() * base * 0.5;
|
|
2633
3163
|
return Math.min(base + jitter, RETRY_MAX_DELAY_MS);
|
|
2634
3164
|
}
|
|
2635
|
-
classifyFetchError(err) {
|
|
2636
|
-
|
|
3165
|
+
classifyFetchError(err, requestSignal) {
|
|
3166
|
+
// AbortSignal.timeout is the only timeout source owned by this SDK. Keep
|
|
3167
|
+
// this check tied to that signal instead of trusting an injected fetch's
|
|
3168
|
+
// AbortError or TimeoutError name.
|
|
3169
|
+
if (isSdkTimeout(err, requestSignal)) {
|
|
2637
3170
|
return new errors_js_1.TimeoutError("Request timed out", { cause: err });
|
|
2638
3171
|
}
|
|
2639
3172
|
const cause = err instanceof Error ? err : undefined;
|
|
2640
3173
|
return new errors_js_1.NetworkError(cause?.message ?? String(err), { cause });
|
|
2641
3174
|
}
|
|
3175
|
+
classifyResponseReadError(err, response, requestSignal) {
|
|
3176
|
+
if (err instanceof ResponseBodyMaterializationError)
|
|
3177
|
+
return undefined;
|
|
3178
|
+
if (!(err instanceof Error))
|
|
3179
|
+
return undefined;
|
|
3180
|
+
if (!response.ok) {
|
|
3181
|
+
const statusText = boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`;
|
|
3182
|
+
return withErrorCause((0, errors_js_1.createError)({
|
|
3183
|
+
status: response.status,
|
|
3184
|
+
code: errors_js_1.ERROR_CODE_UNKNOWN,
|
|
3185
|
+
title: statusText,
|
|
3186
|
+
detail: `Failed to read response body after HTTP ${response.status}`,
|
|
3187
|
+
retry_after: this.parseRetryAfter(response),
|
|
3188
|
+
}), err);
|
|
3189
|
+
}
|
|
3190
|
+
if (isSdkTimeout(err, requestSignal)) {
|
|
3191
|
+
return new errors_js_1.TimeoutError(`Request timed out while reading response body after HTTP ${response.status}`, { cause: err });
|
|
3192
|
+
}
|
|
3193
|
+
return new errors_js_1.NetworkError(`Network failure while reading response body after HTTP ${response.status}`, { cause: err });
|
|
3194
|
+
}
|
|
2642
3195
|
}
|
|
2643
3196
|
exports.QURLClient = QURLClient;
|
|
2644
3197
|
/**
|