@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.
Files changed (42) hide show
  1. package/README.md +83 -136
  2. package/dist/client.d.ts +30 -102
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +120 -325
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +48 -93
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +131 -137
  9. package/dist/errors.js.map +1 -1
  10. package/dist/generated/compiled.d.ts +182 -0
  11. package/dist/generated/compiled.d.ts.map +1 -0
  12. package/dist/generated/compiled.js +25761 -0
  13. package/dist/generated/compiled.js.map +1 -0
  14. package/dist/generated/types.d.ts +387 -0
  15. package/dist/generated/types.d.ts.map +1 -0
  16. package/dist/generated/types.js +2 -0
  17. package/dist/generated/types.js.map +1 -0
  18. package/dist/generated/validation.d.ts +18 -0
  19. package/dist/generated/validation.d.ts.map +1 -0
  20. package/dist/generated/validation.js +184 -0
  21. package/dist/generated/validation.js.map +1 -0
  22. package/dist/generated/validators.d.ts +3 -0
  23. package/dist/generated/validators.d.ts.map +1 -0
  24. package/dist/generated/validators.js +10 -0
  25. package/dist/generated/validators.js.map +1 -0
  26. package/dist/http.d.ts +91 -35
  27. package/dist/http.d.ts.map +1 -1
  28. package/dist/http.js +345 -243
  29. package/dist/http.js.map +1 -1
  30. package/dist/index.d.ts +8 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -5
  33. package/dist/index.js.map +1 -1
  34. package/dist/types.d.ts +90 -83
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js +16 -155
  37. package/dist/types.js.map +1 -1
  38. package/dist/wait.d.ts +65 -0
  39. package/dist/wait.d.ts.map +1 -0
  40. package/dist/wait.js +192 -0
  41. package/dist/wait.js.map +1 -0
  42. package/package.json +6 -3
package/dist/http.js CHANGED
@@ -1,298 +1,400 @@
1
1
  /**
2
- * The single HTTP transport used by the SDK.
2
+ * HTTP transport for `@syndroo/sdk`.
3
3
  *
4
- * Invariants this module enforces for every request:
4
+ * Scope, deliberately narrow:
5
5
  *
6
- * - exactly one network request per call; the SDK never retries a write,
7
- * because a retry without a stable idempotency key can duplicate a post;
8
- * - redirects are not followed, so the `Authorization` header can never reach
9
- * a redirect target;
10
- * - every request is bounded by a finite deadline;
11
- * - the response body is read with a size limit, so an oversized or hostile
12
- * response cannot exhaust the caller's memory;
13
- * - no logging, so credentials and content never reach stdout or stderr.
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 { SyndrooAbortError, SyndrooApiError, SyndrooError, SyndrooNetworkError, SyndrooResponseError, SyndrooTimeoutError, truncate, } from "./errors.js";
16
- export const DEFAULT_TIMEOUT_MS = 30_000;
17
- export const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
18
- export const MIN_MAX_RESPONSE_BYTES = 1024;
19
- const PREVIEW_LIMIT = 200;
20
- const MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1000;
21
- export async function sendRequest(config, request) {
22
- const url = buildUrl(config.baseUrl, request.path, request.query);
23
- const timeoutMs = request.timeoutMs ?? config.timeoutMs;
24
- const isWrite = request.method !== "GET";
25
- const userSignal = request.signal;
26
- if (userSignal?.aborted === true) {
27
- throw new SyndrooAbortError("The request was aborted before it was sent, so nothing reached Syndroo.", { cause: userSignal.reason, requestMayHaveBeenApplied: false });
28
- }
29
- // Read through a call so TypeScript's narrowing from the check above does not
30
- // make the later failure classification unreachable.
31
- const callerAbort = () => ({
32
- aborted: userSignal?.aborted === true,
33
- reason: userSignal?.reason,
34
- });
35
- const headers = new Headers({ accept: "application/json" });
36
- if (request.authenticated !== false) {
37
- headers.set("authorization", `Bearer ${config.apiKey}`);
38
- }
39
- if (request.idempotencyKey !== undefined) {
40
- headers.set("idempotency-key", request.idempotencyKey);
41
- }
42
- const init = {
43
- method: request.method,
44
- headers,
45
- redirect: "manual",
46
- };
47
- if (request.json !== undefined) {
48
- headers.set("content-type", "application/json");
49
- init.body = JSON.stringify(request.json);
50
- }
51
- const controller = new AbortController();
52
- let timedOut = false;
53
- const handle = setTimeout(() => {
54
- timedOut = true;
55
- controller.abort();
56
- }, timeoutMs);
57
- unrefTimer(handle);
58
- init.signal = controller.signal;
59
- const forwardAbort = () => {
60
- controller.abort();
61
- };
62
- userSignal?.addEventListener("abort", forwardAbort, { once: true });
63
- try {
64
- const response = await globalThis.fetch(url, init);
65
- if (isRedirectResponse(response)) {
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
- finally {
109
- clearTimeout(handle);
110
- userSignal?.removeEventListener("abort", forwardAbort);
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
- * Node keeps the process alive for a pending timer. An SDK call must not pin a
115
- * CLI process open after its response or its deadline.
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 unrefTimer(handle) {
118
- const candidate = handle;
119
- const unref = candidate.unref;
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
- function buildUrl(baseUrl, path, query) {
125
- const url = new URL(`${baseUrl}${path}`);
126
- for (const [key, value] of Object.entries(query ?? {})) {
127
- url.searchParams.set(key, value);
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
- function isRedirectResponse(response) {
132
- if (response.type === "opaqueredirect") {
133
- return true;
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 response.status >= 300 && response.status < 400;
119
+ return apiKey;
136
120
  }
137
- async function readBody(response, maxBytes, isWrite) {
138
- const body = response.body;
139
- if (body === null) {
140
- return "";
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
- const reader = body.getReader();
143
- const chunks = [];
144
- let length = 0;
145
- try {
146
- for (;;) {
147
- const { done, value } = await reader.read();
148
- if (done) {
149
- break;
150
- }
151
- length += value.byteLength;
152
- if (length > maxBytes) {
153
- await reader.cancel("Response exceeded the SDK response size limit").catch(() => undefined);
154
- throw new SyndrooResponseError(`Syndroo returned a response larger than the configured limit of ${maxBytes} bytes. ` +
155
- "Raise maxResponseBytes only for a trusted deployment.", {
156
- status: response.status,
157
- requestMayHaveBeenApplied: mayHaveBeenApplied(response.status, isWrite),
158
- });
159
- }
160
- chunks.push(value);
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
- finally {
164
- reader.releaseLock();
167
+ const stream = response.body;
168
+ if (stream === null) {
169
+ return new Uint8Array(0);
165
170
  }
166
- const bytes = new Uint8Array(length);
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
- bytes.set(chunk, offset);
193
+ body.set(chunk, offset);
170
194
  offset += chunk.byteLength;
171
195
  }
172
- return new TextDecoder().decode(bytes);
196
+ return body;
173
197
  }
174
- function parseJson(text, status, preview, isWrite) {
175
- if (text === "") {
176
- return undefined;
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
- return JSON.parse(text);
205
+ await body.cancel();
180
206
  }
181
207
  catch {
182
- throw new SyndrooResponseError(`Syndroo returned HTTP ${status} with a body that is not JSON: ${preview}`, {
183
- status,
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
- function apiError(response, text, preview, isWrite) {
190
- const envelope = errorEnvelope(text);
191
- return new SyndrooApiError(envelope?.message ??
192
- `Syndroo returned HTTP ${response.status} without a documented error body: ${preview}`, {
193
- code: envelope?.code ?? `HTTP_${response.status}`,
194
- status: response.status,
195
- requestMayHaveBeenApplied: mayHaveBeenApplied(response.status, isWrite),
196
- retryAfterMs: retryAfterMs(response.headers.get("retry-after")),
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 = JSON.parse(text);
225
+ parsed = parseStrictResponseJson(bytes);
206
226
  }
207
227
  catch {
208
- return undefined;
228
+ return { kind: "malformed" };
209
229
  }
210
- if (typeof parsed !== "object" || parsed === null) {
211
- return undefined;
230
+ try {
231
+ validateWire("Envelope", parsed, true);
212
232
  }
213
- const error = parsed.error;
214
- if (typeof error !== "object" || error === null) {
215
- return undefined;
233
+ catch {
234
+ return { kind: "malformed" };
235
+ }
236
+ const envelope = parsed;
237
+ if (envelope.operation !== operation) {
238
+ return { kind: "malformed" };
216
239
  }
217
- const record = error;
218
- const code = typeof record.code === "string" ? record.code : undefined;
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 { code, message: truncate(message, PREVIEW_LIMIT) };
243
+ return { kind: "rejected", error: envelope.error };
224
244
  }
225
245
  /**
226
- * `Retry-After` is either a number of seconds or an HTTP date. Both are
227
- * converted to a bounded delay so a caller can honor backpressure without
228
- * trusting an attacker-controlled header to park the process for days.
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
- function retryAfterMs(header) {
231
- if (header === null) {
232
- return undefined;
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
- const trimmed = header.trim();
235
- const seconds = Number(trimmed);
236
- if (trimmed !== "" && Number.isFinite(seconds) && seconds >= 0) {
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
- const date = Date.parse(trimmed);
240
- if (Number.isNaN(date)) {
241
- return undefined;
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 Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
348
+ return headers;
244
349
  }
245
350
  /**
246
- * A write may have been applied when Syndroo accepted it (2xx) or when the
247
- * failure happened at or after the server's processing boundary (5xx). A 4xx
248
- * is a decision, so nothing was applied.
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 mayHaveBeenApplied(status, isWrite) {
251
- if (!isWrite) {
252
- return false;
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
- return status >= 500 || (status >= 200 && status < 300);
255
- }
256
- function timeoutMessage(isWrite, timeoutMs) {
257
- if (!isWrite) {
258
- return (`Request timed out after ${timeoutMs}ms. This was a read, so nothing was ` +
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
- return ("The request was aborted after it was sent, so Syndroo may still have " +
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
- function networkMessage(isWrite, error) {
274
- const code = networkCodeOf(error);
275
- const suffix = code === undefined ? "" : ` (${code})`;
276
- if (!isWrite) {
277
- return `The request failed before a response was received${suffix}.`;
278
- }
279
- return (`The request failed before a response was received${suffix}. Syndroo may ` +
280
- "still have accepted the post: do not resend it with a new " +
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
- function networkCodeOf(error) {
285
- let current = error;
286
- for (let depth = 0; depth < 4; depth += 1) {
287
- if (typeof current !== "object" || current === null) {
288
- return undefined;
289
- }
290
- const code = current.code;
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 undefined;
398
+ return new SyndrooError("TRANSPORT", { retryable: true });
297
399
  }
298
400
  //# sourceMappingURL=http.js.map