fix(storage): recognize errors across module packages
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 29s
CI / migration-smoke (pull_request) Successful in 1m44s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 53s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 2m20s
CI / package-publication (pull_request) Successful in 51s

This commit is contained in:
ekklesia
2026-10-10 10:54:13 -05:00
parent 6e64b3c5f9
commit a0e9948820
4 changed files with 72 additions and 20 deletions
+12
View File
@@ -1,5 +1,6 @@
import { import {
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES, MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS, MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
} }
}); });
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => { it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider(); const shared = fakeProvider();
const announcements = createModuleStorage({ const announcements = createModuleStorage({
+22
View File
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
} }
} }
/**
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
*
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
* by the host's storage provider does not share class identity with the module's
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
* module boundary.
*/
export function isModuleStorageError(
value: unknown,
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
return false;
}
const { code, key } = value as { code?: unknown; key?: unknown };
return (
typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
(key === undefined || typeof key === "string")
);
}
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes, /** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
* not characters, because that is what the wire format bounds. */ * not characters, because that is what the wire format bounds. */
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024; export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
+1
View File
@@ -37,6 +37,7 @@ export {
isDevicePrincipal, isDevicePrincipal,
isModuleHttpMethod, isModuleHttpMethod,
isModuleRouteBodyMode, isModuleRouteBodyMode,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
isValidModuleStorageReadUrlFilename, isValidModuleStorageReadUrlFilename,
MODULE_HTTP_BODY_MODES, MODULE_HTTP_BODY_MODES,
+19 -2
View File
@@ -426,7 +426,9 @@ async function seedSignage(owner) {
const base = "/api/v1/modules/signage"; const base = "/api/v1/modules/signage";
const images = []; const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) { for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate( let uploaded;
for (let attempt = 1; attempt <= 2; attempt += 1) {
uploaded = await owner.evaluate(
`(async () => { `(async () => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = 640; canvas.width = 640;
@@ -445,11 +447,26 @@ async function seedSignage(owner) {
})()`, })()`,
`uploading slide ${index}`, `uploading slide ${index}`,
); );
if (
attempt === 1 &&
uploaded.status === 503 &&
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
) {
note(`slide ${index} met one transient storage outage; retrying once`);
await sleep(1_000);
continue;
}
break;
}
if (uploaded.status === "timeout") { if (uploaded.status === "timeout") {
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs); throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
} }
if (uploaded.status !== 200 && uploaded.status !== 201) { if (uploaded.status !== 200 && uploaded.status !== 201) {
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}.`); const code =
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
? ` (${uploaded.body.code})`
: "";
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
} }
images.push(uploaded.body?.image?.id); images.push(uploaded.body?.image?.id);
} }