run402 4.109.0 → 4.110.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.
@@ -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) {
@@ -1072,22 +1103,26 @@ function siteToCoreSpec(site) {
1072
1103
  ...(site.public_paths ? { public_paths: site.public_paths } : {}),
1073
1104
  };
1074
1105
  }
1075
- // tenant-site-embedding and site.sign_in_path never reach core: both are
1076
- // gateway-owned and core validates site keys against its own list.
1106
+ // tenant-site-embedding, site.sign_in_path, and site.noindex never reach
1107
+ // core: all three are gateway-owned and core validates site keys against
1108
+ // its own list.
1077
1109
  return site.public_paths ? { public_paths: site.public_paths } : {};
1078
1110
  }
1079
1111
  function siteToWire(site) {
1080
1112
  // tenant-site-embedding: `null` is a meaningful value on the wire (clear), so
1081
1113
  // it is carried whenever the key is present, unlike public_paths.
1082
- // site.sign_in_path follows the same rule (`null` clears back to the hosted page).
1114
+ // site.sign_in_path follows the same rule (`null` clears back to the hosted page),
1115
+ // and so does site.noindex (`false` or `null` clears back to indexable).
1083
1116
  const embedding = "embedding" in site && site.embedding !== undefined ? { embedding: site.embedding } : {};
1084
1117
  const signInPath = "sign_in_path" in site && site.sign_in_path !== undefined ? { sign_in_path: site.sign_in_path } : {};
1118
+ const noindex = "noindex" in site && site.noindex !== undefined ? { noindex: site.noindex } : {};
1085
1119
  if ("replace" in site && site.replace) {
1086
1120
  return {
1087
1121
  replace: fileSetToWire(site.replace),
1088
1122
  ...(site.public_paths ? { public_paths: site.public_paths } : {}),
1089
1123
  ...embedding,
1090
1124
  ...signInPath,
1125
+ ...noindex,
1091
1126
  };
1092
1127
  }
1093
1128
  if ("patch" in site && site.patch) {
@@ -1099,12 +1134,14 @@ function siteToWire(site) {
1099
1134
  ...(site.public_paths ? { public_paths: site.public_paths } : {}),
1100
1135
  ...embedding,
1101
1136
  ...signInPath,
1137
+ ...noindex,
1102
1138
  };
1103
1139
  }
1104
1140
  return {
1105
1141
  ...(site.public_paths ? { public_paths: site.public_paths } : {}),
1106
1142
  ...embedding,
1107
1143
  ...signInPath,
1144
+ ...noindex,
1108
1145
  };
1109
1146
  }
1110
1147
  function i18nToWire(i18n) {
@@ -1599,6 +1636,7 @@ function expandCronNumberPart(part, min, max) {
1599
1636
  return values;
1600
1637
  }
1601
1638
  async function commitInternal(client, planId, idempotencyKey, project, requiredPlan, vault, operationId) {
1639
+ const tries = { attempts: 0 };
1602
1640
  try {
1603
1641
  const body = {};
1604
1642
  if (idempotencyKey)
@@ -1622,10 +1660,16 @@ async function commitInternal(client, planId, idempotencyKey, project, requiredP
1622
1660
  : {}),
1623
1661
  context: "committing deploy",
1624
1662
  });
1625
- return await sendCommitRecoveringLostResponse(client, send, operationId, project);
1663
+ return await sendCommitRecoveringLostResponse(client, send, operationId, project, tries);
1626
1664
  }
1627
1665
  catch (err) {
1628
- throw translateDeployError(err, "commit", planId, null);
1666
+ const translated = translateDeployError(err, "commit", planId, operationId ?? null);
1667
+ // Retries were spent on a lost response: say how many and on what, so a
1668
+ // caller can tell "the SDK already tried" from "the first attempt failed".
1669
+ if (tries.attempts > 1) {
1670
+ throw withDeployRetryMetadata(translated, tries.attempts, DEPLOY_COMMIT_MAX_ATTEMPTS - 1, translated.code);
1671
+ }
1672
+ throw translated;
1629
1673
  }
1630
1674
  }
1631
1675
  /**
@@ -1636,9 +1680,11 @@ async function commitInternal(client, planId, idempotencyKey, project, requiredP
1636
1680
  * operation first: once it has left `planning`/`uploading` the commit landed,
1637
1681
  * and the caller follows the operation; otherwise the commit never arrived and
1638
1682
  * is sent again. Without an operation id to read, the failure surfaces as is.
1683
+ * `tries.attempts` counts the commits sent, for the error's retry metadata.
1639
1684
  */
1640
- async function sendCommitRecoveringLostResponse(client, send, operationId, projectId) {
1685
+ async function sendCommitRecoveringLostResponse(client, send, operationId, projectId, tries) {
1641
1686
  for (let attempt = 1;; attempt++) {
1687
+ tries.attempts = attempt;
1642
1688
  try {
1643
1689
  return await send();
1644
1690
  }
@@ -1692,14 +1738,16 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
1692
1738
  content_type: reader?.contentType,
1693
1739
  };
1694
1740
  });
1695
- const planRes = await client.request("/content/v1/plans", {
1741
+ // A lost content plan is safe to repeat: it only opens upload sessions, and
1742
+ // an abandoned one expires unused.
1743
+ const planRes = await requestWithTransientRetry(() => client.request("/content/v1/plans", {
1696
1744
  method: "POST",
1697
1745
  headers,
1698
1746
  body: ciCredentials
1699
1747
  ? { project_id: projectId, content: contentRequest }
1700
1748
  : { content: contentRequest },
1701
1749
  context: "planning content upload",
1702
- });
1750
+ }));
1703
1751
  const total = planRes.missing.length;
1704
1752
  let done = 0;
1705
1753
  for (const session of planRes.missing) {
@@ -1782,6 +1830,19 @@ async function commitContentPlanWithRetry(client, planId, headers) {
1782
1830
  }
1783
1831
  }
1784
1832
  }
1833
+ /** Repeat a read or an idempotent request lost to a 502/503/504 or a dropped connection. */
1834
+ async function requestWithTransientRetry(send) {
1835
+ for (let attempt = 1;; attempt++) {
1836
+ try {
1837
+ return await send();
1838
+ }
1839
+ catch (err) {
1840
+ if (!isTransientGatewayFailure(err) || attempt >= TRANSIENT_REQUEST_MAX_ATTEMPTS)
1841
+ throw err;
1842
+ await sleep(TRANSIENT_REQUEST_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
1843
+ }
1844
+ }
1845
+ }
1785
1846
  function isTransientGatewayFailure(err) {
1786
1847
  if (!isRun402Error(err))
1787
1848
  return false;
@@ -2068,7 +2129,10 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
2068
2129
  if (Date.now() - start > COMMIT_POLL_BACKOFF_AFTER_MS) {
2069
2130
  interval = Math.min(Math.floor(interval * 1.5), COMMIT_POLL_MAX_MS);
2070
2131
  }
2071
- snapshot = await client.request(`/apply/v1/operations/${encodeURIComponent(snapshot.operation_id)}`, { headers: opHeaders, context: "polling deploy operation" });
2132
+ // A poll lost to a 502/503/504 (a gateway rollout) says nothing about the
2133
+ // operation; read it again rather than abandon a deploy that is running.
2134
+ const operationId = snapshot.operation_id;
2135
+ snapshot = await requestWithTransientRetry(() => client.request(`/apply/v1/operations/${encodeURIComponent(operationId)}`, { headers: opHeaders, context: "polling deploy operation" }));
2072
2136
  }
2073
2137
  }
2074
2138
  async function drainRecordedOperationEvents(client, operationId, headers, cursor, emit) {
@@ -2313,7 +2377,7 @@ const FUNCTION_SPEC_FIELDS = new Set([
2313
2377
  const FUNCTION_CONFIG_FIELDS = new Set(["timeoutSeconds", "memoryMb"]);
2314
2378
  const FUNCTION_TRIGGER_FIELDS = new Set(["id", "type", "cron", "timezone", "misfire_policy", "overlap_policy", "mailbox", "events", "run"]);
2315
2379
  const FUNCTION_TRIGGER_RUN_FIELDS = new Set(["event_type", "payload", "retry", "expires_after_seconds"]);
2316
- const SITE_SPEC_FIELDS = new Set(["replace", "patch", "public_paths", "embedding", "sign_in_path"]);
2380
+ const SITE_SPEC_FIELDS = new Set(["replace", "patch", "public_paths", "embedding", "sign_in_path", "noindex"]);
2317
2381
  const SITE_PATCH_FIELDS = new Set(["put", "delete"]);
2318
2382
  const SITE_PUBLIC_PATHS_FIELDS = new Set(["mode", "replace"]);
2319
2383
  const PUBLIC_STATIC_PATH_FIELDS = new Set(["asset", "cache_class"]);
@@ -2652,6 +2716,17 @@ function validateSiteSpec(site) {
2652
2716
  if (obj.sign_in_path !== undefined) {
2653
2717
  validateSiteSignInPath(obj.sign_in_path, "site.sign_in_path");
2654
2718
  }
2719
+ if (obj.noindex !== undefined) {
2720
+ validateSiteNoindex(obj.noindex, "site.noindex");
2721
+ }
2722
+ }
2723
+ /** site.noindex. `true` = every response of the host carries
2724
+ * `X-Robots-Tag: noindex, nofollow, nosnippet`; `false` or `null` clears back
2725
+ * to indexable; omitted carries forward. */
2726
+ function validateSiteNoindex(value, resource) {
2727
+ if (value === null || typeof value === "boolean")
2728
+ return;
2729
+ throw invalidSpec(`ReleaseSpec.${resource} must be true, false, or null; send true to add X-Robots-Tag: noindex, nofollow, nosnippet to every response of the host, false or null to make it indexable again, or omit ${resource} to carry the previous value forward`, resource);
2655
2730
  }
2656
2731
  /** site.sign_in_path. Structural check only (the gateway is authoritative on
2657
2732
  * the reserved prefixes and the full rule set): a same-origin absolute path
@@ -3031,6 +3106,10 @@ function hasSiteContent(site) {
3031
3106
  if (site && typeof site === "object" && "sign_in_path" in site && site.sign_in_path !== undefined) {
3032
3107
  return true;
3033
3108
  }
3109
+ // site.noindex: same rule, true/false/null is content.
3110
+ if (site && typeof site === "object" && "noindex" in site && site.noindex !== undefined) {
3111
+ return true;
3112
+ }
3034
3113
  if (!isRecord(site))
3035
3114
  return false;
3036
3115
  if (hasRecordEntries(site.replace))
@@ -3113,7 +3192,7 @@ function invalidRouteSpec(message, resource) {
3113
3192
  }
3114
3193
  function normalizePlanResponse(plan) {
3115
3194
  const raw = plan;
3116
- const warnings = Array.isArray(raw.warnings) ? raw.warnings : [];
3195
+ const warnings = withSiteRemovalSummaries(Array.isArray(raw.warnings) ? raw.warnings : []);
3117
3196
  const missingContent = Array.isArray(raw.missing_content) ? raw.missing_content : [];
3118
3197
  if (raw.kind === "plan_response") {
3119
3198
  const diff = {
@@ -3487,6 +3566,10 @@ async function normalizeReleaseSpec(client, spec, opts = {}) {
3487
3566
  const signInPath = "sign_in_path" in spec.site && spec.site.sign_in_path !== undefined
3488
3567
  ? { sign_in_path: spec.site.sign_in_path }
3489
3568
  : {};
3569
+ // site.noindex: present (boolean or null) travels as-is.
3570
+ const noindex = "noindex" in spec.site && spec.site.noindex !== undefined
3571
+ ? { noindex: spec.site.noindex }
3572
+ : {};
3490
3573
  if ("replace" in spec.site && spec.site.replace) {
3491
3574
  const map = await normalizeFileSet(spec.site.replace, rememberRelease);
3492
3575
  // Re-check post-expansion so `dir("dist")` (a LocalDirRef whose keys are
@@ -3497,6 +3580,7 @@ async function normalizeReleaseSpec(client, spec, opts = {}) {
3497
3580
  ...(publicPaths ? { public_paths: publicPaths } : {}),
3498
3581
  ...embedding,
3499
3582
  ...signInPath,
3583
+ ...noindex,
3500
3584
  };
3501
3585
  }
3502
3586
  else if ("patch" in spec.site && spec.site.patch) {
@@ -3512,13 +3596,15 @@ async function normalizeReleaseSpec(client, spec, opts = {}) {
3512
3596
  ...(publicPaths ? { public_paths: publicPaths } : {}),
3513
3597
  ...embedding,
3514
3598
  ...signInPath,
3599
+ ...noindex,
3515
3600
  };
3516
3601
  }
3517
- else if (publicPaths || "embedding" in embedding || "sign_in_path" in signInPath) {
3602
+ else if (publicPaths || "embedding" in embedding || "sign_in_path" in signInPath || "noindex" in noindex) {
3518
3603
  normalized.site = {
3519
3604
  ...(publicPaths ? { public_paths: publicPaths } : {}),
3520
3605
  ...embedding,
3521
3606
  ...signInPath,
3607
+ ...noindex,
3522
3608
  };
3523
3609
  }
3524
3610
  }
@@ -4106,14 +4192,14 @@ function makeUnreadableReader(sha256, label) {
4106
4192
  async function uploadInlineCas(client, projectId, bytes, contentType) {
4107
4193
  const sha256 = await sha256Hex(bytes);
4108
4194
  const headers = await apikeyHeaders(client, projectId);
4109
- const planRes = await client.request("/content/v1/plans", {
4195
+ const planRes = await requestWithTransientRetry(() => client.request("/content/v1/plans", {
4110
4196
  method: "POST",
4111
4197
  headers,
4112
4198
  body: {
4113
4199
  content: [{ sha256, size: bytes.byteLength, content_type: contentType }],
4114
4200
  },
4115
4201
  context: "planning content upload",
4116
- });
4202
+ }));
4117
4203
  if (planRes.missing.length > 0) {
4118
4204
  const session = planRes.missing[0];
4119
4205
  await uploadOne(client.fetch, session, bytes);