@north-light/api 0.3.559 → 0.3.560

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 (2) hide show
  1. package/dist/api/client.js +34 -0
  2. package/package.json +1 -1
@@ -908,6 +908,8 @@ export class CrtrClient {
908
908
  const opts = this.resolveBudget(callOpts);
909
909
  const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
910
910
  const maxRetries = opts.maxRetries ?? this.maxRetries;
911
+ // A hosted runtime being created or replaced answers reads with a refusal that never reached it; wait for it inside this allowance.
912
+ const replacementWaitEndsAt = Math.min(opts.deadlineAt ?? Infinity, Date.now() + HOSTED_REPLACEMENT_WAIT_MS);
911
913
  for (let attempt = 0;; attempt++) {
912
914
  let response;
913
915
  try {
@@ -942,6 +944,15 @@ export class CrtrClient {
942
944
  throw toTransportApiError(err, opts.signal);
943
945
  }
944
946
  }
947
+ if (isRead(method) && maxRetries > 0 && response.status === 503 && await isRefusedBeforeForwarding(response, opts.deadlineAt)) {
948
+ const delayMs = Math.max(250, (retryAfterHeaderS(response) ?? 0) * 1_000) + Math.random() * 250;
949
+ // Out of allowance: the refusal itself is the answer, so the caller sees its cause.
950
+ if (Date.now() + delayMs >= replacementWaitEndsAt)
951
+ return response;
952
+ await drainResponseBody(response, opts.deadlineAt);
953
+ await sleepWithin(delayMs, { signal: opts.signal });
954
+ continue;
955
+ }
945
956
  if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
946
957
  await drainResponseBody(response, opts.deadlineAt);
947
958
  await sleepWithin(retryDelayMs(attempt + 1), opts, retryAfterHeaderS(response));
@@ -1266,6 +1277,29 @@ async function parse(res, deadlineAt) {
1266
1277
  }
1267
1278
  throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
1268
1279
  }
1280
+ /** How long a read waits for a hosted runtime that is being created or replaced. */
1281
+ const HOSTED_REPLACEMENT_WAIT_MS = 60_000;
1282
+ /** Only reads are replayed after a refusal; a write is never retried on this state. */
1283
+ function isRead(method) {
1284
+ return method === 'GET' || method === 'HEAD';
1285
+ }
1286
+ /** True when the response is the hosting router's 503 for a runtime that provisioning is creating or replacing
1287
+ * (`details.forwarded === false`): the request reached no runtime, so replaying it cannot run anything twice.
1288
+ * Reads a clone, so the caller still gets the body of the response it is handed. */
1289
+ async function isRefusedBeforeForwarding(response, deadlineAt) {
1290
+ try {
1291
+ // A router refusal is a few hundred bytes; anything declared larger, or a body that stalls, is not one.
1292
+ if (Number(response.headers.get('content-length') ?? 0) > 8_192)
1293
+ return false;
1294
+ const text = await readTextWithin(response.clone(), Math.min(deadlineAt ?? Infinity, Date.now() + 5_000));
1295
+ const error = JSON.parse(text).error;
1296
+ return error?.origin === 'router' && error.details?.forwarded === false
1297
+ && (error.details.runtime_state === 'creating' || error.details.runtime_state === 'recovering');
1298
+ }
1299
+ catch {
1300
+ return false;
1301
+ }
1302
+ }
1269
1303
  /** `429`/`5xx` are the only statuses the §7 retry policy replays. */
1270
1304
  function isRetryableStatus(status) {
1271
1305
  return status === 429 || status >= 500;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.559",
3
+ "version": "0.3.560",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",