Skip to content
Closed
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
31 changes: 31 additions & 0 deletions packages/sandbox/server/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,37 @@ describe("Bun-style connection errors", () => {
});
});

describe("transient HTTP status retries", () => {
it("retries a 503 and returns the eventual success", async () => {
let attempts = 0;
const { calls } = installFetch(() => {
attempts++;
if (attempts === 1) return new Response("unavailable", { status: 503 });
return new Response(
JSON.stringify({ bootId: "b", transition: "t", config: {} }),
{ status: 200 },
);
});
await postConfig("http://daemon:9000", "tok", { env: {} } as never);
expect(calls.length).toBe(2);
});

it("surfaces the last 503 as a ConfigRequestError once retries are exhausted", async () => {
installFetch(() => new Response("still unavailable", { status: 503 }));
await expect(
postConfig("http://daemon:9000", "tok", { env: {} } as never),
).rejects.toThrow("/_sandbox/config returned 503");
});

it("does not retry a plain 500", async () => {
const { calls } = installFetch(() => new Response("boom", { status: 500 }));
await expect(
postConfig("http://daemon:9000", "tok", { env: {} } as never),
).rejects.toThrow("/_sandbox/config returned 500");
expect(calls.length).toBe(1);
});
});

describe("proxyDaemonRequest", () => {
it("injects Authorization: Bearer <token> header", async () => {
const { calls } = installFetch(() => new Response("", { status: 204 }));
Expand Down
33 changes: 31 additions & 2 deletions packages/sandbox/server/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import type { ConfigPatch, TenantConfig } from "../daemon-protocol";
import { sleep } from "../shared";
import { retry, type RetryOptions } from "@decocms/shared/std";
import { retry, RetryError, type RetryOptions } from "@decocms/shared/std";

export type { ConfigPatch };

Expand Down Expand Up @@ -47,9 +47,29 @@ function isTransientError(err: unknown): boolean {
if (err instanceof DOMException && err.name === "AbortError") {
return true;
}
if (err instanceof TransientStatusError) {
return true;
}
return false;
}

/** HTTP statuses the daemon can return while restarting/behind a proxy that
* are worth one more attempt, same as a network-level connection failure.
* Deliberately excludes 500 (an application bug, not infra hiccup). */
const TRANSIENT_STATUSES = new Set([502, 503, 504]);

/** Marks a non-2xx response as retriable so it flows through the same retry
* path as a thrown network error, instead of returning immediately as a
* "successful" (but failed) response. */
class TransientStatusError extends Error {
constructor(
readonly status: number,
readonly responseBody: string,
) {
super(`transient status ${status}`);
}
}

/**
* Call a daemon endpoint and read its whole body, labelling any transport
* failure with the endpoint that failed. Retries on transient errors
Expand Down Expand Up @@ -95,9 +115,18 @@ async function daemonRequest(
...init,
signal: AbortSignal.timeout(timeoutMs),
});
return { status: res.status, ok: res.ok, body: await res.text() };
const body = await res.text();
if (TRANSIENT_STATUSES.has(res.status)) {
throw new TransientStatusError(res.status, body);
}
return { status: res.status, ok: res.ok, body };
}, retryOpts);
} catch (err) {
// Unwrap RetryError's cause: a transient status is a real response.
const cause = err instanceof RetryError ? err.cause : err;
if (cause instanceof TransientStatusError) {
return { status: cause.status, ok: false, body: cause.responseBody };
}
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`[SANDBOX_UNREACHABLE] sandbox daemon ${endpoint} request failed: ${message}`,
Expand Down
Loading