run402 4.109.0 → 4.109.1

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.
@@ -26,6 +26,7 @@ import { assertCiDeployableSpec } from "./ci.js";
26
26
  import { ROUTE_PRICING_NETWORKS, ROUTE_HTTP_METHODS, isModernPlanMigrationDiff, normalizeDeployResolveRequest, } from "./deploy.types.js";
27
27
  import { ApiError, LocalError, NetworkError, PaymentRequired, Run402DeployError, Unauthorized, isRun402Error, isTransferFreezeError, } from "../errors.js";
28
28
  import { assertAssetMetadata, assertExifPolicy, } from "./assets-validation.js";
29
+ import { withSiteRemovalSummaries } from "./site-removal-summary.js";
29
30
  // ─── Constants ───────────────────────────────────────────────────────────────
30
31
  const PLAN_BODY_LIMIT_BYTES = 5 * 1024 * 1024;
31
32
  const COMMIT_POLL_INITIAL_MS = 1_000;
@@ -45,6 +46,25 @@ const SECRET_KEY_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
45
46
  const APPLY_SAFE_RETRY_CODES = new Set([
46
47
  "BASE_RELEASE_CONFLICT",
47
48
  ]);
49
+ /**
50
+ * Transient platform conditions the gateway marks `safe_to_retry: true`: the
51
+ * task running the deploy was replaced mid-commit, an image encode ran out of
52
+ * wall clock or queue under load, or an archive export briefly paused writes.
53
+ * Nothing was activated, so the whole apply re-plans and runs again — content
54
+ * is addressed by hash, so already-uploaded bytes are not sent twice
55
+ * (kychee-com/run402#628, #616). These wait longer than a release-race replan.
56
+ */
57
+ const APPLY_TRANSIENT_RETRY_CODES = new Set([
58
+ "OPERATION_INTERRUPTED",
59
+ "IMAGE_ENCODE_TIMEOUT",
60
+ "TOO_MANY_ENCODES_QUEUED",
61
+ "EXPORT_CONSISTENCY_UNAVAILABLE",
62
+ ]);
63
+ const APPLY_TRANSIENT_RETRY_BASE_DELAY_MS = 2_000;
64
+ const APPLY_TRANSIENT_RETRY_MAX_DELAY_MS = 15_000;
65
+ /** A read or an idempotent request lost to a 502/503/504 or a dropped connection. */
66
+ const TRANSIENT_REQUEST_MAX_ATTEMPTS = 3;
67
+ const TRANSIENT_REQUEST_RETRY_BASE_DELAY_MS = 500;
48
68
  const EMAIL_TRIGGER_EVENTS = new Set(["reply_received", "delivery", "bounced", "complained", "mailbox_suspended"]);
49
69
  const STATIC_ACTIVATION_FAILURE_CODES = new Set([
50
70
  "BAD_FIELD",
@@ -111,29 +131,31 @@ export class Deploy {
111
131
  return await applyOnce(this.client, spec, opts, emit);
112
132
  }
113
133
  catch (err) {
114
- if (!(err instanceof Run402DeployError))
134
+ if (!isRun402Error(err))
115
135
  throw err;
116
136
  const safeToRetry = isSafeDeployApplyRetry(err, spec);
117
137
  if (!safeToRetry)
118
138
  throw err;
119
139
  if (attempt === maxAttempts) {
120
- if (maxRetries > 0) {
140
+ if (maxRetries > 0 && err instanceof Run402DeployError) {
121
141
  throw withDeployRetryMetadata(err, attempt, maxRetries, err.code);
122
142
  }
123
143
  throw err;
124
144
  }
125
- const delayMs = deployApplyRetryDelayMs(attempt);
145
+ const code = err.code ?? "UNKNOWN";
146
+ const delayMs = deployApplyRetryDelayMs(attempt, code);
147
+ const deployErr = err instanceof Run402DeployError ? err : null;
126
148
  emit({
127
149
  type: "deploy.retry",
128
150
  attempt,
129
151
  nextAttempt: attempt + 1,
130
152
  maxAttempts,
131
153
  delayMs,
132
- code: err.code,
133
- phase: err.phase,
134
- resource: err.resource,
135
- operationId: err.operationId,
136
- planId: err.planId,
154
+ code,
155
+ phase: deployErr?.phase ?? null,
156
+ resource: deployErr?.resource ?? null,
157
+ operationId: deployErr?.operationId ?? null,
158
+ planId: deployErr?.planId ?? null,
137
159
  message: err.message,
138
160
  });
139
161
  await sleep(delayMs);
@@ -842,7 +864,14 @@ function normalizeApplyMaxRetries(value) {
842
864
  return value;
843
865
  }
844
866
  function isSafeDeployApplyRetry(err, spec) {
845
- return (err.safeToRetry === true &&
867
+ if (err.safeToRetry !== true || typeof err.code !== "string")
868
+ return false;
869
+ // Transient codes reach here from the deploy state machine (a failed
870
+ // operation) or as a plain API error from an upload-phase request. Nothing
871
+ // was activated either way, so the spec's base still holds.
872
+ if (APPLY_TRANSIENT_RETRY_CODES.has(err.code))
873
+ return true;
874
+ return (err instanceof Run402DeployError &&
846
875
  APPLY_SAFE_RETRY_CODES.has(err.code) &&
847
876
  isAutoRebasableSpec(spec));
848
877
  }
@@ -851,9 +880,11 @@ function isAutoRebasableSpec(spec) {
851
880
  return true;
852
881
  return "release" in spec.base && spec.base.release === "current";
853
882
  }
854
- function deployApplyRetryDelayMs(attempt) {
855
- const exponential = APPLY_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
856
- const capped = Math.min(exponential, APPLY_RETRY_MAX_DELAY_MS);
883
+ function deployApplyRetryDelayMs(attempt, code) {
884
+ const transient = APPLY_TRANSIENT_RETRY_CODES.has(code);
885
+ const base = transient ? APPLY_TRANSIENT_RETRY_BASE_DELAY_MS : APPLY_RETRY_BASE_DELAY_MS;
886
+ const max = transient ? APPLY_TRANSIENT_RETRY_MAX_DELAY_MS : APPLY_RETRY_MAX_DELAY_MS;
887
+ const capped = Math.min(base * 2 ** (attempt - 1), max);
857
888
  return capped + Math.floor(Math.random() * (APPLY_RETRY_JITTER_MS + 1));
858
889
  }
859
890
  function withDeployRetryMetadata(err, attempts, maxRetries, lastRetryCode) {
@@ -1599,6 +1630,7 @@ function expandCronNumberPart(part, min, max) {
1599
1630
  return values;
1600
1631
  }
1601
1632
  async function commitInternal(client, planId, idempotencyKey, project, requiredPlan, vault, operationId) {
1633
+ const tries = { attempts: 0 };
1602
1634
  try {
1603
1635
  const body = {};
1604
1636
  if (idempotencyKey)
@@ -1622,10 +1654,16 @@ async function commitInternal(client, planId, idempotencyKey, project, requiredP
1622
1654
  : {}),
1623
1655
  context: "committing deploy",
1624
1656
  });
1625
- return await sendCommitRecoveringLostResponse(client, send, operationId, project);
1657
+ return await sendCommitRecoveringLostResponse(client, send, operationId, project, tries);
1626
1658
  }
1627
1659
  catch (err) {
1628
- throw translateDeployError(err, "commit", planId, null);
1660
+ const translated = translateDeployError(err, "commit", planId, operationId ?? null);
1661
+ // Retries were spent on a lost response: say how many and on what, so a
1662
+ // caller can tell "the SDK already tried" from "the first attempt failed".
1663
+ if (tries.attempts > 1) {
1664
+ throw withDeployRetryMetadata(translated, tries.attempts, DEPLOY_COMMIT_MAX_ATTEMPTS - 1, translated.code);
1665
+ }
1666
+ throw translated;
1629
1667
  }
1630
1668
  }
1631
1669
  /**
@@ -1636,9 +1674,11 @@ async function commitInternal(client, planId, idempotencyKey, project, requiredP
1636
1674
  * operation first: once it has left `planning`/`uploading` the commit landed,
1637
1675
  * and the caller follows the operation; otherwise the commit never arrived and
1638
1676
  * is sent again. Without an operation id to read, the failure surfaces as is.
1677
+ * `tries.attempts` counts the commits sent, for the error's retry metadata.
1639
1678
  */
1640
- async function sendCommitRecoveringLostResponse(client, send, operationId, projectId) {
1679
+ async function sendCommitRecoveringLostResponse(client, send, operationId, projectId, tries) {
1641
1680
  for (let attempt = 1;; attempt++) {
1681
+ tries.attempts = attempt;
1642
1682
  try {
1643
1683
  return await send();
1644
1684
  }
@@ -1692,14 +1732,16 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
1692
1732
  content_type: reader?.contentType,
1693
1733
  };
1694
1734
  });
1695
- const planRes = await client.request("/content/v1/plans", {
1735
+ // A lost content plan is safe to repeat: it only opens upload sessions, and
1736
+ // an abandoned one expires unused.
1737
+ const planRes = await requestWithTransientRetry(() => client.request("/content/v1/plans", {
1696
1738
  method: "POST",
1697
1739
  headers,
1698
1740
  body: ciCredentials
1699
1741
  ? { project_id: projectId, content: contentRequest }
1700
1742
  : { content: contentRequest },
1701
1743
  context: "planning content upload",
1702
- });
1744
+ }));
1703
1745
  const total = planRes.missing.length;
1704
1746
  let done = 0;
1705
1747
  for (const session of planRes.missing) {
@@ -1782,6 +1824,19 @@ async function commitContentPlanWithRetry(client, planId, headers) {
1782
1824
  }
1783
1825
  }
1784
1826
  }
1827
+ /** Repeat a read or an idempotent request lost to a 502/503/504 or a dropped connection. */
1828
+ async function requestWithTransientRetry(send) {
1829
+ for (let attempt = 1;; attempt++) {
1830
+ try {
1831
+ return await send();
1832
+ }
1833
+ catch (err) {
1834
+ if (!isTransientGatewayFailure(err) || attempt >= TRANSIENT_REQUEST_MAX_ATTEMPTS)
1835
+ throw err;
1836
+ await sleep(TRANSIENT_REQUEST_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
1837
+ }
1838
+ }
1839
+ }
1785
1840
  function isTransientGatewayFailure(err) {
1786
1841
  if (!isRun402Error(err))
1787
1842
  return false;
@@ -2068,7 +2123,10 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
2068
2123
  if (Date.now() - start > COMMIT_POLL_BACKOFF_AFTER_MS) {
2069
2124
  interval = Math.min(Math.floor(interval * 1.5), COMMIT_POLL_MAX_MS);
2070
2125
  }
2071
- snapshot = await client.request(`/apply/v1/operations/${encodeURIComponent(snapshot.operation_id)}`, { headers: opHeaders, context: "polling deploy operation" });
2126
+ // A poll lost to a 502/503/504 (a gateway rollout) says nothing about the
2127
+ // operation; read it again rather than abandon a deploy that is running.
2128
+ const operationId = snapshot.operation_id;
2129
+ snapshot = await requestWithTransientRetry(() => client.request(`/apply/v1/operations/${encodeURIComponent(operationId)}`, { headers: opHeaders, context: "polling deploy operation" }));
2072
2130
  }
2073
2131
  }
2074
2132
  async function drainRecordedOperationEvents(client, operationId, headers, cursor, emit) {
@@ -3113,7 +3171,7 @@ function invalidRouteSpec(message, resource) {
3113
3171
  }
3114
3172
  function normalizePlanResponse(plan) {
3115
3173
  const raw = plan;
3116
- const warnings = Array.isArray(raw.warnings) ? raw.warnings : [];
3174
+ const warnings = withSiteRemovalSummaries(Array.isArray(raw.warnings) ? raw.warnings : []);
3117
3175
  const missingContent = Array.isArray(raw.missing_content) ? raw.missing_content : [];
3118
3176
  if (raw.kind === "plan_response") {
3119
3177
  const diff = {
@@ -4106,14 +4164,14 @@ function makeUnreadableReader(sha256, label) {
4106
4164
  async function uploadInlineCas(client, projectId, bytes, contentType) {
4107
4165
  const sha256 = await sha256Hex(bytes);
4108
4166
  const headers = await apikeyHeaders(client, projectId);
4109
- const planRes = await client.request("/content/v1/plans", {
4167
+ const planRes = await requestWithTransientRetry(() => client.request("/content/v1/plans", {
4110
4168
  method: "POST",
4111
4169
  headers,
4112
4170
  body: {
4113
4171
  content: [{ sha256, size: bytes.byteLength, content_type: contentType }],
4114
4172
  },
4115
4173
  context: "planning content upload",
4116
- });
4174
+ }));
4117
4175
  if (planRes.missing.length > 0) {
4118
4176
  const session = planRes.missing[0];
4119
4177
  await uploadOne(client.fetch, session, bytes);