run402 4.108.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 +47 -7
- 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 +80 -38
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +0 -4
- package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.types.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/sdk/dist/node/deploy-manifest.d.ts.map +1 -1
- package/sdk/dist/node/deploy-manifest.js +1 -23
- package/sdk/dist/node/deploy-manifest.js.map +1 -1
- 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) {
|
|
@@ -2319,7 +2377,7 @@ const SITE_PUBLIC_PATHS_FIELDS = new Set(["mode", "replace"]);
|
|
|
2319
2377
|
const PUBLIC_STATIC_PATH_FIELDS = new Set(["asset", "cache_class"]);
|
|
2320
2378
|
const SUBDOMAINS_SPEC_FIELDS = new Set(["set", "add", "remove"]);
|
|
2321
2379
|
const ROUTES_SPEC_FIELDS = new Set(["replace"]);
|
|
2322
|
-
const ROUTE_ENTRY_FIELDS = new Set(["pattern", "methods", "target", "pricing"
|
|
2380
|
+
const ROUTE_ENTRY_FIELDS = new Set(["pattern", "methods", "target", "pricing"]);
|
|
2323
2381
|
const FUNCTION_ROUTE_TARGET_FIELDS = new Set(["type", "name"]);
|
|
2324
2382
|
const STATIC_ROUTE_TARGET_FIELDS = new Set(["type", "file"]);
|
|
2325
2383
|
const ROUTE_METHOD_SET = new Set(ROUTE_HTTP_METHODS);
|
|
@@ -2785,7 +2843,6 @@ function validateRouteEntry(route, resource) {
|
|
|
2785
2843
|
}
|
|
2786
2844
|
const targetType = validateRouteTarget(entry.target, `${resource}.target`);
|
|
2787
2845
|
validateRoutePricing(entry.pricing, resource, targetType);
|
|
2788
|
-
validateRouteReadOnlyAcknowledgement(entry, targetType, resource);
|
|
2789
2846
|
if (targetType === "static") {
|
|
2790
2847
|
validateStaticRouteEntry(entry, resource);
|
|
2791
2848
|
}
|
|
@@ -2830,19 +2887,6 @@ function validateRoutePricing(pricing, resource, targetType) {
|
|
|
2830
2887
|
seen.add(network);
|
|
2831
2888
|
}
|
|
2832
2889
|
}
|
|
2833
|
-
function validateRouteReadOnlyAcknowledgement(entry, targetType, resource) {
|
|
2834
|
-
if (entry.acknowledge_readonly === undefined)
|
|
2835
|
-
return;
|
|
2836
|
-
if (entry.acknowledge_readonly !== true) {
|
|
2837
|
-
throw invalidRouteSpec(`ReleaseSpec.${resource}.acknowledge_readonly must be true when present`, `${resource}.acknowledge_readonly`);
|
|
2838
|
-
}
|
|
2839
|
-
if (targetType !== "function" ||
|
|
2840
|
-
typeof entry.pattern !== "string" ||
|
|
2841
|
-
!isFinalWildcardRoutePattern(entry.pattern) ||
|
|
2842
|
-
!isReadOnlyRouteMethods(entry.methods)) {
|
|
2843
|
-
throw invalidRouteSpec(`ReleaseSpec.${resource}.acknowledge_readonly applies only to GET/HEAD final-wildcard function routes`, `${resource}.acknowledge_readonly`);
|
|
2844
|
-
}
|
|
2845
|
-
}
|
|
2846
2890
|
function validateRouteTarget(target, resource) {
|
|
2847
2891
|
const obj = requireObject(target, resource);
|
|
2848
2892
|
if ((hasOwn(obj, "function") || hasOwn(obj, "static")) && !hasOwn(obj, "type")) {
|
|
@@ -3127,7 +3171,7 @@ function invalidRouteSpec(message, resource) {
|
|
|
3127
3171
|
}
|
|
3128
3172
|
function normalizePlanResponse(plan) {
|
|
3129
3173
|
const raw = plan;
|
|
3130
|
-
const warnings = Array.isArray(raw.warnings) ? raw.warnings : [];
|
|
3174
|
+
const warnings = withSiteRemovalSummaries(Array.isArray(raw.warnings) ? raw.warnings : []);
|
|
3131
3175
|
const missingContent = Array.isArray(raw.missing_content) ? raw.missing_content : [];
|
|
3132
3176
|
if (raw.kind === "plan_response") {
|
|
3133
3177
|
const diff = {
|
|
@@ -3275,8 +3319,6 @@ function clientRoutePlanWarnings(spec) {
|
|
|
3275
3319
|
return false;
|
|
3276
3320
|
if (!route.methods)
|
|
3277
3321
|
return false;
|
|
3278
|
-
if (route.acknowledge_readonly === true)
|
|
3279
|
-
return false;
|
|
3280
3322
|
return isReadOnlyRouteMethods(route.methods);
|
|
3281
3323
|
})
|
|
3282
3324
|
.map((route) => route.pattern)
|
|
@@ -4122,14 +4164,14 @@ function makeUnreadableReader(sha256, label) {
|
|
|
4122
4164
|
async function uploadInlineCas(client, projectId, bytes, contentType) {
|
|
4123
4165
|
const sha256 = await sha256Hex(bytes);
|
|
4124
4166
|
const headers = await apikeyHeaders(client, projectId);
|
|
4125
|
-
const planRes = await client.request("/content/v1/plans", {
|
|
4167
|
+
const planRes = await requestWithTransientRetry(() => client.request("/content/v1/plans", {
|
|
4126
4168
|
method: "POST",
|
|
4127
4169
|
headers,
|
|
4128
4170
|
body: {
|
|
4129
4171
|
content: [{ sha256, size: bytes.byteLength, content_type: contentType }],
|
|
4130
4172
|
},
|
|
4131
4173
|
context: "planning content upload",
|
|
4132
|
-
});
|
|
4174
|
+
}));
|
|
4133
4175
|
if (planRes.missing.length > 0) {
|
|
4134
4176
|
const session = planRes.missing[0];
|
|
4135
4177
|
await uploadOne(client.fetch, session, bytes);
|