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
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:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EMPTY_MODULE_STORAGE,
|
||||
isModuleStorageError,
|
||||
isValidModuleStorageKey,
|
||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||
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 () => {
|
||||
const shared = fakeProvider();
|
||||
const announcements = createModuleStorage({
|
||||
|
||||
@@ -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,
|
||||
* not characters, because that is what the wire format bounds. */
|
||||
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
isDevicePrincipal,
|
||||
isModuleHttpMethod,
|
||||
isModuleRouteBodyMode,
|
||||
isModuleStorageError,
|
||||
isValidModuleStorageKey,
|
||||
isValidModuleStorageReadUrlFilename,
|
||||
MODULE_HTTP_BODY_MODES,
|
||||
|
||||
@@ -426,30 +426,47 @@ async function seedSignage(owner) {
|
||||
const base = "/api/v1/modules/signage";
|
||||
const images = [];
|
||||
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||
const uploaded = await owner.evaluate(
|
||||
`(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = ${JSON.stringify(colour)};
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||
}).then(
|
||||
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||
);
|
||||
})()`,
|
||||
`uploading slide ${index}`,
|
||||
);
|
||||
let uploaded;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
uploaded = await owner.evaluate(
|
||||
`(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = ${JSON.stringify(colour)};
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||
}).then(
|
||||
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||
);
|
||||
})()`,
|
||||
`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") {
|
||||
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user