Skip to content
Merged
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
16 changes: 14 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ function mergeCookieHeader(headers: Headers, jarValue: string): void {
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
const sensitiveHeaders = ["authorization", "cookie", "proxy-authorization"];

function discardResponseBody(response: Response): void {
void response.body?.cancel().catch(() => {
// A discarded body must not replace the redirect result with a cleanup error.
});
}

async function run(
target: Injectable,
initial: Request,
Expand All @@ -51,12 +57,18 @@ async function run(
const response = await dispatch(target, dispatched);
if (jar) await captureCookies(jar, response, dispatched.url);
if (!redirectStatuses.has(response.status) || request.redirect === "manual") return response;
if (request.redirect === "error")
if (request.redirect === "error") {
discardResponseBody(response);
throw new TypeError("Redirect encountered with redirect mode 'error'");
}
const location = response.headers.get("location");
if (!location) return response;
if (hops++ >= maxRedirects)
if (hops++ >= maxRedirects) {
discardResponseBody(response);
throw new TypeError(`Maximum redirect count of ${maxRedirects} exceeded`);
}

discardResponseBody(response);

const nextUrl = new URL(location, request.url);
const headers = new Headers(request.headers);
Expand Down
68 changes: 68 additions & 0 deletions tests/testing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,74 @@ describe("redirects", () => {
return new Response("done");
};

it.each([
{ redirect: "follow", maxRedirects: 10, rejects: false },
{ redirect: "error", maxRedirects: 10, rejects: true },
{ redirect: "follow", maxRedirects: 0, rejects: true },
] as const)(
"should cancel discarded redirect bodies for $redirect with limit $maxRedirects",
async ({ redirect, maxRedirects, rejects }) => {
let cancelled = 0;
const request = inject(
(incoming) =>
new URL(incoming.url).pathname === "/start"
? new Response(
new ReadableStream({
cancel() {
cancelled += 1;
},
}),
{ status: 302, headers: { location: "/end" } },
)
: new Response("done"),
"/start",
{ redirect, maxRedirects },
);

if (rejects) await expect(request).rejects.toBeInstanceOf(TypeError);
else expect(await (await request).text()).toBe("done");
expect(cancelled).toBe(1);
},
);

it("should preserve redirect traversal when discarded-body cancellation fails", async () => {
const response = await inject(
(incoming) =>
new URL(incoming.url).pathname === "/start"
? new Response(
new ReadableStream({
cancel() {
throw new Error("cleanup failed");
},
}),
{ status: 302, headers: { location: "/end" } },
)
: new Response("done"),
"/start",
{ redirect: "follow" },
);

expect(await response.text()).toBe("done");
});

it("should not wait for discarded-body cancellation to settle", async () => {
const response = await inject(
(incoming) =>
new URL(incoming.url).pathname === "/start"
? new Response(
new ReadableStream({
cancel: () => new Promise(() => undefined),
}),
{ status: 302, headers: { location: "/end" } },
)
: new Response("done"),
"/start",
{ redirect: "follow" },
);

expect(await response.text()).toBe("done");
});

it("should be manual by default and follow with standard rewriting", async () => {
expect((await inject(redirects, "/start", { method: "POST", body: "value" })).status).toBe(302);
seen.length = 0;
Expand Down