Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deploy-token-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/cli": patch
---

`cloudhal deploy` retries its post-deploy calls (push check, primary domain) for up to 90 seconds while they get 401: a Workers Builds deploy makes a new deployment token, and the edge can answer from the previous admin Worker version for a few seconds. Such a build used to fail after both Workers had deployed.
25 changes: 25 additions & 0 deletions packages/cli/src/commands/deploy/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,31 @@ export async function waitForHealth(
}
}

/**
* Runs `step` again while it fails with 401. Right after a deploy the edge can still answer from
* the previous admin Worker version, which holds the previous deployment token (Workers Builds
* makes a new one on every build), for a few seconds. Gives up after `timeoutMs`.
*/
export async function whileTokenPropagates<T>(
step: () => Promise<T>,
options: { log: (line: string) => void; timeoutMs?: number; waitMs?: number },
): Promise<T> {
const deadline = Date.now() + (options.timeoutMs ?? 90_000);
let announced = false;
for (;;) {
try {
return await step();
} catch (e) {
if (!/\(401\)/.test(e instanceof Error ? e.message : "") || Date.now() > deadline) throw e;
if (!announced) {
options.log("the deployment does not accept the new token yet; retrying while it spreads…");
announced = true;
}
await new Promise((r) => setTimeout(r, options.waitMs ?? 5000));
}
}
}

/** Purges the site Worker's whole cache once (the script tag lives in cached HTML). */
export async function purgeSite(
siteUrl: string,
Expand Down
44 changes: 25 additions & 19 deletions packages/cli/src/commands/deploy/ship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
import { deployedUrls, isCronLimitError, must, WranglerError } from "../../cloudflare/wrangler.ts";
import { pushSite, setPrimaryDomain } from "../sync.ts";
import type { DeployContext } from "./context.ts";
import { purgeSite, waitForHealth } from "./resources.ts";
import { purgeSite, waitForHealth, whileTokenPropagates } from "./resources.ts";
import { CRON_REFUSED_MESSAGE } from "./schedules-guard.ts";
import type { DeploySecrets } from "./state.ts";

Expand Down Expand Up @@ -111,29 +111,35 @@ export async function afterDeploy(
});
await l.close();
}
const result = await pushSite({
projectDir: options.projectDir,
ifAbsent: true,
log,
...(options.fetch ? { fetch: options.fetch } : {}),
});
// A custom domain becomes the site's primary hostname (the workers.dev one stays secondary).
if (cfg.domains?.site) {
await setPrimaryDomain({
const retry = <T>(step: () => Promise<T>) => whileTokenPropagates(step, { log });
const result = await retry(() =>
pushSite({
projectDir: options.projectDir,
hostname: cfg.domains.site,
ifAbsent: true,
log,
...(options.fetch ? { fetch: options.fetch } : {}),
});
log(
`site domain: https://${cfg.domains.site} (Cloudflare issues the certificate on first use)`,
}),
);
// A custom domain becomes the site's primary hostname (the workers.dev one stays secondary).
const domain = cfg.domains?.site;
if (domain) {
await retry(() =>
setPrimaryDomain({
projectDir: options.projectDir,
hostname: domain,
...(options.fetch ? { fetch: options.fetch } : {}),
}),
);
log(`site domain: https://${domain} (Cloudflare issues the certificate on first use)`);
} else if (previous?.siteDomain && siteUrl) {
// The custom domain was removed from cloudhal.json: workers.dev is primary again.
await setPrimaryDomain({
projectDir: options.projectDir,
hostname: new URL(siteUrl).hostname,
...(options.fetch ? { fetch: options.fetch } : {}),
});
await retry(() =>
setPrimaryDomain({
projectDir: options.projectDir,
hostname: new URL(siteUrl).hostname,
...(options.fetch ? { fetch: options.fetch } : {}),
}),
);
log(
`site domain ${previous.siteDomain} removed from cloudhal.json; if Cloudflare still lists it under the Worker's Domains & Routes, remove it there`,
);
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/test/token-propagation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { whileTokenPropagates } from "../src/commands/deploy/resources.ts";

describe("whileTokenPropagates (a new deployment token takes seconds to reach every edge)", () => {
it("retries a step that fails with 401, then returns its result", async () => {
let calls = 0;
const lines: string[] = [];
const result = await whileTokenPropagates(
async () => {
calls++;
if (calls < 3) throw new Error('push failed (401): {"error":"unauthorized"}');
return "pushed";
},
{ log: (l) => lines.push(l), waitMs: 1 },
);
expect(result).toBe("pushed");
expect(calls).toBe(3);
expect(lines).toHaveLength(1);
});

it("does not retry other failures, and gives up after the timeout", async () => {
let calls = 0;
await expect(
whileTokenPropagates(
async () => {
calls++;
throw new Error("push failed (500): boom");
},
{ log: () => {}, waitMs: 1 },
),
).rejects.toThrow(/500/);
expect(calls).toBe(1);
await expect(
whileTokenPropagates(
async () => {
throw new Error("push failed (401)");
},
{ log: () => {}, waitMs: 1, timeoutMs: 20 },
),
).rejects.toThrow(/401/);
});
});
Loading