Skip to content

Commit 9763e92

Browse files
committed
fix(deploy): tolerate transient CF 404 on workers-domain DELETE
Right after a 12MB putScript upload, Cloudflare transiently 404s DELETE /workers/domains/{id} for bindings attached to that worker (the envelope's result is null, surfacing as a bare CloudflareHttpError, not DomainNotFound). The same call succeeds seconds later — verified by direct curl/SDK probes of the exact binding. - alchemy patch: catch CloudflareHttpError alongside DomainNotFound in syncDomains detachment (stack code reconciles domains right after and fails loudly if the detach truly mattered) - web/docs stacks: retry deleteDomain through the transient window (5x, 2s spacing, CloudflareHttpError only)
1 parent 3eef5f6 commit 9763e92

3 files changed

Lines changed: 45 additions & 2 deletions

File tree

apps/docs/alchemy.run.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Cloudflare from "alchemy/Cloudflare";
88
import * as Output from "alchemy/Output";
99
import * as Workers from "@distilled.cloud/cloudflare/workers";
1010
import * as Effect from "effect/Effect";
11+
import * as Schedule from "effect/Schedule";
1112

1213
const PROJECT = "stackpanel";
1314
const SERVICE = "docs";
@@ -139,7 +140,16 @@ const program = Effect.gen(function* () {
139140
);
140141
}
141142
for (const d of stale) {
142-
yield* Workers.deleteDomain({ accountId, domainId: d.id! });
143+
// Cloudflare transiently 404s workers-domain DELETEs right
144+
// after the worker upload (binding record rebuild) — retry
145+
// through that window instead of dying on a phantom 404.
146+
yield* Workers.deleteDomain({ accountId, domainId: d.id! }).pipe(
147+
Effect.retry({
148+
while: (e) => e._tag === "CloudflareHttpError",
149+
schedule: Schedule.spaced("2 seconds"),
150+
times: 5,
151+
}),
152+
);
143153
}
144154
yield* Workers.putDomain({
145155
accountId,

apps/web/alchemy.run.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as Cloudflare from "alchemy/Cloudflare";
99
import * as Output from "alchemy/Output";
1010
import * as Workers from "@distilled.cloud/cloudflare/workers";
1111
import * as Effect from "effect/Effect";
12+
import * as Schedule from "effect/Schedule";
1213
import * as Layer from "effect/Layer";
1314

1415
const PROJECT = "stackpanel";
@@ -134,7 +135,16 @@ const program = Effect.gen(function* () {
134135
);
135136
}
136137
for (const d of stale) {
137-
yield* Workers.deleteDomain({ accountId, domainId: d.id! });
138+
// Cloudflare transiently 404s workers-domain DELETEs right
139+
// after the worker upload (binding record rebuild) — retry
140+
// through that window instead of dying on a phantom 404.
141+
yield* Workers.deleteDomain({ accountId, domainId: d.id! }).pipe(
142+
Effect.retry({
143+
while: (e) => e._tag === "CloudflareHttpError",
144+
schedule: Schedule.spaced("2 seconds"),
145+
times: 5,
146+
}),
147+
);
138148
}
139149
yield* Workers.putDomain({
140150
accountId,

patches/alchemy@2.0.0-beta.57.patch

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
diff --git a/node_modules/alchemy/.bun-tag-2e60a3d01e391349 b/.bun-tag-2e60a3d01e391349
2+
new file mode 100644
3+
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
14
diff --git a/lib/Cloudflare/StateStore/Api.js b/lib/Cloudflare/StateStore/Api.js
25
index 91cd6535c3ff5b8997764cd5c37a5a527f09a773..c60ded30c61681649df77d00f1b1e834095c93aa 100644
36
--- a/lib/Cloudflare/StateStore/Api.js
@@ -55,6 +58,26 @@ index ff7b9a56c3795e13b833cf1ea6ece859c7896532..a7048956934ee531bd9ac6d14b66e911
5558
}).pipe(Effect.orDie);
5659
return {
5760
// -- Root DO methods -----------------------------------------
61+
diff --git a/lib/Cloudflare/Workers/Worker.js b/lib/Cloudflare/Workers/Worker.js
62+
index 912be17fdd1a0380471856b0c485a7f1a88dbc0b..d863bba4904105bcf4c9fe5640be7258c18355c3 100644
63+
--- a/lib/Cloudflare/Workers/Worker.js
64+
+++ b/lib/Cloudflare/Workers/Worker.js
65+
@@ -584,7 +584,14 @@ export const LiveWorkerProvider = () => Provider.effect(Worker, Effect.gen(funct
66+
const toRemove = liveAll.filter((d) => !desiredSet.has(d.hostname));
67+
yield* Effect.all(toRemove.map((d) => workers
68+
.deleteDomain({ accountId, domainId: d.id })
69+
- .pipe(Effect.catchTag("DomainNotFound", () => Effect.void))), { concurrency: "unbounded" });
70+
+ // darkmatter: Cloudflare transiently 404s workers-domain DELETEs
71+
+ // in the window right after a large putScript upload (the binding
72+
+ // record is rebuilt server-side) — the identical call succeeds
73+
+ // seconds later. That 404 surfaces as a bare CloudflareHttpError
74+
+ // (envelope `result` is null), not DomainNotFound, so widen the
75+
+ // catch: the stack's own domain reconciliation runs immediately
76+
+ // after and fails loudly if the detach was genuinely required.
77+
+ .pipe(Effect.catchTags({ DomainNotFound: () => Effect.void, CloudflareHttpError: () => Effect.void }))), { concurrency: "unbounded" });
78+
if (desired.length === 0)
79+
return [];
80+
const zoneCache = new Map();
5881
diff --git a/src/Cloudflare/StateStore/Api.ts b/src/Cloudflare/StateStore/Api.ts
5982
index 5d63113ef5c9ed720a1cdf08a57949e45e8362ae..b7100a4495ff02ae70aa70fc0a51a34ab69270f1 100644
6083
--- a/src/Cloudflare/StateStore/Api.ts

0 commit comments

Comments
 (0)