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.
- package/lib/deploy-v2.mjs +46 -5
- package/lib/sdk-errors.mjs +8 -1
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +1 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/index.d.ts +1 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.js +79 -21
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/site-removal-summary.d.ts +48 -0
- package/sdk/dist/namespaces/site-removal-summary.d.ts.map +1 -0
- package/sdk/dist/namespaces/site-removal-summary.js +107 -0
- package/sdk/dist/namespaces/site-removal-summary.js.map +1 -0
- package/vault-surface.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
133
|
-
phase:
|
|
134
|
-
resource:
|
|
135
|
-
operationId:
|
|
136
|
-
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
|
-
|
|
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
|
|
856
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|