Skip to content

Commit

Permalink
fix: request wasn't timing out waiting for body (#227)
Browse files Browse the repository at this point in the history
  • Loading branch information
dsherret authored Jan 28, 2024
1 parent c3128fc commit 558762e
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 30 deletions.
60 changes: 60 additions & 0 deletions src/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,51 @@ function withServer(action: (serverUrl: URL) => Promise<void>) {
} else if (url.pathname.startsWith("/code/")) {
const code = parseInt(url.pathname.replace(/^\/code\//, ""), 0);
return new Response(code.toString(), { status: code });
} else if (url.pathname.startsWith("/sleep/")) {
const ms = parseInt(url.pathname.replace(/^\/sleep\//, ""), 0);
const signal = request.signal;
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve(new Response("", { status: 200 }));
}, ms);
signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject(new Error("Client aborted."));
});
});
} else if (url.pathname.startsWith("/sleep-body/")) {
const ms = parseInt(url.pathname.replace(/^\/sleep-body\//, ""), 0);
const signal = request.signal;
const abortController = new AbortController();
return new Response(
new ReadableStream({
start(controller) {
return new Promise((resolve, reject) => {
if (signal.aborted || abortController.signal.aborted) {
return;
}
const timeoutId = setTimeout(() => {
controller.close();
resolve();
}, ms);
signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject(new Error("Client aborted."));
});
abortController.signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject(new Error("Client aborted."));
});
});
},
cancel(reason) {
abortController.abort(reason);
},
}),
{
status: 200,
},
);
} else {
return new Response("Not Found", { status: 404 });
}
Expand Down Expand Up @@ -234,6 +279,21 @@ Deno.test("$.request", (t) => {
);
});

step("ensure times out waiting for body", async () => {
const request = new RequestBuilder()
.url(new URL("/sleep-body/10000", serverUrl))
.timeout(50)
.showProgress();
const response = await request.fetch();
let caughtErr: unknown;
try {
await response.text();
} catch (err) {
caughtErr = err;
}
assertEquals(caughtErr, "Request timed out after 50 milliseconds.");
});

await Promise.all(steps);
});
});
112 changes: 82 additions & 30 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatMillis } from "./common.ts";
import { Delay, delayToMs, filterEmptyRecordValues, getFileNameFromUrl } from "./common.ts";
import { ProgressBar } from "./console/mod.ts";
import { PathRef } from "./path.ts";
Expand Down Expand Up @@ -336,20 +337,31 @@ export class RequestBuilder implements PromiseLike<RequestResult> {
}
}

interface Timeout {
signal: AbortSignal;
clear(): void;
}

/** Result of making a request. */
export class RequestResult {
#response: Response;
#downloadResponse: Response;
#originalUrl: string;
#timeout: Timeout | undefined;

/** @internal */
constructor(opts: {
response: Response;
originalUrl: string;
progressBar: ProgressBar | undefined;
timeout: Timeout | undefined;
}) {
this.#originalUrl = opts.originalUrl;
this.#response = opts.response;
this.#timeout = opts.timeout;
if (opts.response.body == null) {
this.#timeout?.clear();
}

if (opts.progressBar != null) {
const pb = opts.progressBar;
Expand All @@ -363,11 +375,17 @@ export class RequestResult {
try {
while (true) {
const { done, value } = await reader.read();
if (done || value == null) break;
if (done || value == null) {
break;
}
pb.increment(value.byteLength);
controller.enqueue(value);
}
controller.close();
if (opts.timeout?.signal?.aborted) {
controller.error(opts.timeout.signal.reason);
} else {
controller.close();
}
} finally {
reader.releaseLock();
pb.finish();
Expand Down Expand Up @@ -400,6 +418,11 @@ export class RequestResult {
return this.#response.redirected;
}

/** The underlying `AbortSignal` if a delay or signal was specified. */
get signal(): AbortSignal | undefined {
return this.#timeout?.signal;
}

/** Status code of the response. */
get status(): number {
return this.#response.status;
Expand Down Expand Up @@ -436,11 +459,15 @@ export class RequestResult {
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async arrayBuffer(): Promise<ArrayBuffer> {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
try {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
}
return this.#downloadResponse.arrayBuffer();
} finally {
this.#timeout?.clear();
}
return this.#downloadResponse.arrayBuffer();
}

/**
Expand All @@ -449,11 +476,15 @@ export class RequestResult {
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async blob(): Promise<Blob> {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
try {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
}
return await this.#downloadResponse.blob();
} finally {
this.#timeout?.clear();
}
return this.#downloadResponse.blob();
}

/**
Expand All @@ -462,11 +493,15 @@ export class RequestResult {
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async formData(): Promise<FormData> {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
try {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
}
return await this.#downloadResponse.formData();
} finally {
this.#timeout?.clear();
}
return this.#downloadResponse.formData();
}

/**
Expand All @@ -475,11 +510,15 @@ export class RequestResult {
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async json<TResult = any>(): Promise<TResult> {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined as any;
try {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined as any;
}
return await this.#downloadResponse.json();
} finally {
this.#timeout?.clear();
}
return this.#downloadResponse.json();
}

/**
Expand All @@ -488,19 +527,27 @@ export class RequestResult {
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async text(): Promise<string> {
if (this.#response.status === 404) {
// most people don't need to bother with this and if they do, they will
// need to opt-in with `noThrow()`. So just assert non-nullable
// to make it easier to work with and highlight this behaviour in the jsdocs.
await this.#response.body?.cancel();
return undefined!;
try {
if (this.#response.status === 404) {
// most people don't need to bother with this and if they do, they will
// need to opt-in with `noThrow()`. So just assert non-nullable
// to make it easier to work with and highlight this behaviour in the jsdocs.
await this.#response.body?.cancel();
return undefined!;
}
return await this.#downloadResponse.text();
} finally {
this.#timeout?.clear();
}
return this.#downloadResponse.text();
}

/** Pipes the response body to the provided writable stream. */
pipeTo(dest: WritableStream<Uint8Array>, options?: PipeOptions): Promise<void> {
return this.readable.pipeTo(dest, options);
async pipeTo(dest: WritableStream<Uint8Array>, options?: PipeOptions): Promise<void> {
try {
await this.readable.pipeTo(dest, options);
} finally {
this.#timeout?.clear();
}
}

/**
Expand Down Expand Up @@ -555,6 +602,7 @@ export class RequestResult {
} catch {
// do nothing
}
this.#timeout?.clear();
}
} catch (err) {
await this.#response.body?.cancel();
Expand Down Expand Up @@ -599,11 +647,11 @@ export async function makeRequest(state: RequestBuilderState) {
referrerPolicy: state.referrerPolicy,
signal: timeout?.signal,
});
timeout?.clear();
const result = new RequestResult({
response,
originalUrl: state.url.toString(),
progressBar: getProgressBar(),
timeout,
});
if (!state.noThrow) {
result.throwIfNotOk();
Expand Down Expand Up @@ -633,12 +681,16 @@ export async function makeRequest(state: RequestBuilderState) {
}
}

function getTimeout() {
function getTimeout(): Timeout | undefined {
if (state.timeout == null) {
return undefined;
}
const timeout = state.timeout;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), state.timeout);
const timeoutId = setTimeout(
() => controller.abort(`Request timed out after ${formatMillis(timeout)}.`),
timeout,
);
return {
signal: controller.signal,
clear() {
Expand Down

0 comments on commit 558762e

Please sign in to comment.