Skip to content

Commit

Permalink
feat(BREAKING): a timed out $.request now throws a TimeoutError w…
Browse files Browse the repository at this point in the history
…ith a stack (#236)
  • Loading branch information
dsherret authored Feb 4, 2024
1 parent ea75093 commit ccf8aed
Show file tree
Hide file tree
Showing 4 changed files with 60 additions and 37 deletions.
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { RequestBuilder, withProgressBarFactorySymbol } from "./src/request.ts";
import { createPathRef, PathRef } from "./src/path.ts";

export type { Delay, DelayIterator } from "./src/common.ts";
export { TimeoutError } from "./src/common.ts";
export { FsFileWrapper, PathRef } from "./src/path.ts";
export type { ExpandGlobOptions, PathSymlinkOptions, SymlinkOptions, WalkEntry, WalkOptions } from "./src/path.ts";
export {
Expand Down
11 changes: 11 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export const symbols = {
readable: Symbol.for("dax.readableStream"),
};

/** A timeout error. */
export class TimeoutError extends Error {
constructor(message: string) {
super(message);
}

get name() {
return "TimeoutError";
}
}

/**
* Delay used for certain actions.
*
Expand Down
9 changes: 6 additions & 3 deletions src/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Buffer, path } from "./deps.ts";
import { assertEquals, assertRejects, toWritableStream } from "./deps.test.ts";
import { RequestBuilder } from "./request.ts";
import $ from "../mod.ts";
import { TimeoutError } from "./common.ts";
import { assert } from "./deps.test.ts";

function withServer(action: (serverUrl: URL) => Promise<void>) {
return new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -282,16 +284,17 @@ 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)
.timeout(100)
.showProgress();
const response = await request.fetch();
let caughtErr: unknown;
let caughtErr: TimeoutError | undefined;
try {
await response.text();
} catch (err) {
caughtErr = err;
}
assertEquals(caughtErr, "Request timed out after 50 milliseconds.");
assertEquals(caughtErr!, new TimeoutError("Request timed out after 100 milliseconds."));
assert(caughtErr!.stack!.includes("request.test.ts")); // current file
});

step("ability to abort while waiting", async () => {
Expand Down
76 changes: 42 additions & 34 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatMillis, symbols } from "./common.ts";
import { TimeoutError } 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 @@ -131,7 +132,12 @@ export class RequestBuilder implements PromiseLike<RequestResponse> {

/** Fetches and gets the response. */
fetch(): Promise<RequestResponse> {
return makeRequest(this.#getClonedState());
return makeRequest(this.#getClonedState()).catch((err) => {
if (err instanceof TimeoutError) {
Error.captureStackTrace(err, TimeoutError);
}
return Promise.reject(err);
});
}

/** Specifies the URL to send the request to. */
Expand Down Expand Up @@ -276,7 +282,7 @@ export class RequestBuilder implements PromiseLike<RequestResponse> {
});
}

/** Timeout the request after the specified delay. */
/** Timeout the request after the specified delay throwing a `TimeoutError`. */
timeout(delay: Delay | undefined): RequestBuilder {
return this.#newWithState((state) => {
state.timeout = delay == null ? undefined : delayToMs(delay);
Expand Down Expand Up @@ -502,76 +508,68 @@ export class RequestResponse {
*
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async arrayBuffer(): Promise<ArrayBuffer> {
try {
arrayBuffer(): Promise<ArrayBuffer> {
return this.#withReturnHandling(async () => {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
}
return this.#downloadResponse.arrayBuffer();
} finally {
this.#abortController?.clearTimeout();
}
});
}

/**
* Response body as a blog.
*
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async blob(): Promise<Blob> {
try {
blob(): Promise<Blob> {
return this.#withReturnHandling(async () => {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
}
return await this.#downloadResponse.blob();
} finally {
this.#abortController?.clearTimeout();
}
});
}

/**
* Response body as a form data.
*
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async formData(): Promise<FormData> {
try {
formData(): Promise<FormData> {
return this.#withReturnHandling(async () => {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined!;
}
return await this.#downloadResponse.formData();
} finally {
this.#abortController?.clearTimeout();
}
});
}

/**
* Respose body as JSON.
*
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async json<TResult = any>(): Promise<TResult> {
try {
json<TResult = any>(): Promise<TResult> {
return this.#withReturnHandling(async () => {
if (this.#response.status === 404) {
await this.#response.body?.cancel();
return undefined as any;
}
return await this.#downloadResponse.json();
} finally {
this.#abortController?.clearTimeout();
}
});
}

/**
* Respose body as text.
*
* Note: Returns `undefined` when `.noThrow(404)` and status code is 404.
*/
async text(): Promise<string> {
try {
text(): Promise<string> {
return this.#withReturnHandling(async () => {
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
Expand All @@ -580,18 +578,12 @@ export class RequestResponse {
return undefined!;
}
return await this.#downloadResponse.text();
} finally {
this.#abortController?.clearTimeout();
}
});
}

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

/**
Expand Down Expand Up @@ -671,6 +663,22 @@ export class RequestResponse {
}
return body;
}

async #withReturnHandling<T>(action: () => Promise<T>): Promise<T> {
try {
return await action();
} catch (err) {
if (err instanceof TimeoutError) {
// give the timeout error a better stack trace because
// otherwise it will have the stack where it was aborted
// which isn't very useful
Error.captureStackTrace(err);
}
throw err;
} finally {
this.#abortController.clearTimeout();
}
}
}

export async function makeRequest(state: RequestBuilderState) {
Expand Down Expand Up @@ -737,7 +745,7 @@ export async function makeRequest(state: RequestBuilderState) {
const timeout = state.timeout;
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(`Request timed out after ${formatMillis(timeout)}.`),
() => controller.abort(new TimeoutError(`Request timed out after ${formatMillis(timeout)}.`)),
timeout,
);
return {
Expand Down

0 comments on commit ccf8aed

Please sign in to comment.