test: execute the HTTP scenario catalog
This commit is contained in:
@@ -65,6 +65,30 @@ async function flushMicrotasks(): Promise<void> {
|
||||
}
|
||||
|
||||
describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
it("normalizes a read-side 429 to the non-applicable effect vocabulary", async () => {
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: async () =>
|
||||
Response.json(
|
||||
{ type: "about:blank", title: "limited", status: 429 },
|
||||
{ status: 429 },
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(installed, { limit: 20 }, { scope }),
|
||||
).resolves.toMatchObject({
|
||||
kind: "RATE_LIMITED",
|
||||
effect: "NOT_APPLICABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absent intent", () => undefined],
|
||||
["empty key", () => mutationIntent({ idempotencyKey: "" })],
|
||||
@@ -471,6 +495,67 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("never retries a deadline-owned abort while the monotonic clock still has a sub-tick budget", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const iterationCount = 100;
|
||||
const sleep = vi.fn(async () => {
|
||||
throw new Error("a deadline-owned abort must not enter retry sleep");
|
||||
});
|
||||
const fetcher = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("Aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const observe = vi.fn();
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 2,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher,
|
||||
monotonicNow: () => 0,
|
||||
random: () => 0,
|
||||
sleep,
|
||||
observe,
|
||||
});
|
||||
|
||||
for (let iteration = 0; iteration < iterationCount; iteration += 1) {
|
||||
const result = executor.execute(
|
||||
operation({ deadlineMs: 5 }),
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await flushMicrotasks();
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
kind: "TRANSPORT_FAILURE",
|
||||
failure: { kind: "TIMEOUT" },
|
||||
});
|
||||
}
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(iterationCount);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
expect(observe).toHaveBeenCalledTimes(iterationCount);
|
||||
for (const [observation] of observe.mock.calls) {
|
||||
expect(observation).toEqual(
|
||||
expect.objectContaining({ attempts: 1, certainty: "TIMEOUT" }),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels a non-cooperative retry sleep when the caller aborts", async () => {
|
||||
const caller = new AbortController();
|
||||
let sleepSignal: AbortSignal | undefined;
|
||||
|
||||
Reference in New Issue
Block a user