Skip to content

Commit

Permalink
feat: support timeout and AbortController (#268)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <[email protected]>
  • Loading branch information
picunada and pi0 authored Aug 22, 2023
1 parent 2be558c commit 98396ec
Show file tree
Hide file tree
Showing 5 changed files with 59 additions and 7 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ await ofetch("http://google.com/404", {
});
```

## ✔️ Timeout

You can specify `timeout` in milliseconds to automatically abort request after a timeout (default is disabled).

```ts
await ofetch("http://google.com/404", {
timeout: 3000, // Timeout after 3 seconds
});
```

## ✔️ Type Friendly

Response can be type assisted:
Expand Down
25 changes: 21 additions & 4 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface CreateFetchOptions {
defaults?: FetchOptions;
fetch?: Fetch;
Headers?: typeof Headers;
AbortController?: typeof AbortController;
}

export type FetchRequest = RequestInfo;
Expand Down Expand Up @@ -45,7 +46,8 @@ export interface FetchOptions<R extends ResponseType = ResponseType>
responseType?: R;
response?: boolean;
retry?: number | false;

/** timeout in milliseconds */
timeout?: number;
/** Delay between retries in milliseconds. */
retryDelay?: number;

Expand Down Expand Up @@ -87,15 +89,21 @@ const retryStatusCodes = new Set([
]);

export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
const { fetch = globalThis.fetch, Headers = globalThis.Headers } =
globalOptions;
const {
fetch = globalThis.fetch,
Headers = globalThis.Headers,
AbortController = globalThis.AbortController,
} = globalOptions;

async function onError(context: FetchContext): Promise<FetchResponse<any>> {
// Is Abort
// If it is an active abort, it will not retry automatically.
// https://developer.mozilla.org/en-US/docs/Web/API/DOMException#error_names
const isAbort =
(context.error && context.error.name === "AbortError") || false;
(context.error &&
context.error.name === "AbortError" &&
!context.options.timeout) ||
false;
// Retry
if (context.options.retry !== false && !isAbort) {
let retries;
Expand All @@ -111,9 +119,11 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
if (retryDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
// Timeout
return $fetchRaw(context.request, {
...context.options,
retry: retries - 1,
timeout: context.options.timeout,
});
}
}
Expand Down Expand Up @@ -184,6 +194,13 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
}
}

// TODO: Can we merge signals?
if (!context.options.signal && context.options.timeout) {
const controller = new AbortController();
setTimeout(() => controller.abort(), context.options.timeout);
context.options.signal = controller.signal;
}

try {
context.response = await fetch(
context.request,
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const fetch =
(() => Promise.reject(new Error("[ofetch] global.fetch is not supported!")));

export const Headers = _globalThis.Headers;
export const AbortController = _globalThis.AbortController;

export const ofetch = createFetch({ fetch, Headers });
export const ofetch = createFetch({ fetch, Headers, AbortController });
export const $fetch = ofetch;
8 changes: 6 additions & 2 deletions src/node.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import http from "node:http";
import https, { AgentOptions } from "node:https";
import nodeFetch, { Headers as _Headers } from "node-fetch-native";
import nodeFetch, {
Headers as _Headers,
AbortController as _AbortController,
} from "node-fetch-native";

import { createFetch } from "./base";

Expand Down Expand Up @@ -33,6 +36,7 @@ export function createNodeFetch() {
export const fetch = globalThis.fetch || createNodeFetch();

export const Headers = globalThis.Headers || _Headers;
export const AbortController = globalThis.AbortController || _AbortController;

export const ofetch = createFetch({ fetch, Headers });
export const ofetch = createFetch({ fetch, Headers, AbortController });
export const $fetch = ofetch;
20 changes: 20 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ describe("ofetch", () => {
.use(
"/408",
eventHandler(() => createError({ status: 408 }))
)
.use(
"/timeout",
eventHandler(async () => {
await new Promise((resolve) => {
setTimeout(() => {
resolve(createError({ status: 408 }));
}, 1000 * 5);
});
})
);

listener = await listen(toNodeListener(app));
Expand Down Expand Up @@ -229,6 +239,16 @@ describe("ofetch", () => {
expect(abortHandle()).rejects.toThrow(/aborted/);
});

it("aborting on timeout", async () => {
const noTimeout = $fetch(getURL("timeout")).catch(() => "no timeout");
const timeout = $fetch(getURL("timeout"), {
timeout: 100,
retry: 0,
}).catch(() => "timeout");
const race = await Promise.race([noTimeout, timeout]);
expect(race).to.equal("timeout");
});

it("deep merges defaultOptions", async () => {
const _customFetch = $fetch.create({
query: {
Expand Down

0 comments on commit 98396ec

Please sign in to comment.