@syndroo/sdk 0.4.0-rc.1 → 0.7.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 +83 -136
- package/dist/client.d.ts +30 -102
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +120 -325
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +48 -93
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +131 -137
- package/dist/errors.js.map +1 -1
- package/dist/generated/compiled.d.ts +182 -0
- package/dist/generated/compiled.d.ts.map +1 -0
- package/dist/generated/compiled.js +25761 -0
- package/dist/generated/compiled.js.map +1 -0
- package/dist/generated/types.d.ts +387 -0
- package/dist/generated/types.d.ts.map +1 -0
- package/dist/generated/types.js +2 -0
- package/dist/generated/types.js.map +1 -0
- package/dist/generated/validation.d.ts +18 -0
- package/dist/generated/validation.d.ts.map +1 -0
- package/dist/generated/validation.js +184 -0
- package/dist/generated/validation.js.map +1 -0
- package/dist/generated/validators.d.ts +3 -0
- package/dist/generated/validators.d.ts.map +1 -0
- package/dist/generated/validators.js +10 -0
- package/dist/generated/validators.js.map +1 -0
- package/dist/http.d.ts +91 -35
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +345 -243
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +90 -83
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +16 -155
- package/dist/types.js.map +1 -1
- package/dist/wait.d.ts +65 -0
- package/dist/wait.d.ts.map +1 -0
- package/dist/wait.js +192 -0
- package/dist/wait.js.map +1 -0
- package/package.json +6 -3
package/dist/http.js
CHANGED
|
@@ -1,298 +1,400 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HTTP transport for `@syndroo/sdk`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Scope, deliberately narrow:
|
|
5
5
|
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* a
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* - `POST /v1/connect`, `POST /v1/publish`, `POST /v1/status` only. There is no
|
|
7
|
+
* GET business route, no health probe and no CRUD family.
|
|
8
|
+
* - The bearer secret is required and travels only in the `Authorization`
|
|
9
|
+
* header. It is never placed in a URL, a query string or an error.
|
|
10
|
+
* - The base URL is checked before any socket is opened. Credentials/userinfo,
|
|
11
|
+
* a query and a fragment are rejected, and plain HTTP is accepted only for a
|
|
12
|
+
* loopback host, because a remote cleartext endpoint would expose the bearer
|
|
13
|
+
* secret to the network.
|
|
14
|
+
* - Redirects are never followed (`redirect: "manual"`). Following one would
|
|
15
|
+
* forward the `Authorization` header to an origin the caller never chose.
|
|
16
|
+
* - A request body is bounded and validated by the generated validator (64 KiB);
|
|
17
|
+
* a response is read with a hard 1 MiB cap while streaming, decoded as strict
|
|
18
|
+
* UTF-8 and parsed by the generated bounded parser, then validated as an
|
|
19
|
+
* envelope whose `operation` must match the call.
|
|
20
|
+
* - Transport retries reuse one idempotency key. A valid envelope is never
|
|
21
|
+
* retried, whatever its business result says: `failed`, `partial` and
|
|
22
|
+
* `unknown` are protocol successes, not transport errors.
|
|
14
23
|
*/
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new SyndrooApiError("Syndroo answered with a redirect. The SDK does not follow redirects " +
|
|
67
|
-
"because the Authorization header must never be forwarded to another " +
|
|
68
|
-
"location; point baseUrl at the final API origin instead.", {
|
|
69
|
-
code: "REDIRECT_NOT_FOLLOWED",
|
|
70
|
-
status: response.status,
|
|
71
|
-
requestMayHaveBeenApplied: false,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
const text = await readBody(response, config.maxResponseBytes, isWrite);
|
|
75
|
-
const preview = truncate(text, PREVIEW_LIMIT);
|
|
76
|
-
if (!response.ok) {
|
|
77
|
-
throw apiError(response, text, preview, isWrite);
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
status: response.status,
|
|
81
|
-
body: parseJson(text, response.status, preview, isWrite),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
catch (error) {
|
|
85
|
-
if (error instanceof SyndrooError) {
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
if (timedOut) {
|
|
89
|
-
throw new SyndrooTimeoutError(timeoutMessage(isWrite, timeoutMs), {
|
|
90
|
-
timeoutMs,
|
|
91
|
-
cause: error,
|
|
92
|
-
requestMayHaveBeenApplied: isWrite,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
const caller = callerAbort();
|
|
96
|
-
if (caller.aborted) {
|
|
97
|
-
throw new SyndrooAbortError(abortMessage(isWrite), {
|
|
98
|
-
cause: caller.reason ?? error,
|
|
99
|
-
requestMayHaveBeenApplied: isWrite,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
throw new SyndrooNetworkError(networkMessage(isWrite, error), {
|
|
103
|
-
cause: error,
|
|
104
|
-
networkCode: networkCodeOf(error),
|
|
105
|
-
requestMayHaveBeenApplied: isWrite,
|
|
106
|
-
});
|
|
24
|
+
import { SyndrooError } from "./errors.js";
|
|
25
|
+
import { parseStrictResponseJson } from "./generated/validation.js";
|
|
26
|
+
import { validateWire } from "./generated/validators.js";
|
|
27
|
+
/** Protocol and transport bounds. Changing one is an API decision. */
|
|
28
|
+
export const LIMITS = Object.freeze({
|
|
29
|
+
/** Largest request body the wire protocol accepts. */
|
|
30
|
+
maxRequestBytes: 65536,
|
|
31
|
+
/** Largest response body the SDK will buffer. */
|
|
32
|
+
maxResponseBytes: 1048576,
|
|
33
|
+
/** Default per-request deadline. */
|
|
34
|
+
defaultTimeoutMs: 30000,
|
|
35
|
+
/** Smallest accepted per-request deadline. */
|
|
36
|
+
minTimeoutMs: 1,
|
|
37
|
+
/** Largest per-request deadline the protocol allows. */
|
|
38
|
+
maxTimeoutMs: 120000,
|
|
39
|
+
/** Largest number of transport retries the protocol allows. */
|
|
40
|
+
maxTransportRetries: 2,
|
|
41
|
+
/** Longest accepted caller-supplied idempotency key. */
|
|
42
|
+
maxIdempotencyKeyLength: 128,
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Statuses retried at the transport layer only when no valid envelope came back.
|
|
46
|
+
*
|
|
47
|
+
* These are the classic "something between the client and the application
|
|
48
|
+
* failed" statuses: a gateway or a crashed worker answered while the Syndroo
|
|
49
|
+
* server never produced a protocol response. Retrying is safe because the
|
|
50
|
+
* request identity is reused, so an already-admitted operation is looked up
|
|
51
|
+
* instead of repeated. `429` is deliberately absent: rate limiting is an
|
|
52
|
+
* admission answer, not a transport failure.
|
|
53
|
+
*/
|
|
54
|
+
const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
|
|
55
|
+
/** Statuses that may carry an `ok: true` protocol envelope. */
|
|
56
|
+
const ACCEPTED_STATUS = new Set([200, 202]);
|
|
57
|
+
/**
|
|
58
|
+
* The blueprint's idempotency-key grammar: one to 128 characters from
|
|
59
|
+
* `[A-Za-z0-9._:-]`. Anything else (whitespace, punctuation, an over-long key)
|
|
60
|
+
* is rejected before a request is built.
|
|
61
|
+
*/
|
|
62
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
63
|
+
/** The loopback hosts that may be reached over plain HTTP. */
|
|
64
|
+
export function isLoopbackHostname(hostname) {
|
|
65
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
66
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (host === "::1") {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
73
|
+
if (ipv4 === null) {
|
|
74
|
+
return false;
|
|
107
75
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
76
|
+
const octets = ipv4.slice(1, 5).map((part) => Number.parseInt(part ?? "", 10));
|
|
77
|
+
if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) {
|
|
78
|
+
return false;
|
|
111
79
|
}
|
|
80
|
+
return octets[0] === 127;
|
|
112
81
|
}
|
|
113
82
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
83
|
+
* Validate a base URL, or throw a static `INSECURE_BASE_URL` error.
|
|
84
|
+
*
|
|
85
|
+
* Rejected: a non-absolute URL, a scheme other than HTTP or HTTPS, embedded
|
|
86
|
+
* credentials, a query, a fragment, and plain HTTP to anything that is not
|
|
87
|
+
* loopback.
|
|
116
88
|
*/
|
|
117
|
-
export function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (typeof unref === "function") {
|
|
121
|
-
unref.call(handle);
|
|
89
|
+
export function assertUsableBaseUrl(raw) {
|
|
90
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
91
|
+
throw new SyndrooError("INSECURE_BASE_URL");
|
|
122
92
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
93
|
+
let url;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(raw);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new SyndrooError("INSECURE_BASE_URL");
|
|
99
|
+
}
|
|
100
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
101
|
+
throw new SyndrooError("INSECURE_BASE_URL");
|
|
102
|
+
}
|
|
103
|
+
if (url.username !== "" || url.password !== "") {
|
|
104
|
+
throw new SyndrooError("INSECURE_BASE_URL");
|
|
105
|
+
}
|
|
106
|
+
if (url.hash !== "" || url.search !== "") {
|
|
107
|
+
throw new SyndrooError("INSECURE_BASE_URL");
|
|
108
|
+
}
|
|
109
|
+
if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
|
|
110
|
+
throw new SyndrooError("INSECURE_BASE_URL");
|
|
128
111
|
}
|
|
129
112
|
return url;
|
|
130
113
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
114
|
+
/** Validate the API key before any request is built. */
|
|
115
|
+
export function assertApiKey(apiKey) {
|
|
116
|
+
if (typeof apiKey !== "string" || apiKey.length === 0 || /[\r\n\u0000]/.test(apiKey)) {
|
|
117
|
+
throw new SyndrooError("INVALID_ARGUMENT");
|
|
134
118
|
}
|
|
135
|
-
return
|
|
119
|
+
return apiKey;
|
|
136
120
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
121
|
+
/** Validate a caller- or SDK-generated idempotency key. */
|
|
122
|
+
export function assertIdempotencyKey(key) {
|
|
123
|
+
if (typeof key !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(key)) {
|
|
124
|
+
throw new SyndrooError("INVALID_ARGUMENT");
|
|
141
125
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
126
|
+
return key;
|
|
127
|
+
}
|
|
128
|
+
function assertIntegerInRange(value, min, max) {
|
|
129
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
130
|
+
throw new SyndrooError("INVALID_ARGUMENT");
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
/** Resolve and bound the per-call transport options. */
|
|
135
|
+
export function resolveTransportOptions(options) {
|
|
136
|
+
const timeoutMs = options.timeoutMs === undefined
|
|
137
|
+
? LIMITS.defaultTimeoutMs
|
|
138
|
+
: assertIntegerInRange(options.timeoutMs, LIMITS.minTimeoutMs, LIMITS.maxTimeoutMs);
|
|
139
|
+
const transportRetries = options.transportRetries === undefined
|
|
140
|
+
? 0
|
|
141
|
+
: assertIntegerInRange(options.transportRetries, 0, LIMITS.maxTransportRetries);
|
|
142
|
+
return { timeoutMs, transportRetries };
|
|
143
|
+
}
|
|
144
|
+
/** Keep a pending timer from holding the process open, when the runtime allows it. */
|
|
145
|
+
export function unrefTimer(timer) {
|
|
146
|
+
if (timer !== null && typeof timer === "object" && "unref" in timer) {
|
|
147
|
+
timer.unref();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Read a response body with a hard byte cap instead of buffering blindly.
|
|
152
|
+
*
|
|
153
|
+
* A rejection path calls `onReject` after releasing the body, so the caller can
|
|
154
|
+
* abort the request too: leaving a rejected response's body unread keeps the
|
|
155
|
+
* socket alive and can hold the process open.
|
|
156
|
+
*/
|
|
157
|
+
async function readBoundedBody(response, limit, onReject) {
|
|
158
|
+
const declared = response.headers.get("content-length");
|
|
159
|
+
if (declared !== null) {
|
|
160
|
+
const declaredBytes = Number.parseInt(declared, 10);
|
|
161
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > limit) {
|
|
162
|
+
await cancelBody(response);
|
|
163
|
+
onReject();
|
|
164
|
+
throw new SyndrooError("RESPONSE_TOO_LARGE", { status: response.status });
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
const stream = response.body;
|
|
168
|
+
if (stream === null) {
|
|
169
|
+
return new Uint8Array(0);
|
|
165
170
|
}
|
|
166
|
-
const
|
|
171
|
+
const reader = stream.getReader();
|
|
172
|
+
const chunks = [];
|
|
173
|
+
let total = 0;
|
|
174
|
+
for (;;) {
|
|
175
|
+
const { done, value } = await reader.read();
|
|
176
|
+
if (done) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
if (value === undefined) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
total += value.byteLength;
|
|
183
|
+
if (total > limit) {
|
|
184
|
+
await reader.cancel();
|
|
185
|
+
onReject();
|
|
186
|
+
throw new SyndrooError("RESPONSE_TOO_LARGE", { status: response.status });
|
|
187
|
+
}
|
|
188
|
+
chunks.push(value);
|
|
189
|
+
}
|
|
190
|
+
const body = new Uint8Array(total);
|
|
167
191
|
let offset = 0;
|
|
168
192
|
for (const chunk of chunks) {
|
|
169
|
-
|
|
193
|
+
body.set(chunk, offset);
|
|
170
194
|
offset += chunk.byteLength;
|
|
171
195
|
}
|
|
172
|
-
return
|
|
196
|
+
return body;
|
|
173
197
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
198
|
+
/** Release a response body the SDK is not going to read. */
|
|
199
|
+
async function cancelBody(response) {
|
|
200
|
+
const body = response.body;
|
|
201
|
+
if (body === null) {
|
|
202
|
+
return;
|
|
177
203
|
}
|
|
178
204
|
try {
|
|
179
|
-
|
|
205
|
+
await body.cancel();
|
|
180
206
|
}
|
|
181
207
|
catch {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
preview,
|
|
185
|
-
requestMayHaveBeenApplied: mayHaveBeenApplied(status, isWrite),
|
|
186
|
-
});
|
|
208
|
+
// A body that is already errored or locked cannot be cancelled; the request
|
|
209
|
+
// abort that follows still releases the socket.
|
|
187
210
|
}
|
|
188
211
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
function errorEnvelope(text) {
|
|
200
|
-
if (text === "") {
|
|
201
|
-
return undefined;
|
|
212
|
+
/**
|
|
213
|
+
* Parse and validate one response envelope.
|
|
214
|
+
*
|
|
215
|
+
* Extra fields are tolerated (the generated response validator ignores them);
|
|
216
|
+
* an unknown enum value, a missing required field, invalid UTF-8, invalid JSON
|
|
217
|
+
* or a mismatched `operation` are all `malformed`.
|
|
218
|
+
*/
|
|
219
|
+
function readEnvelope(bytes, operation) {
|
|
220
|
+
if (bytes.byteLength === 0) {
|
|
221
|
+
return { kind: "malformed" };
|
|
202
222
|
}
|
|
203
223
|
let parsed;
|
|
204
224
|
try {
|
|
205
|
-
parsed =
|
|
225
|
+
parsed = parseStrictResponseJson(bytes);
|
|
206
226
|
}
|
|
207
227
|
catch {
|
|
208
|
-
return
|
|
228
|
+
return { kind: "malformed" };
|
|
209
229
|
}
|
|
210
|
-
|
|
211
|
-
|
|
230
|
+
try {
|
|
231
|
+
validateWire("Envelope", parsed, true);
|
|
212
232
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
233
|
+
catch {
|
|
234
|
+
return { kind: "malformed" };
|
|
235
|
+
}
|
|
236
|
+
const envelope = parsed;
|
|
237
|
+
if (envelope.operation !== operation) {
|
|
238
|
+
return { kind: "malformed" };
|
|
216
239
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const message = typeof record.message === "string" ? record.message : undefined;
|
|
220
|
-
if (code === undefined || message === undefined) {
|
|
221
|
-
return undefined;
|
|
240
|
+
if (envelope.ok) {
|
|
241
|
+
return { kind: "result", result: envelope.result };
|
|
222
242
|
}
|
|
223
|
-
return {
|
|
243
|
+
return { kind: "rejected", error: envelope.error };
|
|
224
244
|
}
|
|
225
245
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
246
|
+
* A transport bound to one base URL and one bearer secret.
|
|
247
|
+
*
|
|
248
|
+
* The class owns the retry loop, so a retry always reuses the same idempotency
|
|
249
|
+
* key: the key is part of `TransportRequest` and is never regenerated.
|
|
229
250
|
*/
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
251
|
+
export class HttpTransport {
|
|
252
|
+
baseUrl;
|
|
253
|
+
apiKey;
|
|
254
|
+
fetchImpl;
|
|
255
|
+
constructor(config) {
|
|
256
|
+
this.baseUrl = assertUsableBaseUrl(config.baseUrl);
|
|
257
|
+
this.apiKey = assertApiKey(config.apiKey);
|
|
258
|
+
this.fetchImpl = config.fetch;
|
|
259
|
+
}
|
|
260
|
+
/** The origin the transport will talk to; safe for tests and diagnostics. */
|
|
261
|
+
get endpointOrigin() {
|
|
262
|
+
return this.baseUrl.origin;
|
|
263
|
+
}
|
|
264
|
+
/** Validate the body, then send it, retrying only transport-level failures. */
|
|
265
|
+
async send(request) {
|
|
266
|
+
if (request.idempotencyKey !== undefined) {
|
|
267
|
+
assertIdempotencyKey(request.idempotencyKey);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
validateWire(request.wireName, request.body, false);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new SyndrooError("INVALID_REQUEST");
|
|
274
|
+
}
|
|
275
|
+
const url = this.buildUrl(request.operation);
|
|
276
|
+
const payload = JSON.stringify(request.body);
|
|
277
|
+
const attempts = request.transportRetries + 1;
|
|
278
|
+
let lastError;
|
|
279
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
280
|
+
try {
|
|
281
|
+
return await this.attempt(url, payload, request);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
const failure = error instanceof SyndrooError ? error : new SyndrooError("TRANSPORT");
|
|
285
|
+
if (!failure.retryable || attempt + 1 >= attempts) {
|
|
286
|
+
throw failure;
|
|
287
|
+
}
|
|
288
|
+
lastError = failure;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
throw lastError ?? new SyndrooError("TRANSPORT");
|
|
233
292
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
|
|
293
|
+
buildUrl(operation) {
|
|
294
|
+
const base = this.baseUrl.origin + this.baseUrl.pathname.replace(/\/+$/, "");
|
|
295
|
+
return `${base}/v1/${operation}`;
|
|
238
296
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
297
|
+
async attempt(url, payload, request) {
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
const external = request.signal;
|
|
300
|
+
if (external !== undefined && external.aborted) {
|
|
301
|
+
throw new SyndrooError("ABORTED");
|
|
302
|
+
}
|
|
303
|
+
const onExternalAbort = () => controller.abort();
|
|
304
|
+
external?.addEventListener("abort", onExternalAbort, { once: true });
|
|
305
|
+
let deadlineElapsed = false;
|
|
306
|
+
const timer = setTimeout(() => {
|
|
307
|
+
deadlineElapsed = true;
|
|
308
|
+
controller.abort();
|
|
309
|
+
}, request.timeoutMs);
|
|
310
|
+
unrefTimer(timer);
|
|
311
|
+
try {
|
|
312
|
+
const response = await this.fetchImpl(url, {
|
|
313
|
+
method: "POST",
|
|
314
|
+
headers: buildHeaders(this.apiKey, request.idempotencyKey),
|
|
315
|
+
body: payload,
|
|
316
|
+
redirect: "manual",
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
});
|
|
319
|
+
if (response.status >= 300 && response.status < 400) {
|
|
320
|
+
await cancelBody(response);
|
|
321
|
+
controller.abort();
|
|
322
|
+
throw new SyndrooError("REDIRECT_NOT_ALLOWED", { status: response.status });
|
|
323
|
+
}
|
|
324
|
+
const bytes = await readBoundedBody(response, LIMITS.maxResponseBytes, () => controller.abort());
|
|
325
|
+
return unwrapEnvelope(readEnvelope(bytes, request.operation), response.status, request.operation);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (error instanceof SyndrooError) {
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
throw classifyThrown(external, deadlineElapsed);
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
external?.removeEventListener("abort", onExternalAbort);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function buildHeaders(apiKey, idempotencyKey) {
|
|
340
|
+
const headers = {
|
|
341
|
+
accept: "application/json",
|
|
342
|
+
"content-type": "application/json",
|
|
343
|
+
authorization: `Bearer ${apiKey}`,
|
|
344
|
+
};
|
|
345
|
+
if (idempotencyKey !== undefined) {
|
|
346
|
+
headers["idempotency-key"] = idempotencyKey;
|
|
242
347
|
}
|
|
243
|
-
return
|
|
348
|
+
return headers;
|
|
244
349
|
}
|
|
245
350
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* is
|
|
351
|
+
* Turn a validated envelope into a value or a static error.
|
|
352
|
+
*
|
|
353
|
+
* `ok: false` is an authoritative protocol answer, so it is never retried: the
|
|
354
|
+
* server already did whatever it is going to do. A non-envelope 2xx is a
|
|
355
|
+
* protocol violation; a non-envelope error status is an HTTP failure, retryable
|
|
356
|
+
* only for the statuses that mean "the application never answered".
|
|
357
|
+
*
|
|
358
|
+
* `202` is reserved by the protocol for an already durably admitted execution
|
|
359
|
+
* that has not reached a terminal status. It is a correlation, not a generic
|
|
360
|
+
* "accepted": a status query, a connect step, a prepared result or a terminal
|
|
361
|
+
* execution answered with `202` is a protocol violation.
|
|
249
362
|
*/
|
|
250
|
-
function
|
|
251
|
-
if (
|
|
252
|
-
|
|
363
|
+
function unwrapEnvelope(outcome, status, operation) {
|
|
364
|
+
if (outcome.kind === "result") {
|
|
365
|
+
if (!ACCEPTED_STATUS.has(status)) {
|
|
366
|
+
throw new SyndrooError("INVALID_RESPONSE", { status });
|
|
367
|
+
}
|
|
368
|
+
if (status === 202 && !isAdmittedNonterminalExecution(operation, outcome.result)) {
|
|
369
|
+
throw new SyndrooError("INVALID_RESPONSE", { status });
|
|
370
|
+
}
|
|
371
|
+
return outcome.result;
|
|
253
372
|
}
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
"created; retrying is safe.");
|
|
260
|
-
}
|
|
261
|
-
return (`Request timed out after ${timeoutMs}ms. Syndroo may still have accepted the ` +
|
|
262
|
-
"post: do not resend it with a new Idempotency-Key. Look the post up with " +
|
|
263
|
-
"posts.list, or retry the identical request with the same key.");
|
|
264
|
-
}
|
|
265
|
-
function abortMessage(isWrite) {
|
|
266
|
-
if (!isWrite) {
|
|
267
|
-
return "The request was aborted after it was sent. This was a read, so nothing was created.";
|
|
373
|
+
if (outcome.kind === "rejected") {
|
|
374
|
+
throw new SyndrooError("PROTOCOL", { status, serverError: outcome.error });
|
|
375
|
+
}
|
|
376
|
+
if (ACCEPTED_STATUS.has(status)) {
|
|
377
|
+
throw new SyndrooError("INVALID_RESPONSE", { status });
|
|
268
378
|
}
|
|
269
|
-
|
|
270
|
-
"accepted the post: do not resend it with a new Idempotency-Key. Look the " +
|
|
271
|
-
"post up with posts.list, or retry the identical request with the same key.");
|
|
379
|
+
throw new SyndrooError("HTTP_ERROR", { status, retryable: RETRYABLE_STATUS.has(status) });
|
|
272
380
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return (
|
|
280
|
-
|
|
281
|
-
"Idempotency-Key. Look the post up with posts.list, or retry the identical " +
|
|
282
|
-
"request with the same key.");
|
|
381
|
+
/** True for the only result a `202` may carry: admitted, non-terminal execution. */
|
|
382
|
+
function isAdmittedNonterminalExecution(operation, result) {
|
|
383
|
+
if (operation !== "publish" || result === null || typeof result !== "object") {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const candidate = result;
|
|
387
|
+
return (candidate.phase === "execution" &&
|
|
388
|
+
(candidate.status === "pending" || candidate.status === "running"));
|
|
283
389
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (typeof code === "string" && code !== "") {
|
|
292
|
-
return truncate(code, 40);
|
|
293
|
-
}
|
|
294
|
-
current = current.cause;
|
|
390
|
+
/** Classify a thrown fetch or stream error without echoing it. */
|
|
391
|
+
function classifyThrown(external, deadlineElapsed) {
|
|
392
|
+
if (external?.aborted === true) {
|
|
393
|
+
return new SyndrooError("ABORTED");
|
|
394
|
+
}
|
|
395
|
+
if (deadlineElapsed) {
|
|
396
|
+
return new SyndrooError("TIMEOUT", { retryable: true });
|
|
295
397
|
}
|
|
296
|
-
return
|
|
398
|
+
return new SyndrooError("TRANSPORT", { retryable: true });
|
|
297
399
|
}
|
|
298
400
|
//# sourceMappingURL=http.js.map
|