nxus-qbd 0.8.0 → 1.0.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 +269 -91
- package/dist/client.d.ts +81 -77
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +77 -71
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/generated/count-capabilities.d.ts +64 -0
- package/dist/generated/count-capabilities.d.ts.map +1 -0
- package/dist/generated/count-capabilities.js +64 -0
- package/dist/generated/count-capabilities.js.map +1 -0
- package/dist/generated/index.d.ts +1 -1
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/resource-capabilities.d.ts +506 -0
- package/dist/generated/resource-capabilities.d.ts.map +1 -0
- package/dist/generated/resource-capabilities.js +506 -0
- package/dist/generated/resource-capabilities.js.map +1 -0
- package/dist/generated/types.gen.d.ts +29410 -20818
- package/dist/generated/types.gen.d.ts.map +1 -1
- package/dist/generated/types.gen.js +80 -0
- package/dist/generated/types.gen.js.map +1 -1
- package/dist/helpers/enums.d.ts +29 -0
- package/dist/helpers/enums.d.ts.map +1 -0
- package/dist/helpers/enums.js +38 -0
- package/dist/helpers/enums.js.map +1 -0
- package/dist/helpers/errors.d.ts +14 -3
- package/dist/helpers/errors.d.ts.map +1 -1
- package/dist/helpers/errors.js +274 -23
- package/dist/helpers/errors.js.map +1 -1
- package/dist/helpers/index.d.ts +4 -2
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +4 -2
- package/dist/helpers/index.js.map +1 -1
- package/dist/helpers/pagination.d.ts +11 -1
- package/dist/helpers/pagination.d.ts.map +1 -1
- package/dist/helpers/pagination.js.map +1 -1
- package/dist/helpers/response.d.ts +93 -0
- package/dist/helpers/response.d.ts.map +1 -0
- package/dist/helpers/response.js +84 -0
- package/dist/helpers/response.js.map +1 -0
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/dist/models/core/auth_session.d.ts +1 -1
- package/dist/models/core/auth_session.d.ts.map +1 -1
- package/dist/models/core/connections.d.ts +1 -1
- package/dist/models/core/connections.d.ts.map +1 -1
- package/dist/models/core/index.d.ts +3 -3
- package/dist/models/core/index.d.ts.map +1 -1
- package/dist/models/core/index.js +3 -3
- package/dist/models/core/index.js.map +1 -1
- package/dist/models/core/qwc_auth_setup.d.ts +1 -1
- package/dist/models/core/qwc_auth_setup.d.ts.map +1 -1
- package/dist/models/index.d.ts +6 -6
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +5 -5
- package/dist/models/index.js.map +1 -1
- package/dist/models/qbd/account.d.ts +2 -2
- package/dist/models/qbd/account.d.ts.map +1 -1
- package/dist/models/qbd/account.js +1 -1
- package/dist/models/qbd/account.js.map +1 -1
- package/dist/models/qbd/account_tax_line_info.d.ts +1 -1
- package/dist/models/qbd/account_tax_line_info.d.ts.map +1 -1
- package/dist/models/qbd/ar_refund_credit_card.d.ts +1 -1
- package/dist/models/qbd/ar_refund_credit_card.d.ts.map +1 -1
- package/dist/models/qbd/bar_code.d.ts +1 -1
- package/dist/models/qbd/bar_code.d.ts.map +1 -1
- package/dist/models/qbd/bill.d.ts +1 -1
- package/dist/models/qbd/bill.d.ts.map +1 -1
- package/dist/models/qbd/bill_payment_or_credit.d.ts +1 -1
- package/dist/models/qbd/bill_payment_or_credit.d.ts.map +1 -1
- package/dist/models/qbd/billing_rate.d.ts +1 -1
- package/dist/models/qbd/billing_rate.d.ts.map +1 -1
- package/dist/models/qbd/build_assembly.d.ts +1 -1
- package/dist/models/qbd/build_assembly.d.ts.map +1 -1
- package/dist/models/qbd/charge.d.ts +1 -1
- package/dist/models/qbd/charge.d.ts.map +1 -1
- package/dist/models/qbd/check.d.ts +1 -1
- package/dist/models/qbd/check.d.ts.map +1 -1
- package/dist/models/qbd/check_bill_payment.d.ts +1 -1
- package/dist/models/qbd/check_bill_payment.d.ts.map +1 -1
- package/dist/models/qbd/class_.d.ts +1 -1
- package/dist/models/qbd/class_.d.ts.map +1 -1
- package/dist/models/qbd/credit_card_bill_payment.d.ts +1 -1
- package/dist/models/qbd/credit_card_bill_payment.d.ts.map +1 -1
- package/dist/models/qbd/credit_card_charge.d.ts +1 -1
- package/dist/models/qbd/credit_card_charge.d.ts.map +1 -1
- package/dist/models/qbd/credit_card_credit.d.ts +1 -1
- package/dist/models/qbd/credit_card_credit.d.ts.map +1 -1
- package/dist/models/qbd/credit_memo.d.ts +1 -1
- package/dist/models/qbd/credit_memo.d.ts.map +1 -1
- package/dist/models/qbd/currency.d.ts +2 -1
- package/dist/models/qbd/currency.d.ts.map +1 -1
- package/dist/models/qbd/currency.js +2 -1
- package/dist/models/qbd/currency.js.map +1 -1
- package/dist/models/qbd/custom_field_definitions.d.ts +1 -1
- package/dist/models/qbd/custom_field_definitions.d.ts.map +1 -1
- package/dist/models/qbd/custom_fields.d.ts +2 -2
- package/dist/models/qbd/custom_fields.d.ts.map +1 -1
- package/dist/models/qbd/custom_fields.js +1 -1
- package/dist/models/qbd/custom_fields.js.map +1 -1
- package/dist/models/qbd/customer.d.ts +2 -2
- package/dist/models/qbd/customer.d.ts.map +1 -1
- package/dist/models/qbd/customer.js +1 -1
- package/dist/models/qbd/customer.js.map +1 -1
- package/dist/models/qbd/customer_type.d.ts +1 -1
- package/dist/models/qbd/customer_type.d.ts.map +1 -1
- package/dist/models/qbd/date_driven_term.d.ts +1 -1
- package/dist/models/qbd/date_driven_term.d.ts.map +1 -1
- package/dist/models/qbd/deposit.d.ts +1 -1
- package/dist/models/qbd/deposit.d.ts.map +1 -1
- package/dist/models/qbd/employee.d.ts +2 -2
- package/dist/models/qbd/employee.d.ts.map +1 -1
- package/dist/models/qbd/employee.js +1 -1
- package/dist/models/qbd/employee.js.map +1 -1
- package/dist/models/qbd/estimate.d.ts +1 -1
- package/dist/models/qbd/estimate.d.ts.map +1 -1
- package/dist/models/qbd/index.d.ts +66 -66
- package/dist/models/qbd/index.d.ts.map +1 -1
- package/dist/models/qbd/index.js +66 -66
- package/dist/models/qbd/index.js.map +1 -1
- package/dist/models/qbd/inventory_adjustment.d.ts +1 -1
- package/dist/models/qbd/inventory_adjustment.d.ts.map +1 -1
- package/dist/models/qbd/inventory_site.d.ts +1 -1
- package/dist/models/qbd/inventory_site.d.ts.map +1 -1
- package/dist/models/qbd/invoice.d.ts +2 -1
- package/dist/models/qbd/invoice.d.ts.map +1 -1
- package/dist/models/qbd/invoice.js +2 -1
- package/dist/models/qbd/invoice.js.map +1 -1
- package/dist/models/qbd/item.d.ts +2 -2
- package/dist/models/qbd/item.d.ts.map +1 -1
- package/dist/models/qbd/item.js +1 -1
- package/dist/models/qbd/item.js.map +1 -1
- package/dist/models/qbd/item_discount.d.ts +1 -1
- package/dist/models/qbd/item_discount.d.ts.map +1 -1
- package/dist/models/qbd/item_fixed_asset.d.ts +1 -1
- package/dist/models/qbd/item_fixed_asset.d.ts.map +1 -1
- package/dist/models/qbd/item_group.d.ts +1 -1
- package/dist/models/qbd/item_group.d.ts.map +1 -1
- package/dist/models/qbd/item_inventory.d.ts +1 -1
- package/dist/models/qbd/item_inventory.d.ts.map +1 -1
- package/dist/models/qbd/item_inventory_assembly.d.ts +1 -1
- package/dist/models/qbd/item_inventory_assembly.d.ts.map +1 -1
- package/dist/models/qbd/item_non_inventory.d.ts +1 -1
- package/dist/models/qbd/item_non_inventory.d.ts.map +1 -1
- package/dist/models/qbd/item_other_charge.d.ts +1 -1
- package/dist/models/qbd/item_other_charge.d.ts.map +1 -1
- package/dist/models/qbd/item_payment.d.ts +1 -1
- package/dist/models/qbd/item_payment.d.ts.map +1 -1
- package/dist/models/qbd/item_receipt.d.ts +1 -1
- package/dist/models/qbd/item_receipt.d.ts.map +1 -1
- package/dist/models/qbd/item_sales_tax.d.ts +1 -1
- package/dist/models/qbd/item_sales_tax.d.ts.map +1 -1
- package/dist/models/qbd/item_sales_tax_group.d.ts +1 -1
- package/dist/models/qbd/item_sales_tax_group.d.ts.map +1 -1
- package/dist/models/qbd/item_service.d.ts +1 -1
- package/dist/models/qbd/item_service.d.ts.map +1 -1
- package/dist/models/qbd/item_subtotal.d.ts +1 -1
- package/dist/models/qbd/item_subtotal.d.ts.map +1 -1
- package/dist/models/qbd/journal_entry.d.ts +1 -1
- package/dist/models/qbd/journal_entry.d.ts.map +1 -1
- package/dist/models/qbd/other_name.d.ts +1 -1
- package/dist/models/qbd/other_name.d.ts.map +1 -1
- package/dist/models/qbd/payment_method.d.ts +1 -1
- package/dist/models/qbd/payment_method.d.ts.map +1 -1
- package/dist/models/qbd/payroll_item_non_wage.d.ts +1 -1
- package/dist/models/qbd/payroll_item_non_wage.d.ts.map +1 -1
- package/dist/models/qbd/payroll_item_wage.d.ts +1 -1
- package/dist/models/qbd/payroll_item_wage.d.ts.map +1 -1
- package/dist/models/qbd/price_level.d.ts +1 -1
- package/dist/models/qbd/price_level.d.ts.map +1 -1
- package/dist/models/qbd/purchase_order.d.ts +1 -1
- package/dist/models/qbd/purchase_order.d.ts.map +1 -1
- package/dist/models/qbd/receive_payment.d.ts +1 -1
- package/dist/models/qbd/receive_payment.d.ts.map +1 -1
- package/dist/models/qbd/report.d.ts +1 -1
- package/dist/models/qbd/report.d.ts.map +1 -1
- package/dist/models/qbd/sales_order.d.ts +1 -1
- package/dist/models/qbd/sales_order.d.ts.map +1 -1
- package/dist/models/qbd/sales_receipt.d.ts +1 -1
- package/dist/models/qbd/sales_receipt.d.ts.map +1 -1
- package/dist/models/qbd/sales_tax_code.d.ts +1 -1
- package/dist/models/qbd/sales_tax_code.d.ts.map +1 -1
- package/dist/models/qbd/sales_tax_payment_check.d.ts +1 -1
- package/dist/models/qbd/sales_tax_payment_check.d.ts.map +1 -1
- package/dist/models/qbd/ship_method.d.ts +1 -1
- package/dist/models/qbd/ship_method.d.ts.map +1 -1
- package/dist/models/qbd/special_item.d.ts +1 -1
- package/dist/models/qbd/special_item.d.ts.map +1 -1
- package/dist/models/qbd/tenant_me.d.ts +1 -1
- package/dist/models/qbd/tenant_me.d.ts.map +1 -1
- package/dist/models/qbd/term.d.ts +1 -1
- package/dist/models/qbd/term.d.ts.map +1 -1
- package/dist/models/qbd/time_tracking_activity.d.ts +1 -1
- package/dist/models/qbd/time_tracking_activity.d.ts.map +1 -1
- package/dist/models/qbd/transaction.d.ts +1 -1
- package/dist/models/qbd/transaction.d.ts.map +1 -1
- package/dist/models/qbd/unit_of_measure_set.d.ts +1 -1
- package/dist/models/qbd/unit_of_measure_set.d.ts.map +1 -1
- package/dist/models/qbd/vendor.d.ts +1 -1
- package/dist/models/qbd/vendor.d.ts.map +1 -1
- package/dist/models/qbd/vendor_credit.d.ts +1 -1
- package/dist/models/qbd/vendor_credit.d.ts.map +1 -1
- package/dist/models/qbd/vendor_type.d.ts +1 -1
- package/dist/models/qbd/vendor_type.d.ts.map +1 -1
- package/dist/models/qbd/workers_comp_code.d.ts +1 -1
- package/dist/models/qbd/workers_comp_code.d.ts.map +1 -1
- package/dist/resources/base.d.ts +159 -29
- package/dist/resources/base.d.ts.map +1 -1
- package/dist/resources/base.js +417 -151
- package/dist/resources/base.js.map +1 -1
- package/dist/resources/connections.d.ts +17 -4
- package/dist/resources/connections.d.ts.map +1 -1
- package/dist/resources/connections.js +39 -13
- package/dist/resources/connections.js.map +1 -1
- package/dist/resources/custom-fields.d.ts +35 -32
- package/dist/resources/custom-fields.d.ts.map +1 -1
- package/dist/resources/custom-fields.js +121 -119
- package/dist/resources/custom-fields.js.map +1 -1
- package/dist/resources/reports.d.ts +23 -10
- package/dist/resources/reports.d.ts.map +1 -1
- package/dist/resources/reports.js +40 -29
- package/dist/resources/reports.js.map +1 -1
- package/dist/transport.d.ts +61 -10
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +522 -113
- package/dist/transport.js.map +1 -1
- package/package.json +8 -4
package/dist/transport.js
CHANGED
|
@@ -4,11 +4,25 @@
|
|
|
4
4
|
* Handles authentication, base URL resolution, JSON serialization,
|
|
5
5
|
* and error mapping for all SDK requests.
|
|
6
6
|
*/
|
|
7
|
-
import { NxusApiError } from
|
|
7
|
+
import { NxusApiError } from "./helpers/errors.js";
|
|
8
8
|
export const DEFAULT_TIMEOUT_MS = 100_000;
|
|
9
9
|
export const DEFAULT_MAX_RETRIES = 2;
|
|
10
10
|
const RETRY_BASE_DELAY_MS = 500;
|
|
11
11
|
const RETRY_MAX_DELAY_MS = 8_000;
|
|
12
|
+
/**
|
|
13
|
+
* Ceiling on how long a server-supplied `Retry-After` will be honoured.
|
|
14
|
+
*
|
|
15
|
+
* Separate from RETRY_MAX_DELAY_MS, which bounds our own exponential backoff.
|
|
16
|
+
* Clamping the server's value to the backoff cap was actively harmful: on
|
|
17
|
+
* `Retry-After: 60` the SDK slept 8s, retried into a window still in force,
|
|
18
|
+
* and repeated until the budget was gone — every attempt guaranteed to fail.
|
|
19
|
+
*
|
|
20
|
+
* Beyond this ceiling the SDK does not sleep and does not spend an attempt; it
|
|
21
|
+
* returns the error with `retryAfter` intact so the caller can schedule the
|
|
22
|
+
* wait itself. Blocking a process for an unbounded server value is not the
|
|
23
|
+
* SDK's decision to make.
|
|
24
|
+
*/
|
|
25
|
+
const RETRY_AFTER_MAX_MS = 60_000;
|
|
12
26
|
// 409 is intentionally omitted: the backend overloads it for both retryable
|
|
13
27
|
// lock contention (`ObjectInUse`, `LockFailed`) and terminal business-rule
|
|
14
28
|
// violations (`OutdatedEditSequence`, `NameNotUnique`, `TimeCreationMismatch`).
|
|
@@ -16,13 +30,13 @@ const RETRY_MAX_DELAY_MS = 8_000;
|
|
|
16
30
|
// attempts on errors that need client-side action. Servers that emit the
|
|
17
31
|
// header (`x-should-retry: true`) override this fallback and opt 409s in.
|
|
18
32
|
const RETRYABLE_STATUSES = new Set([408, 429]);
|
|
19
|
-
const REDACTED =
|
|
33
|
+
const REDACTED = "[REDACTED]";
|
|
20
34
|
const SENSITIVE_HEADER_NAMES = new Set([
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
"authorization",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
"x-api-key",
|
|
38
|
+
"cookie",
|
|
39
|
+
"set-cookie",
|
|
26
40
|
]);
|
|
27
41
|
function redactHeaders(headers) {
|
|
28
42
|
const out = {};
|
|
@@ -34,12 +48,43 @@ function redactHeaders(headers) {
|
|
|
34
48
|
function defaultLogger() {
|
|
35
49
|
// Bind to console so callers can swap the logger without losing context.
|
|
36
50
|
return {
|
|
37
|
-
debug: (m, c) => console.debug(`[nxus-qbd] ${m}`, c ??
|
|
38
|
-
info: (m, c) => console.info(`[nxus-qbd] ${m}`, c ??
|
|
39
|
-
warn: (m, c) => console.warn(`[nxus-qbd] ${m}`, c ??
|
|
40
|
-
error: (m, c) => console.error(`[nxus-qbd] ${m}`, c ??
|
|
51
|
+
debug: (m, c) => console.debug(`[nxus-qbd] ${m}`, c ?? ""),
|
|
52
|
+
info: (m, c) => console.info(`[nxus-qbd] ${m}`, c ?? ""),
|
|
53
|
+
warn: (m, c) => console.warn(`[nxus-qbd] ${m}`, c ?? ""),
|
|
54
|
+
error: (m, c) => console.error(`[nxus-qbd] ${m}`, c ?? ""),
|
|
41
55
|
};
|
|
42
56
|
}
|
|
57
|
+
function hasHeader(headers, name) {
|
|
58
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
function setHeader(headers, name, value) {
|
|
61
|
+
for (const key of Object.keys(headers)) {
|
|
62
|
+
if (key.toLowerCase() === name.toLowerCase() && key !== name) {
|
|
63
|
+
delete headers[key];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
headers[name] = value;
|
|
67
|
+
}
|
|
68
|
+
/** Resolve case-insensitive duplicates after all header sources are merged. */
|
|
69
|
+
function canonicalizeHeader(headers, canonicalName) {
|
|
70
|
+
const matching = Object.entries(headers).filter(([name]) => name.toLowerCase() === canonicalName.toLowerCase());
|
|
71
|
+
const value = matching.at(-1)?.[1];
|
|
72
|
+
if (value !== undefined) {
|
|
73
|
+
setHeader(headers, canonicalName, value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const RETRY_IDEMPOTENT_CREATE_TIMEOUT = Symbol("retryIdempotentCreateTimeout");
|
|
77
|
+
/** @internal Marks only SDK-owned generic Create options as timeout-retryable. */
|
|
78
|
+
export function enableIdempotentCreateTimeoutRetry(options) {
|
|
79
|
+
Object.defineProperty(options, RETRY_IDEMPOTENT_CREATE_TIMEOUT, {
|
|
80
|
+
value: true,
|
|
81
|
+
});
|
|
82
|
+
return options;
|
|
83
|
+
}
|
|
84
|
+
function retriesIdempotentCreateTimeout(options) {
|
|
85
|
+
return Boolean(options &&
|
|
86
|
+
options[RETRY_IDEMPOTENT_CREATE_TIMEOUT]);
|
|
87
|
+
}
|
|
43
88
|
function normalizeErrorPayload(errorBody, response) {
|
|
44
89
|
if (errorBody == null) {
|
|
45
90
|
return {
|
|
@@ -47,28 +92,108 @@ function normalizeErrorPayload(errorBody, response) {
|
|
|
47
92
|
message: response.statusText,
|
|
48
93
|
};
|
|
49
94
|
}
|
|
50
|
-
if (typeof errorBody !==
|
|
95
|
+
if (typeof errorBody !== "object") {
|
|
51
96
|
return errorBody;
|
|
52
97
|
}
|
|
53
|
-
const
|
|
98
|
+
const source = errorBody;
|
|
99
|
+
const nestedEnvelope = asRecord(source.nxusApiError) ?? asRecord(source.qbdApiError);
|
|
100
|
+
const normalized = {
|
|
101
|
+
...(nestedEnvelope ?? source),
|
|
102
|
+
};
|
|
103
|
+
if (nestedEnvelope) {
|
|
104
|
+
if (normalized.requestId == null && source.requestId != null) {
|
|
105
|
+
normalized.requestId = source.requestId;
|
|
106
|
+
}
|
|
107
|
+
if (normalized.status == null && source.status != null) {
|
|
108
|
+
normalized.status = source.status;
|
|
109
|
+
}
|
|
110
|
+
if (normalized.statusCode == null && source.statusCode != null) {
|
|
111
|
+
normalized.statusCode = source.statusCode;
|
|
112
|
+
}
|
|
113
|
+
if (normalized.retryAfter == null && source.retryAfter != null) {
|
|
114
|
+
normalized.retryAfter = source.retryAfter;
|
|
115
|
+
}
|
|
116
|
+
if (normalized.restriction == null && source.restriction != null) {
|
|
117
|
+
normalized.restriction = source.restriction;
|
|
118
|
+
}
|
|
119
|
+
if (normalized.billing == null && source.billing != null) {
|
|
120
|
+
normalized.billing = source.billing;
|
|
121
|
+
}
|
|
122
|
+
if (normalized.lifecycleState == null && source.lifecycleState != null) {
|
|
123
|
+
normalized.lifecycleState = source.lifecycleState;
|
|
124
|
+
}
|
|
125
|
+
if (normalized.restrictionReason == null &&
|
|
126
|
+
source.restrictionReason != null) {
|
|
127
|
+
normalized.restrictionReason = source.restrictionReason;
|
|
128
|
+
}
|
|
129
|
+
if (normalized.restrictionCode == null && source.restrictionCode != null) {
|
|
130
|
+
normalized.restrictionCode = source.restrictionCode;
|
|
131
|
+
}
|
|
132
|
+
if (normalized.requiresPayment == null && source.requiresPayment != null) {
|
|
133
|
+
normalized.requiresPayment = source.requiresPayment;
|
|
134
|
+
}
|
|
135
|
+
if (normalized.checkoutUrl == null && source.checkoutUrl != null) {
|
|
136
|
+
normalized.checkoutUrl = source.checkoutUrl;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
54
139
|
if (normalized.status == null) {
|
|
55
140
|
normalized.status = response.status;
|
|
56
141
|
}
|
|
57
|
-
|
|
58
|
-
|
|
142
|
+
if (normalized.httpStatusCode == null) {
|
|
143
|
+
normalized.httpStatusCode = response.status;
|
|
144
|
+
}
|
|
145
|
+
if (normalized.retryAfter == null) {
|
|
146
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
147
|
+
if (retryAfterMs != null) {
|
|
148
|
+
normalized.retryAfter = retryAfterMs / 1_000;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Bodies that carry no request id still get one from the header, so
|
|
152
|
+
// `err.requestId` is usable for support regardless of the error shape.
|
|
153
|
+
if (normalized.requestId == null) {
|
|
154
|
+
const headerRequestId = response.headers.get("x-request-id");
|
|
155
|
+
if (headerRequestId) {
|
|
156
|
+
normalized.requestId = headerRequestId;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const normalizedError = normalized.error;
|
|
160
|
+
if (normalizedError && typeof normalizedError === "object") {
|
|
59
161
|
normalized.error = {
|
|
60
|
-
...
|
|
61
|
-
httpStatusCode:
|
|
162
|
+
...normalizedError,
|
|
163
|
+
httpStatusCode: normalizedError.httpStatusCode ??
|
|
164
|
+
response.status,
|
|
62
165
|
};
|
|
63
166
|
}
|
|
64
167
|
return normalized;
|
|
65
168
|
}
|
|
169
|
+
function asRecord(value) {
|
|
170
|
+
return typeof value === "object" && value !== null
|
|
171
|
+
? value
|
|
172
|
+
: undefined;
|
|
173
|
+
}
|
|
174
|
+
function extractLogicalErrorPayload(body) {
|
|
175
|
+
const record = asRecord(body);
|
|
176
|
+
if (!record) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if (record.success === false) {
|
|
180
|
+
return body;
|
|
181
|
+
}
|
|
182
|
+
if (asRecord(record.nxusApiError) || asRecord(record.qbdApiError)) {
|
|
183
|
+
return body;
|
|
184
|
+
}
|
|
185
|
+
if (String(record.status).toLowerCase() === "failed") {
|
|
186
|
+
return body;
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
66
190
|
// ---------------------------------------------------------------------------
|
|
67
191
|
// Transport
|
|
68
192
|
// ---------------------------------------------------------------------------
|
|
69
193
|
export class NxusHttpTransport {
|
|
70
194
|
baseUrl;
|
|
71
195
|
apiKey;
|
|
196
|
+
defaultConnectionId;
|
|
72
197
|
defaultHeaders;
|
|
73
198
|
defaultTimeout;
|
|
74
199
|
defaultServerTimeoutSeconds;
|
|
@@ -81,8 +206,9 @@ export class NxusHttpTransport {
|
|
|
81
206
|
dispatcherPromise;
|
|
82
207
|
constructor(options) {
|
|
83
208
|
// Ensure trailing slash for consistent URL joining
|
|
84
|
-
this.baseUrl = options.baseUrl.replace(/\/+$/,
|
|
209
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
85
210
|
this.apiKey = options.apiKey;
|
|
211
|
+
this.defaultConnectionId = options.connectionId;
|
|
86
212
|
this.defaultHeaders = options.headers ?? {};
|
|
87
213
|
this.defaultTimeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
88
214
|
this.defaultServerTimeoutSeconds = options.serverTimeoutSeconds;
|
|
@@ -93,35 +219,76 @@ export class NxusHttpTransport {
|
|
|
93
219
|
this.fetchOptions = options.fetchOptions ?? {};
|
|
94
220
|
}
|
|
95
221
|
async get(path, query, options) {
|
|
96
|
-
|
|
97
|
-
return this.request(url, { method: 'GET' }, options);
|
|
222
|
+
return (await this.sendGet(path, query, options)).body;
|
|
98
223
|
}
|
|
99
224
|
async post(path, body, options) {
|
|
225
|
+
return (await this.sendPost(path, body, options)).body;
|
|
226
|
+
}
|
|
227
|
+
async delete(path, options) {
|
|
228
|
+
return (await this.sendDelete(path, options)).body;
|
|
229
|
+
}
|
|
230
|
+
/** @internal */
|
|
231
|
+
async deleteWithBody(path, body, options) {
|
|
232
|
+
return (await this.sendDeleteWithBody(path, body, options)).body;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Snapshot-returning counterparts of `get`/`post`/`delete`.
|
|
236
|
+
*
|
|
237
|
+
* These are the real implementations; the three above are thin wrappers that
|
|
238
|
+
* discard the metadata. Both paths therefore share one retry loop, one error
|
|
239
|
+
* translation, and one parse — metadata-aware calls cannot drift from plain
|
|
240
|
+
* ones.
|
|
241
|
+
*
|
|
242
|
+
* @internal
|
|
243
|
+
*/
|
|
244
|
+
async sendGet(path, query, options) {
|
|
245
|
+
const url = this.buildUrl(path, query);
|
|
246
|
+
return this.request(url, { method: "GET" }, options);
|
|
247
|
+
}
|
|
248
|
+
/** @internal */
|
|
249
|
+
async sendPost(path, body, options) {
|
|
100
250
|
const url = this.buildUrl(path);
|
|
101
251
|
return this.request(url, {
|
|
102
|
-
method:
|
|
252
|
+
method: "POST",
|
|
103
253
|
body: body != null ? JSON.stringify(body) : undefined,
|
|
104
254
|
}, options);
|
|
105
255
|
}
|
|
106
|
-
|
|
256
|
+
/** @internal */
|
|
257
|
+
async sendDelete(path, options) {
|
|
107
258
|
const url = this.buildUrl(path);
|
|
108
|
-
return this.request(url, { method:
|
|
259
|
+
return this.request(url, { method: "DELETE" }, options);
|
|
260
|
+
}
|
|
261
|
+
/** @internal */
|
|
262
|
+
async sendDeleteWithBody(path, body, options) {
|
|
263
|
+
const url = this.buildUrl(path);
|
|
264
|
+
return this.request(url, {
|
|
265
|
+
method: "DELETE",
|
|
266
|
+
body: JSON.stringify(body),
|
|
267
|
+
}, options);
|
|
109
268
|
}
|
|
110
269
|
/**
|
|
111
270
|
* Issue a raw HTTP request and return the unparsed `Response`.
|
|
112
271
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
272
|
+
* Gives direct access to status, headers, and the response body as a
|
|
273
|
+
* stream/blob/text. Bypasses JSON parsing and the typed error mapping, but
|
|
274
|
+
* still applies authentication, the default headers, the timeout, and
|
|
275
|
+
* retries. Non-2xx responses are returned, not thrown — the caller is
|
|
276
|
+
* responsible for `response.ok` handling.
|
|
277
|
+
*
|
|
278
|
+
* **SDK-internal.** `NxusClient.transport` is private, so this is not
|
|
279
|
+
* reachable from consumer code. The previous `@example` here showed
|
|
280
|
+
* `client.transport.raw(...)`, which does not compile. Consumers needing
|
|
281
|
+
* status/headers/request-id should not reach for the transport — that is
|
|
282
|
+
* what the planned public response wrapper is for.
|
|
118
283
|
*
|
|
119
284
|
* @example
|
|
120
285
|
* ```ts
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* const
|
|
286
|
+
* // Internal use only — see resources/custom-fields.ts `deleteWithBody`,
|
|
287
|
+
* // which needs a JSON body on DELETE and maps non-2xx onto NxusApiError.
|
|
288
|
+
* const res = await this.transport.raw(path, { method: 'DELETE', body });
|
|
124
289
|
* ```
|
|
290
|
+
*
|
|
291
|
+
* @internal
|
|
125
292
|
*/
|
|
126
293
|
async raw(path, init = {}, options) {
|
|
127
294
|
const { query, ...rest } = init;
|
|
@@ -132,10 +299,10 @@ export class NxusHttpTransport {
|
|
|
132
299
|
// Internal
|
|
133
300
|
// -------------------------------------------------------------------------
|
|
134
301
|
buildUrl(path, query) {
|
|
135
|
-
const url = new URL(path, this.baseUrl +
|
|
302
|
+
const url = new URL(path, this.baseUrl + "/");
|
|
136
303
|
// The URL constructor resolves relative to base — if path starts with /
|
|
137
304
|
// we need to set it directly
|
|
138
|
-
if (path.startsWith(
|
|
305
|
+
if (path.startsWith("/")) {
|
|
139
306
|
url.pathname = path;
|
|
140
307
|
}
|
|
141
308
|
if (query) {
|
|
@@ -164,11 +331,17 @@ export class NxusHttpTransport {
|
|
|
164
331
|
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
165
332
|
const maxRetries = Math.max(0, options?.maxRetries ?? this.defaultMaxRetries);
|
|
166
333
|
const verbose = options?.verbose ?? this.verbose;
|
|
167
|
-
const extraFetchOptions = {
|
|
334
|
+
const extraFetchOptions = {
|
|
335
|
+
...this.fetchOptions,
|
|
336
|
+
...(options?.fetchOptions ?? {}),
|
|
337
|
+
};
|
|
338
|
+
// Read here as well as inside the attempt, so a cancellation lands during
|
|
339
|
+
// the backoff wait rather than only at the next dispatch.
|
|
340
|
+
const { callerSignal } = extractCallerSignal(extraFetchOptions);
|
|
168
341
|
let attempt = 0;
|
|
169
342
|
while (true) {
|
|
170
343
|
if (verbose) {
|
|
171
|
-
this.logger.debug(
|
|
344
|
+
this.logger.debug("request", {
|
|
172
345
|
method: init.method,
|
|
173
346
|
url,
|
|
174
347
|
headers: redactHeaders(headers),
|
|
@@ -176,20 +349,26 @@ export class NxusHttpTransport {
|
|
|
176
349
|
maxRetries,
|
|
177
350
|
});
|
|
178
351
|
}
|
|
179
|
-
const outcome = await this.attempt(url, init, headers, timeout, extraFetchOptions);
|
|
352
|
+
const outcome = await this.attempt(url, init, headers, timeout, extraFetchOptions, options?.includeRawBody ?? false);
|
|
180
353
|
if (verbose) {
|
|
181
354
|
this.logOutcome(outcome, { method: init.method, url, attempt });
|
|
182
355
|
}
|
|
183
|
-
if (outcome.kind ===
|
|
356
|
+
if (outcome.kind === "success") {
|
|
184
357
|
return outcome.value;
|
|
185
358
|
}
|
|
359
|
+
// Rethrow the caller's own abort reason rather than wrapping it. A
|
|
360
|
+
// consumer racing an AbortController checks `err.name === "AbortError"`;
|
|
361
|
+
// an NxusApiError here would read as a request failure instead.
|
|
362
|
+
if (outcome.kind === "canceled") {
|
|
363
|
+
throw outcome.reason;
|
|
364
|
+
}
|
|
186
365
|
if (attempt >= maxRetries ||
|
|
187
|
-
!shouldRetry(outcome)) {
|
|
366
|
+
!shouldRetry(outcome, retriesIdempotentCreateTimeout(options))) {
|
|
188
367
|
throw outcome.error;
|
|
189
368
|
}
|
|
190
369
|
const delayMs = computeRetryDelay(attempt, outcome);
|
|
191
370
|
if (verbose) {
|
|
192
|
-
this.logger.debug(
|
|
371
|
+
this.logger.debug("retry-scheduled", {
|
|
193
372
|
url,
|
|
194
373
|
attempt: attempt + 1,
|
|
195
374
|
delayMs,
|
|
@@ -197,7 +376,7 @@ export class NxusHttpTransport {
|
|
|
197
376
|
}
|
|
198
377
|
attempt += 1;
|
|
199
378
|
if (delayMs > 0) {
|
|
200
|
-
await sleep(delayMs);
|
|
379
|
+
await sleep(delayMs, callerSignal);
|
|
201
380
|
}
|
|
202
381
|
}
|
|
203
382
|
}
|
|
@@ -206,12 +385,18 @@ export class NxusHttpTransport {
|
|
|
206
385
|
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
207
386
|
const maxRetries = Math.max(0, options?.maxRetries ?? this.defaultMaxRetries);
|
|
208
387
|
const verbose = options?.verbose ?? this.verbose;
|
|
209
|
-
const extraFetchOptions = {
|
|
388
|
+
const extraFetchOptions = {
|
|
389
|
+
...this.fetchOptions,
|
|
390
|
+
...(options?.fetchOptions ?? {}),
|
|
391
|
+
};
|
|
392
|
+
// Read here as well as inside the attempt, so a cancellation lands during
|
|
393
|
+
// the backoff wait rather than only at the next dispatch.
|
|
394
|
+
const { callerSignal } = extractCallerSignal(extraFetchOptions);
|
|
210
395
|
let attempt = 0;
|
|
211
396
|
while (true) {
|
|
212
397
|
if (verbose) {
|
|
213
|
-
this.logger.debug(
|
|
214
|
-
method: init.method ??
|
|
398
|
+
this.logger.debug("request", {
|
|
399
|
+
method: init.method ?? "GET",
|
|
215
400
|
url,
|
|
216
401
|
headers: redactHeaders(headers),
|
|
217
402
|
attempt,
|
|
@@ -221,22 +406,30 @@ export class NxusHttpTransport {
|
|
|
221
406
|
}
|
|
222
407
|
const outcome = await this.attemptRaw(url, init, headers, timeout, extraFetchOptions);
|
|
223
408
|
if (verbose) {
|
|
224
|
-
this.logRawOutcome(outcome, {
|
|
409
|
+
this.logRawOutcome(outcome, {
|
|
410
|
+
method: init.method ?? "GET",
|
|
411
|
+
url,
|
|
412
|
+
attempt,
|
|
413
|
+
});
|
|
225
414
|
}
|
|
226
|
-
if (outcome.kind ===
|
|
415
|
+
if (outcome.kind === "response") {
|
|
227
416
|
return outcome.response;
|
|
228
417
|
}
|
|
229
|
-
if (
|
|
418
|
+
if (outcome.kind === "canceled") {
|
|
419
|
+
throw outcome.reason;
|
|
420
|
+
}
|
|
421
|
+
if (attempt >= maxRetries ||
|
|
422
|
+
!shouldRetry(outcome, retriesIdempotentCreateTimeout(options))) {
|
|
230
423
|
// Non-2xx that we won't retry: return the Response so the caller can
|
|
231
424
|
// inspect status/headers/body, matching the documented contract.
|
|
232
|
-
if (outcome.kind ===
|
|
425
|
+
if (outcome.kind === "http-error") {
|
|
233
426
|
return outcome.response;
|
|
234
427
|
}
|
|
235
428
|
throw outcome.error;
|
|
236
429
|
}
|
|
237
430
|
const delayMs = computeRetryDelay(attempt, outcome);
|
|
238
431
|
if (verbose) {
|
|
239
|
-
this.logger.debug(
|
|
432
|
+
this.logger.debug("retry-scheduled", {
|
|
240
433
|
url,
|
|
241
434
|
attempt: attempt + 1,
|
|
242
435
|
delayMs,
|
|
@@ -244,24 +437,32 @@ export class NxusHttpTransport {
|
|
|
244
437
|
}
|
|
245
438
|
attempt += 1;
|
|
246
439
|
if (delayMs > 0) {
|
|
247
|
-
await sleep(delayMs);
|
|
440
|
+
await sleep(delayMs, callerSignal);
|
|
248
441
|
}
|
|
249
442
|
}
|
|
250
443
|
}
|
|
251
444
|
buildHeaders(options) {
|
|
252
445
|
const headers = {
|
|
253
|
-
|
|
446
|
+
"Content-Type": "application/json",
|
|
254
447
|
Authorization: `Bearer ${this.apiKey}`,
|
|
255
448
|
...this.defaultHeaders,
|
|
256
449
|
...options?.headers,
|
|
257
450
|
};
|
|
258
|
-
if (options?.connectionId) {
|
|
259
|
-
headers
|
|
451
|
+
if (options?.connectionId !== undefined) {
|
|
452
|
+
setHeader(headers, "X-Connection-Id", options.connectionId);
|
|
260
453
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
headers
|
|
454
|
+
else if (this.defaultConnectionId != null &&
|
|
455
|
+
!hasHeader(headers, "X-Connection-Id")) {
|
|
456
|
+
setHeader(headers, "X-Connection-Id", this.defaultConnectionId);
|
|
264
457
|
}
|
|
458
|
+
if (options?.serverTimeoutSeconds !== undefined) {
|
|
459
|
+
setHeader(headers, "X-Nxus-Timeout-Seconds", String(options.serverTimeoutSeconds));
|
|
460
|
+
}
|
|
461
|
+
else if (this.defaultServerTimeoutSeconds != null &&
|
|
462
|
+
!hasHeader(headers, "X-Nxus-Timeout-Seconds")) {
|
|
463
|
+
setHeader(headers, "X-Nxus-Timeout-Seconds", String(this.defaultServerTimeoutSeconds));
|
|
464
|
+
}
|
|
465
|
+
canonicalizeHeader(headers, "Idempotency-Key");
|
|
265
466
|
return headers;
|
|
266
467
|
}
|
|
267
468
|
async resolveDispatcher() {
|
|
@@ -274,15 +475,16 @@ export class NxusHttpTransport {
|
|
|
274
475
|
// handling without throwing.
|
|
275
476
|
try {
|
|
276
477
|
// @ts-expect-error - undici is an optional runtime peer
|
|
277
|
-
const mod = await import(
|
|
278
|
-
const Agent = mod
|
|
478
|
+
const mod = await import("undici");
|
|
479
|
+
const Agent = mod
|
|
480
|
+
.ProxyAgent;
|
|
279
481
|
if (!Agent)
|
|
280
482
|
return undefined;
|
|
281
483
|
return new Agent(this.proxyUrl);
|
|
282
484
|
}
|
|
283
485
|
catch (err) {
|
|
284
486
|
if (this.verbose) {
|
|
285
|
-
this.logger.warn(
|
|
487
|
+
this.logger.warn("proxy-agent-unavailable", {
|
|
286
488
|
proxy: this.proxyUrl,
|
|
287
489
|
reason: err instanceof Error ? err.message : String(err),
|
|
288
490
|
});
|
|
@@ -294,6 +496,11 @@ export class NxusHttpTransport {
|
|
|
294
496
|
return this.dispatcherPromise;
|
|
295
497
|
}
|
|
296
498
|
async resolveFetchInit(init, headers, signal, extraFetchOptions) {
|
|
499
|
+
// `signal` is placed last on purpose, and `extraFetchOptions` must already
|
|
500
|
+
// have had the caller's own signal removed by extractCallerSignal — the
|
|
501
|
+
// combined signal passed in here is what represents both. Leaving a raw
|
|
502
|
+
// caller signal in extraFetchOptions would let this spread drop the
|
|
503
|
+
// timeout instead, which is the same bug in the other direction.
|
|
297
504
|
const merged = {
|
|
298
505
|
...extraFetchOptions,
|
|
299
506
|
...init,
|
|
@@ -315,22 +522,25 @@ export class NxusHttpTransport {
|
|
|
315
522
|
}
|
|
316
523
|
logOutcome(outcome, ctx) {
|
|
317
524
|
switch (outcome.kind) {
|
|
318
|
-
case
|
|
319
|
-
this.logger.debug(
|
|
525
|
+
case "success":
|
|
526
|
+
this.logger.debug("response", { ...ctx, ok: true });
|
|
320
527
|
return;
|
|
321
|
-
case
|
|
322
|
-
this.logger.warn(
|
|
528
|
+
case "http-error":
|
|
529
|
+
this.logger.warn("response", {
|
|
323
530
|
...ctx,
|
|
324
531
|
status: outcome.status,
|
|
325
532
|
retryAfter: outcome.retryAfter,
|
|
326
533
|
shouldRetry: outcome.shouldRetry,
|
|
327
534
|
});
|
|
328
535
|
return;
|
|
329
|
-
case
|
|
330
|
-
this.logger.warn(
|
|
536
|
+
case "timeout":
|
|
537
|
+
this.logger.warn("timeout", ctx);
|
|
331
538
|
return;
|
|
332
|
-
case
|
|
333
|
-
this.logger.
|
|
539
|
+
case "canceled":
|
|
540
|
+
this.logger.debug("canceled", ctx);
|
|
541
|
+
return;
|
|
542
|
+
case "network-error":
|
|
543
|
+
this.logger.warn("network-error", {
|
|
334
544
|
...ctx,
|
|
335
545
|
reason: outcome.error.message,
|
|
336
546
|
});
|
|
@@ -339,15 +549,15 @@ export class NxusHttpTransport {
|
|
|
339
549
|
}
|
|
340
550
|
logRawOutcome(outcome, ctx) {
|
|
341
551
|
switch (outcome.kind) {
|
|
342
|
-
case
|
|
343
|
-
this.logger.debug(
|
|
552
|
+
case "response":
|
|
553
|
+
this.logger.debug("response", {
|
|
344
554
|
...ctx,
|
|
345
555
|
status: outcome.response.status,
|
|
346
556
|
raw: true,
|
|
347
557
|
});
|
|
348
558
|
return;
|
|
349
|
-
case
|
|
350
|
-
this.logger.warn(
|
|
559
|
+
case "http-error":
|
|
560
|
+
this.logger.warn("response", {
|
|
351
561
|
...ctx,
|
|
352
562
|
status: outcome.status,
|
|
353
563
|
retryAfter: outcome.retryAfter,
|
|
@@ -355,22 +565,33 @@ export class NxusHttpTransport {
|
|
|
355
565
|
raw: true,
|
|
356
566
|
});
|
|
357
567
|
return;
|
|
358
|
-
case
|
|
359
|
-
this.logger.warn(
|
|
568
|
+
case "timeout":
|
|
569
|
+
this.logger.warn("timeout", ctx);
|
|
570
|
+
return;
|
|
571
|
+
case "canceled":
|
|
572
|
+
this.logger.debug("canceled", ctx);
|
|
360
573
|
return;
|
|
361
|
-
case
|
|
362
|
-
this.logger.warn(
|
|
574
|
+
case "network-error":
|
|
575
|
+
this.logger.warn("network-error", {
|
|
363
576
|
...ctx,
|
|
364
577
|
reason: outcome.error.message,
|
|
365
578
|
});
|
|
366
579
|
return;
|
|
367
580
|
}
|
|
368
581
|
}
|
|
369
|
-
async attempt(url, init, headers, timeout, extraFetchOptions) {
|
|
582
|
+
async attempt(url, init, headers, timeout, extraFetchOptions, includeRawBody = false) {
|
|
583
|
+
const { callerSignal, rest } = extractCallerSignal(extraFetchOptions);
|
|
584
|
+
// Bail before dispatching. A caller who aborted before the call was made
|
|
585
|
+
// still had the request sent and the response returned, because the
|
|
586
|
+
// caller's signal never reached fetch at all.
|
|
587
|
+
if (callerSignal?.aborted) {
|
|
588
|
+
return { kind: "canceled", reason: callerSignal.reason };
|
|
589
|
+
}
|
|
370
590
|
const controller = new AbortController();
|
|
371
591
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
592
|
+
const composed = combineAbortSignals(callerSignal, controller.signal);
|
|
372
593
|
try {
|
|
373
|
-
const fetchInit = await this.resolveFetchInit(init, headers,
|
|
594
|
+
const fetchInit = await this.resolveFetchInit(init, headers, composed.signal, rest);
|
|
374
595
|
const response = await fetch(url, fetchInit);
|
|
375
596
|
if (!response.ok) {
|
|
376
597
|
let errorBody;
|
|
@@ -385,42 +606,78 @@ export class NxusHttpTransport {
|
|
|
385
606
|
// body's `error.retryAfter` (seconds, integer) when the header is
|
|
386
607
|
// missing. This keeps behavior correct against proxies that strip
|
|
387
608
|
// hop-by-hop headers but preserve the body.
|
|
388
|
-
const retryAfter = parseRetryAfter(response.headers.get(
|
|
609
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after")) ??
|
|
389
610
|
parseBodyRetryAfter(errorBody);
|
|
390
611
|
return {
|
|
391
|
-
kind:
|
|
612
|
+
kind: "http-error",
|
|
392
613
|
status: response.status,
|
|
393
614
|
retryAfter,
|
|
394
|
-
shouldRetry: parseShouldRetry(response.headers.get(
|
|
615
|
+
shouldRetry: parseShouldRetry(response.headers.get("x-should-retry")),
|
|
395
616
|
error,
|
|
396
617
|
};
|
|
397
618
|
}
|
|
398
619
|
// 204 No Content
|
|
399
620
|
if (response.status === 204) {
|
|
400
|
-
return {
|
|
621
|
+
return {
|
|
622
|
+
kind: "success",
|
|
623
|
+
value: snapshot(response, undefined, undefined, includeRawBody),
|
|
624
|
+
};
|
|
401
625
|
}
|
|
402
626
|
const text = await response.text();
|
|
403
627
|
if (!text) {
|
|
404
|
-
return {
|
|
628
|
+
return {
|
|
629
|
+
kind: "success",
|
|
630
|
+
value: snapshot(response, undefined, "", includeRawBody),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const parsed = JSON.parse(text);
|
|
634
|
+
const logicalErrorPayload = extractLogicalErrorPayload(parsed);
|
|
635
|
+
if (logicalErrorPayload !== undefined) {
|
|
636
|
+
const normalizedError = normalizeErrorPayload(logicalErrorPayload, response);
|
|
637
|
+
return {
|
|
638
|
+
kind: "http-error",
|
|
639
|
+
status: typeof asRecord(normalizedError)?.status === "number"
|
|
640
|
+
? asRecord(normalizedError)?.status
|
|
641
|
+
: response.status,
|
|
642
|
+
retryAfter: parseBodyRetryAfter(normalizedError),
|
|
643
|
+
shouldRetry: parseShouldRetry(response.headers.get("x-should-retry")),
|
|
644
|
+
error: NxusApiError.from(normalizedError),
|
|
645
|
+
};
|
|
405
646
|
}
|
|
406
|
-
return {
|
|
647
|
+
return {
|
|
648
|
+
kind: "success",
|
|
649
|
+
value: snapshot(response, parsed, text, includeRawBody),
|
|
650
|
+
};
|
|
407
651
|
}
|
|
408
652
|
catch (error) {
|
|
409
|
-
|
|
653
|
+
// The caller's signal is checked FIRST, and independently of what was
|
|
654
|
+
// thrown. fetch rejects with the signal's abort reason verbatim, and
|
|
655
|
+
// `controller.abort(new Error("deployment shutdown"))` produces a plain
|
|
656
|
+
// Error whose name is "Error" — so keying off the thrown value's name
|
|
657
|
+
// classified a real cancellation as a network failure and retried it.
|
|
658
|
+
// Whether the caller aborted is a fact about the signal, not about the
|
|
659
|
+
// shape of the rejection.
|
|
660
|
+
if (callerSignal?.aborted) {
|
|
661
|
+
return { kind: "canceled", reason: callerSignal.reason };
|
|
662
|
+
}
|
|
663
|
+
if (isAbortError(error)) {
|
|
664
|
+
// Not the caller, so this is our own timeout controller firing. The
|
|
665
|
+
// two mean opposite things: a timeout leaves the outcome unknown and
|
|
666
|
+
// may deserve a retry, a cancellation is an instruction to stop.
|
|
410
667
|
return {
|
|
411
|
-
kind:
|
|
668
|
+
kind: "timeout",
|
|
412
669
|
error: new NxusApiError({
|
|
413
670
|
message: `Request timed out after ${timeout}ms`,
|
|
414
|
-
userMessage:
|
|
671
|
+
userMessage: "The request timed out. Please try again.",
|
|
415
672
|
status: 0,
|
|
416
673
|
}),
|
|
417
674
|
};
|
|
418
675
|
}
|
|
419
676
|
return {
|
|
420
|
-
kind:
|
|
677
|
+
kind: "network-error",
|
|
421
678
|
error: new NxusApiError({
|
|
422
|
-
message: error instanceof Error ? error.message :
|
|
423
|
-
userMessage:
|
|
679
|
+
message: error instanceof Error ? error.message : "Network request failed",
|
|
680
|
+
userMessage: "A network error occurred. Please check your connection and try again.",
|
|
424
681
|
status: 0,
|
|
425
682
|
raw: error,
|
|
426
683
|
}),
|
|
@@ -428,44 +685,54 @@ export class NxusHttpTransport {
|
|
|
428
685
|
}
|
|
429
686
|
finally {
|
|
430
687
|
clearTimeout(timer);
|
|
688
|
+
composed.dispose();
|
|
431
689
|
}
|
|
432
690
|
}
|
|
433
691
|
async attemptRaw(url, init, headers, timeout, extraFetchOptions) {
|
|
692
|
+
const { callerSignal, rest } = extractCallerSignal(extraFetchOptions);
|
|
693
|
+
if (callerSignal?.aborted) {
|
|
694
|
+
return { kind: "canceled", reason: callerSignal.reason };
|
|
695
|
+
}
|
|
434
696
|
const controller = new AbortController();
|
|
435
697
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
698
|
+
const composed = combineAbortSignals(callerSignal, controller.signal);
|
|
436
699
|
try {
|
|
437
|
-
const fetchInit = await this.resolveFetchInit(init, headers,
|
|
700
|
+
const fetchInit = await this.resolveFetchInit(init, headers, composed.signal, rest);
|
|
438
701
|
const response = await fetch(url, fetchInit);
|
|
439
702
|
if (!response.ok) {
|
|
440
703
|
// Classify retryable failures by status + headers only — never read
|
|
441
704
|
// the body, since the caller owns the stream.
|
|
442
|
-
const retryAfter = parseRetryAfter(response.headers.get(
|
|
705
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after")) ?? undefined;
|
|
443
706
|
return {
|
|
444
|
-
kind:
|
|
707
|
+
kind: "http-error",
|
|
445
708
|
status: response.status,
|
|
446
709
|
retryAfter,
|
|
447
|
-
shouldRetry: parseShouldRetry(response.headers.get(
|
|
710
|
+
shouldRetry: parseShouldRetry(response.headers.get("x-should-retry")),
|
|
448
711
|
response,
|
|
449
712
|
};
|
|
450
713
|
}
|
|
451
|
-
return { kind:
|
|
714
|
+
return { kind: "response", response };
|
|
452
715
|
}
|
|
453
716
|
catch (error) {
|
|
454
|
-
|
|
717
|
+
// Checked first and independently of the thrown value — see attempt().
|
|
718
|
+
if (callerSignal?.aborted) {
|
|
719
|
+
return { kind: "canceled", reason: callerSignal.reason };
|
|
720
|
+
}
|
|
721
|
+
if (isAbortError(error)) {
|
|
455
722
|
return {
|
|
456
|
-
kind:
|
|
723
|
+
kind: "timeout",
|
|
457
724
|
error: new NxusApiError({
|
|
458
725
|
message: `Request timed out after ${timeout}ms`,
|
|
459
|
-
userMessage:
|
|
726
|
+
userMessage: "The request timed out. Please try again.",
|
|
460
727
|
status: 0,
|
|
461
728
|
}),
|
|
462
729
|
};
|
|
463
730
|
}
|
|
464
731
|
return {
|
|
465
|
-
kind:
|
|
732
|
+
kind: "network-error",
|
|
466
733
|
error: new NxusApiError({
|
|
467
|
-
message: error instanceof Error ? error.message :
|
|
468
|
-
userMessage:
|
|
734
|
+
message: error instanceof Error ? error.message : "Network request failed",
|
|
735
|
+
userMessage: "A network error occurred. Please check your connection and try again.",
|
|
469
736
|
status: 0,
|
|
470
737
|
raw: error,
|
|
471
738
|
}),
|
|
@@ -473,15 +740,59 @@ export class NxusHttpTransport {
|
|
|
473
740
|
}
|
|
474
741
|
finally {
|
|
475
742
|
clearTimeout(timer);
|
|
743
|
+
composed.dispose();
|
|
476
744
|
}
|
|
477
745
|
}
|
|
478
746
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Copy everything worth keeping out of a response before it is released.
|
|
749
|
+
*
|
|
750
|
+
* The body is already read by the time this is called, so nothing downstream
|
|
751
|
+
* can touch a consumed stream. `rawBody` is retained only on request — holding
|
|
752
|
+
* the undecoded text of every response would keep a second full copy of every
|
|
753
|
+
* payload alive.
|
|
754
|
+
*/
|
|
755
|
+
function snapshot(response, body, text, includeRawBody) {
|
|
756
|
+
const headers = {};
|
|
757
|
+
response.headers.forEach((value, key) => {
|
|
758
|
+
headers[key.toLowerCase()] = value;
|
|
759
|
+
});
|
|
760
|
+
const bodyRequestId = body != null && typeof body === "object"
|
|
761
|
+
? body.requestId
|
|
762
|
+
: undefined;
|
|
763
|
+
return {
|
|
764
|
+
body,
|
|
765
|
+
status: response.status,
|
|
766
|
+
headers: Object.freeze(headers),
|
|
767
|
+
requestId: (typeof bodyRequestId === "string" ? bodyRequestId : undefined) ??
|
|
768
|
+
headers["x-request-id"],
|
|
769
|
+
rawBody: includeRawBody ? text : undefined,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
function shouldRetry(outcome, retryTimeout = false) {
|
|
773
|
+
// The caller asked us to stop. Retrying would be the opposite of that, and
|
|
774
|
+
// for a Create it would resubmit work the caller is trying to abandon.
|
|
775
|
+
if (outcome.kind === "canceled")
|
|
483
776
|
return false;
|
|
484
|
-
if (outcome.kind ===
|
|
777
|
+
if (outcome.kind === "network-error")
|
|
778
|
+
return true;
|
|
779
|
+
if (outcome.kind === "timeout")
|
|
780
|
+
return retryTimeout;
|
|
781
|
+
if (outcome.kind === "http-error") {
|
|
782
|
+
// A wait longer than we are willing to block for. Returning false here
|
|
783
|
+
// rather than retrying early is the point: an early retry cannot succeed
|
|
784
|
+
// inside a window the server just told us is still open, and it consumes
|
|
785
|
+
// an attempt to learn that. The caller gets the error with `retryAfter`
|
|
786
|
+
// on it and can schedule the real wait.
|
|
787
|
+
//
|
|
788
|
+
// Checked before `x-should-retry` on purpose. The two answer different
|
|
789
|
+
// questions — whether to retry, and when — and a server sending
|
|
790
|
+
// `x-should-retry: true` with `Retry-After: 300` is asking for a retry in
|
|
791
|
+
// five minutes, not for this process to block for five minutes. Nothing
|
|
792
|
+
// is lost: both facts reach the caller on the error.
|
|
793
|
+
if (outcome.retryAfter != null && outcome.retryAfter > RETRY_AFTER_MAX_MS) {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
485
796
|
if (outcome.shouldRetry != null)
|
|
486
797
|
return outcome.shouldRetry;
|
|
487
798
|
if (RETRYABLE_STATUSES.has(outcome.status))
|
|
@@ -493,8 +804,10 @@ function shouldRetry(outcome) {
|
|
|
493
804
|
return false;
|
|
494
805
|
}
|
|
495
806
|
function computeRetryDelay(attempt, outcome) {
|
|
496
|
-
if (outcome.kind ===
|
|
497
|
-
|
|
807
|
+
if (outcome.kind === "http-error" && outcome.retryAfter != null) {
|
|
808
|
+
// Honoured in full. shouldRetry() has already refused anything above
|
|
809
|
+
// RETRY_AFTER_MAX_MS, so this cannot exceed the ceiling.
|
|
810
|
+
return outcome.retryAfter;
|
|
498
811
|
}
|
|
499
812
|
const exponential = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
|
|
500
813
|
const jitter = Math.random() * exponential * 0.5;
|
|
@@ -515,16 +828,18 @@ function parseRetryAfter(value) {
|
|
|
515
828
|
return undefined;
|
|
516
829
|
}
|
|
517
830
|
function parseBodyRetryAfter(body) {
|
|
518
|
-
if (body == null || typeof body !==
|
|
831
|
+
if (body == null || typeof body !== "object")
|
|
519
832
|
return undefined;
|
|
520
833
|
const root = body;
|
|
521
834
|
// Shape per backend contract: `{ error: { retryAfter: <seconds> } }`.
|
|
522
835
|
// Tolerate top-level `retryAfter` for older payloads / future flattening.
|
|
523
|
-
const errorObj = root.error && typeof root.error ===
|
|
836
|
+
const errorObj = root.error && typeof root.error === "object"
|
|
524
837
|
? root.error
|
|
525
838
|
: undefined;
|
|
526
839
|
const candidate = errorObj?.retryAfter ?? root.retryAfter;
|
|
527
|
-
if (typeof candidate !==
|
|
840
|
+
if (typeof candidate !== "number" ||
|
|
841
|
+
!Number.isFinite(candidate) ||
|
|
842
|
+
candidate < 0) {
|
|
528
843
|
return undefined;
|
|
529
844
|
}
|
|
530
845
|
return candidate * 1000;
|
|
@@ -533,13 +848,107 @@ function parseShouldRetry(value) {
|
|
|
533
848
|
if (!value)
|
|
534
849
|
return undefined;
|
|
535
850
|
const normalized = value.trim().toLowerCase();
|
|
536
|
-
if ([
|
|
851
|
+
if (["1", "true", "yes"].includes(normalized))
|
|
537
852
|
return true;
|
|
538
|
-
if ([
|
|
853
|
+
if (["0", "false", "no"].includes(normalized))
|
|
539
854
|
return false;
|
|
540
855
|
return undefined;
|
|
541
856
|
}
|
|
542
|
-
|
|
543
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Sleep that a caller can interrupt.
|
|
859
|
+
*
|
|
860
|
+
* Retry backoff can be seconds long. Without the signal, a caller cancelling
|
|
861
|
+
* mid-backoff would still wait out the delay and then issue another request.
|
|
862
|
+
*/
|
|
863
|
+
/**
|
|
864
|
+
* Abort detection that survives a cross-realm boundary.
|
|
865
|
+
*
|
|
866
|
+
* `instanceof DOMException` is false for an abort raised in another realm
|
|
867
|
+
* (undici's fetch, a worker, a polyfilled test double), and a miss there gets
|
|
868
|
+
* reported as a network error and retried — the opposite of what a cancelling
|
|
869
|
+
* caller asked for.
|
|
870
|
+
*/
|
|
871
|
+
function isAbortError(error) {
|
|
872
|
+
if (error instanceof DOMException && error.name === "AbortError")
|
|
873
|
+
return true;
|
|
874
|
+
return (typeof error === "object" &&
|
|
875
|
+
error !== null &&
|
|
876
|
+
error.name === "AbortError");
|
|
877
|
+
}
|
|
878
|
+
function sleep(ms, signal) {
|
|
879
|
+
if (!signal) {
|
|
880
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
881
|
+
}
|
|
882
|
+
if (signal.aborted) {
|
|
883
|
+
return Promise.reject(signal.reason);
|
|
884
|
+
}
|
|
885
|
+
return new Promise((resolve, reject) => {
|
|
886
|
+
const timer = setTimeout(() => {
|
|
887
|
+
signal.removeEventListener("abort", onAbort);
|
|
888
|
+
resolve();
|
|
889
|
+
}, ms);
|
|
890
|
+
const onAbort = () => {
|
|
891
|
+
clearTimeout(timer);
|
|
892
|
+
reject(signal.reason);
|
|
893
|
+
};
|
|
894
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Read a caller-supplied `signal` out of per-request `fetchOptions`.
|
|
899
|
+
*
|
|
900
|
+
* Returned separately from the rest of the options so the SDK's timeout signal
|
|
901
|
+
* cannot silently overwrite it, which is exactly what used to happen.
|
|
902
|
+
*/
|
|
903
|
+
function extractCallerSignal(extraFetchOptions) {
|
|
904
|
+
const { signal, ...rest } = extraFetchOptions;
|
|
905
|
+
return {
|
|
906
|
+
callerSignal: signal instanceof AbortSignal ? signal : undefined,
|
|
907
|
+
rest,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* One signal that aborts when either the caller or the timeout does, plus the
|
|
912
|
+
* cleanup that detaches whatever it attached.
|
|
913
|
+
*
|
|
914
|
+
* `AbortSignal.any` landed in Node 20.3; the package supports Node 18, so the
|
|
915
|
+
* manual path is not dead code.
|
|
916
|
+
*
|
|
917
|
+
* The caller's signal usually outlives the request — one controller cancels a
|
|
918
|
+
* whole batch — so the fallback's listeners MUST be removed when the request
|
|
919
|
+
* finishes, not only when an abort fires. `{ once: true }` covers the abort
|
|
920
|
+
* case and nothing else: twelve successful requests sharing one caller signal
|
|
921
|
+
* left twelve listeners attached, each retaining a controller and its closure.
|
|
922
|
+
* Hence the returned `dispose`, which every attempt calls from its finally.
|
|
923
|
+
*/
|
|
924
|
+
function combineAbortSignals(callerSignal, timeoutSignal) {
|
|
925
|
+
if (!callerSignal)
|
|
926
|
+
return { signal: timeoutSignal, dispose: () => { } };
|
|
927
|
+
const anyFn = AbortSignal.any;
|
|
928
|
+
if (typeof anyFn === "function") {
|
|
929
|
+
// Native composition detaches itself once the composed signal is
|
|
930
|
+
// unreachable; there is nothing for us to clean up.
|
|
931
|
+
return { signal: anyFn([callerSignal, timeoutSignal]), dispose: () => { } };
|
|
932
|
+
}
|
|
933
|
+
const controller = new AbortController();
|
|
934
|
+
const attached = [];
|
|
935
|
+
for (const source of [callerSignal, timeoutSignal]) {
|
|
936
|
+
if (source.aborted) {
|
|
937
|
+
controller.abort(source.reason);
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
const forward = () => controller.abort(source.reason);
|
|
941
|
+
source.addEventListener("abort", forward, { once: true });
|
|
942
|
+
attached.push([source, forward]);
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
signal: controller.signal,
|
|
946
|
+
dispose: () => {
|
|
947
|
+
for (const [source, forward] of attached) {
|
|
948
|
+
source.removeEventListener("abort", forward);
|
|
949
|
+
}
|
|
950
|
+
attached.length = 0;
|
|
951
|
+
},
|
|
952
|
+
};
|
|
544
953
|
}
|
|
545
954
|
//# sourceMappingURL=transport.js.map
|