Add failing tests for the download retry policy
Tests only; the retry module they import does not exist yet, so the branch is red at this commit. New test/retry/retry.test.ts documents the classifier and the backoff: which errors are worth another attempt, which are not, and how the delay before each retry is derived. It asserts on the arguments handed to an injected sleep function rather than on elapsed time, so the suite never waits and the numbers are exact. test/api/client.test.ts gains the request-count contract for each of the six call sites, the deadline behaviour, the ApiError typing that the presigned PUT and the null-body paths need in order to be classified at all, and the replay rule for the two non-idempotent methods. test/download/download.test.ts gains the case that motivates the whole design: a socket reset after the response headers arrived, which happens below ApiClient and can only be caught by retrying the request, the stream consumption and the decryption together. It also pins that a retried download stages exactly one temp file, and that the two retry layers do not compose into a multiplied request budget. The existing truncation tests now assert on the error type rather than its wording, since that type is what the classifier reads. test/thumbnails/thumbnails.test.ts separates a genuine 404 from an exhausted retry, so a failing server can no longer make fix-missing-thumbnails re-upload thumbnails that already exist.
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
fixMissingThumbnails,
|
||||
} from "../../src/thumbnails.js";
|
||||
import type { KeyAttributes } from "../../src/auth/types.js";
|
||||
import type { RetryOptions } from "../../src/retry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock server with controllable thumbnail behavior
|
||||
@@ -51,7 +52,7 @@ interface ThumbMockState {
|
||||
filesByCollection: Record<number, Record<string, unknown>[]>;
|
||||
fileCiphertexts: Record<number, Uint8Array>;
|
||||
fileKeys: Record<number, Uint8Array>;
|
||||
thumbnailBehavior: Record<number, "ok" | "empty" | "404">;
|
||||
thumbnailBehavior: Record<number, "ok" | "empty" | "404" | "500">;
|
||||
// Captures from fix operations
|
||||
uploadedThumbnails: {
|
||||
fileID: number;
|
||||
@@ -302,6 +303,9 @@ const buildThumbFetch = (m: ThumbMockState) => {
|
||||
if (behavior === "empty") {
|
||||
return new Response(new Uint8Array(0), { status: 200 });
|
||||
}
|
||||
if (behavior === "500") {
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
@@ -345,6 +349,38 @@ const buildThumbFetch = (m: ThumbMockState) => {
|
||||
}) as typeof globalThis.fetch;
|
||||
};
|
||||
|
||||
/**
|
||||
* A retry policy with the waiting removed. `listMissingThumbnails` walks every
|
||||
* file in the account, so a transient failure is retried; without an injected
|
||||
* `sleep` these tests would spend real seconds waiting out backoff.
|
||||
*/
|
||||
const noWait: RetryOptions = {
|
||||
sleep: () => Promise.resolve(),
|
||||
random: () => 0,
|
||||
};
|
||||
|
||||
/** Wrap a fetch so the tests can count how often one endpoint was hit. */
|
||||
const countingFetch = (
|
||||
inner: typeof globalThis.fetch,
|
||||
match: (url: string) => boolean,
|
||||
): { fetch: typeof globalThis.fetch; matched: () => number } => {
|
||||
let matched = 0;
|
||||
const fake = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (match(url)) matched++;
|
||||
return inner(input, init);
|
||||
};
|
||||
return { fetch: fake as typeof globalThis.fetch, matched: () => matched };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,7 +414,77 @@ describe("listMissingThumbnails", () => {
|
||||
expect(emptyEntry.collection).toBe("Photos");
|
||||
|
||||
const notFoundEntry = missing.find((m) => m.fileID === 102)!;
|
||||
expect(notFoundEntry.reason).toContain("fetch failed");
|
||||
// A 404 is the server stating the thumbnail is not there. That is the
|
||||
// only network answer that means "missing", and the reason says so
|
||||
// rather than the older catch-all "fetch failed" — which used to
|
||||
// cover a 500 and a dropped connection too.
|
||||
expect(notFoundEntry.reason).toContain("not found");
|
||||
});
|
||||
|
||||
it("does not report a thumbnail as missing when the server is failing", async () => {
|
||||
// The distinction that matters for `helper fix-missing-thumbnails`.
|
||||
// Reporting a file here leads to downloading the original,
|
||||
// regenerating a thumbnail, and uploading it over a thumbnail that
|
||||
// was fine all along — because the server was briefly returning 500s.
|
||||
//
|
||||
// File 102 serves 500 on every attempt, so the retries are genuinely
|
||||
// exhausted. It must still not be reported.
|
||||
const failingMock = await buildThumbMock();
|
||||
failingMock.thumbnailBehavior[102] = "500";
|
||||
|
||||
const counted = countingFetch(
|
||||
buildThumbFetch(failingMock),
|
||||
(url) => url.includes("thumbnails.ente.io") && url.includes("102"),
|
||||
);
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: counted.fetch, retry: { ...noWait } },
|
||||
});
|
||||
|
||||
const missing = await listMissingThumbnails(client);
|
||||
|
||||
// Only the genuinely empty thumbnail is reported.
|
||||
expect(missing.map((m) => m.fileID)).toEqual([101]);
|
||||
// And the 500 was retried rather than accepted as an answer: four
|
||||
// attempts is the library default.
|
||||
expect(counted.matched()).toBe(4);
|
||||
});
|
||||
|
||||
it("does not report a thumbnail as missing when the connection fails", async () => {
|
||||
// Same rule for a transport failure, which carries no status at all.
|
||||
const failingMock = await buildThumbMock();
|
||||
const inner = buildThumbFetch(failingMock);
|
||||
let thumbRequests = 0;
|
||||
const fetch = (async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (url.includes("thumbnails.ente.io") && url.includes("102")) {
|
||||
thumbRequests++;
|
||||
throw Object.assign(new Error("socket hang up"), {
|
||||
code: "ECONNRESET",
|
||||
});
|
||||
}
|
||||
return inner(input, init);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch, retry: { ...noWait } },
|
||||
});
|
||||
|
||||
const missing = await listMissingThumbnails(client);
|
||||
|
||||
expect(missing.map((m) => m.fileID)).toEqual([101]);
|
||||
expect(thumbRequests).toBe(4);
|
||||
});
|
||||
|
||||
it("deduplicates files seen in multiple collections", async () => {
|
||||
|
||||
Reference in New Issue
Block a user