├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.d.ts ├── index.js ├── license ├── media └── logo.jpg ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 22 14 | - 20 15 | - 18 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Custom error class for HTTP errors that should be thrown when the response has a non-200 status code. 3 | */ 4 | export class HttpError extends Error { 5 | readonly name: 'HttpError'; 6 | readonly code: 'ERR_HTTP_RESPONSE_NOT_OK'; 7 | response: Response; 8 | 9 | /** 10 | Constructs a new `HttpError` instance. 11 | 12 | @param response - The `Response` object that caused the error. 13 | */ 14 | constructor(response: Response); 15 | } 16 | 17 | /** 18 | Throws an `HttpError` if the response is not ok (non-200 status code). 19 | 20 | @param response - The `Response` object to check. 21 | @returns The same `Response` object if it is ok. 22 | @throws {HttpError} If the response is not ok. 23 | 24 | @example 25 | ``` 26 | import {throwIfHttpError} from 'fetch-extras'; 27 | 28 | const response = await fetch('/api'); 29 | throwIfHttpError(response); 30 | const data = await response.json(); 31 | ``` 32 | */ 33 | export function throwIfHttpError(response: Response): Response; 34 | 35 | /** 36 | Throws an `HttpError` if the response is not ok (non-200 status code). 37 | 38 | @param responsePromise - A promise that resolves to a `Response` object to check. 39 | @returns A promise that resolves to the same `Response` object if it is ok. 40 | @throws {HttpError} If the response is not ok. 41 | 42 | @example 43 | ``` 44 | import {throwIfHttpError} from 'fetch-extras'; 45 | 46 | const response = await throwIfHttpError(fetch('/api')); 47 | const data = await response.json(); 48 | ``` 49 | */ 50 | export function throwIfHttpError(responsePromise: Promise): Promise; 51 | 52 | /** 53 | Wraps a fetch function to automatically throw `HttpError` for non-200 responses. 54 | 55 | Can be combined with other `with*` methods. 56 | 57 | @param fetchFunction - The fetch function to wrap (usually the global `fetch`). 58 | @returns A wrapped fetch function that will throw HttpError for non-200 responses. 59 | 60 | @example 61 | ``` 62 | import {withHttpError} from 'fetch-extras'; 63 | 64 | const fetchWithError = withHttpError(fetch); 65 | const response = await fetchWithError('/api'); // Throws HttpError if status is not 200-299 66 | const data = await response.json(); 67 | ``` 68 | */ 69 | export function withHttpError( 70 | fetchFunction: typeof fetch 71 | ): typeof fetch; 72 | 73 | /** 74 | Wraps a fetch function with timeout functionality. 75 | 76 | Can be combined with other `with*` methods. 77 | 78 | @param fetchFunction - The fetch function to wrap (usually the global `fetch`). 79 | @param timeout - Timeout in milliseconds. 80 | @returns A wrapped fetch function that will abort if the request takes longer than the specified timeout. 81 | 82 | @example 83 | ``` 84 | import {withTimeout} from 'fetch-extras'; 85 | 86 | const fetchWithTimeout = withTimeout(fetch, 5000); 87 | const response = await fetchWithTimeout('/api'); 88 | const data = await response.json(); 89 | ``` 90 | */ 91 | export function withTimeout( 92 | fetchFunction: typeof fetch, 93 | timeout: number 94 | ): typeof fetch; 95 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | constructor(response) { 3 | const status = `${response.status} ${response.statusText}`.trim(); 4 | const reason = status ? `status code ${status}` : 'an unknown error'; 5 | 6 | super(`Request failed with ${reason}: ${response.url}`); 7 | Error.captureStackTrace?.(this, this.constructor); 8 | 9 | this.name = 'HttpError'; 10 | this.code = 'ERR_HTTP_RESPONSE_NOT_OK'; 11 | this.response = response; 12 | } 13 | } 14 | 15 | export async function throwIfHttpError(responseOrPromise) { 16 | if (!(responseOrPromise instanceof Response)) { 17 | responseOrPromise = await responseOrPromise; 18 | } 19 | 20 | if (!responseOrPromise.ok) { 21 | throw new HttpError(responseOrPromise); 22 | } 23 | 24 | return responseOrPromise; 25 | } 26 | 27 | export function withHttpError(fetchFunction) { 28 | return async (urlOrRequest, options = {}) => { 29 | const response = await fetchFunction(urlOrRequest, options); 30 | return throwIfHttpError(response); 31 | }; 32 | } 33 | 34 | export function withTimeout(fetchFunction, timeout) { 35 | return async (urlOrRequest, options = {}) => { 36 | const providedSignal = options.signal ?? (urlOrRequest instanceof Request && urlOrRequest.signal); 37 | const timeoutSignal = AbortSignal.timeout(timeout); 38 | const signal = providedSignal ? AbortSignal.any([providedSignal, timeoutSignal]) : timeoutSignal; 39 | return fetchFunction(urlOrRequest, {...options, signal}); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /media/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/fetch-extras/20c88a8c1626ea5c5c1d1826593b65b17cc35cdd/media/logo.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-extras", 3 | "version": "1.0.0", 4 | "description": "Useful utilities for working with Fetch", 5 | "license": "MIT", 6 | "repository": "sindresorhus/fetch-extras", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=18.18" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava && tsc index.d.ts" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "fetch", 31 | "whatwg", 32 | "api", 33 | "request", 34 | "response", 35 | "http", 36 | "client", 37 | "httperror", 38 | "utilities", 39 | "wrapper", 40 | "ky", 41 | "got", 42 | "axios" 43 | ], 44 | "devDependencies": { 45 | "ava": "^6.2.0", 46 | "typescript": "^5.7.3", 47 | "xo": "^0.60.0" 48 | }, 49 | "xo": { 50 | "rules": { 51 | "n/no-unsupported-features/node-builtins": "off" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | fetch-extras logo 3 |

4 | 5 | > Useful utilities for working with [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 6 | 7 | *For more features and conveniences on top of Fetch, check out my [`ky`](https://github.com/sindresorhus/ky) package.* 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install fetch-extras 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import {withHttpError, withTimeout} from 'fetch-extras'; 19 | 20 | // Create an enhanced reusable fetch function that: 21 | // - Throws errors for non-200 responses 22 | // - Times out after 5 seconds 23 | const enhancedFetch = withHttpError(withTimeout(fetch, 5000)); 24 | 25 | const response = await enhancedFetch('/api'); 26 | const data = await response.json(); 27 | ``` 28 | 29 | ## API 30 | 31 | See the [types](index.d.ts) for now. 32 | 33 | ## Related 34 | 35 | - [is-network-error](https://github.com/sindresorhus/is-network-error) - Check if a value is a Fetch network error 36 | - [ky](https://github.com/sindresorhus/ky) - HTTP client based on Fetch 37 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | HttpError, 4 | throwIfHttpError, 5 | withHttpError, 6 | withTimeout, 7 | } from './index.js'; 8 | 9 | const createBasicMockFetch = () => async url => { 10 | if (url === '/ok') { 11 | return { 12 | ok: true, 13 | status: 200, 14 | statusText: 'OK', 15 | url, 16 | }; 17 | } 18 | 19 | return { 20 | ok: false, 21 | status: 404, 22 | statusText: 'Not Found', 23 | url, 24 | }; 25 | }; 26 | 27 | const createTimedMockFetch = delay => async (url, options = {}) => { 28 | if (options.signal?.aborted) { 29 | const error = new Error('The operation was aborted'); 30 | error.name = 'AbortError'; 31 | throw error; 32 | } 33 | 34 | return new Promise((resolve, reject) => { 35 | const timer = setTimeout(() => { 36 | resolve({ 37 | ok: true, 38 | status: 200, 39 | statusText: 'OK', 40 | url, 41 | }); 42 | }, delay); 43 | 44 | options.signal?.addEventListener('abort', () => { 45 | clearTimeout(timer); 46 | const error = new Error('The operation was aborted'); 47 | error.name = 'AbortError'; 48 | reject(error); 49 | }); 50 | }); 51 | }; 52 | 53 | test('throwIfHttpError - should not throw for ok responses', async t => { 54 | const mockFetch = createBasicMockFetch(); 55 | const response = await mockFetch('/ok'); 56 | await t.notThrowsAsync(throwIfHttpError(response)); 57 | }); 58 | 59 | test('throwIfHttpError - should throw HttpError for non-ok responses', async t => { 60 | const mockFetch = createBasicMockFetch(); 61 | const response = await mockFetch('/not-found'); 62 | await t.throwsAsync(throwIfHttpError(response), {instanceOf: HttpError}); 63 | }); 64 | 65 | test('throwIfHttpError - should work with promise responses', async t => { 66 | const mockFetch = createBasicMockFetch(); 67 | await t.throwsAsync(throwIfHttpError(mockFetch('/not-found')), {instanceOf: HttpError}); 68 | }); 69 | 70 | test('withHttpError - should pass through successful responses', async t => { 71 | const mockFetch = createBasicMockFetch(); 72 | const fetchWithError = withHttpError(mockFetch); 73 | 74 | const response = await fetchWithError('/ok'); 75 | t.deepEqual(response, { 76 | ok: true, 77 | status: 200, 78 | statusText: 'OK', 79 | url: '/ok', 80 | }); 81 | }); 82 | 83 | test('withHttpError - should throw HttpError for error responses', async t => { 84 | const mockFetch = createBasicMockFetch(); 85 | const fetchWithError = withHttpError(mockFetch); 86 | 87 | const error = await t.throwsAsync(fetchWithError('/not-found'), {instanceOf: HttpError}); 88 | t.is(error.response.status, 404); 89 | }); 90 | 91 | test('withHttpError - can be combined with withTimeout', async t => { 92 | const mockFetch = createTimedMockFetch(50); 93 | const fetchWithTimeoutAndError = withHttpError(withTimeout(mockFetch, 1000)); 94 | 95 | const response = await fetchWithTimeoutAndError('/test'); 96 | t.deepEqual(response, { 97 | ok: true, 98 | status: 200, 99 | statusText: 'OK', 100 | url: '/test', 101 | }); 102 | }); 103 | 104 | test('withHttpError - throws HttpError even with timeout', async t => { 105 | const mockFetch = async url => ({ 106 | ok: false, 107 | status: 500, 108 | statusText: 'Internal Server Error', 109 | url, 110 | }); 111 | 112 | const fetchWithTimeoutAndError = withHttpError(withTimeout(mockFetch, 1000)); 113 | 114 | const error = await t.throwsAsync(fetchWithTimeoutAndError('/test'), {instanceOf: HttpError}); 115 | t.is(error.response.status, 500); 116 | }); 117 | 118 | test('withTimeout - should abort request after timeout', async t => { 119 | const slowFetch = createTimedMockFetch(200); 120 | const fetchWithTimeout = withTimeout(slowFetch, 100); 121 | 122 | await t.throwsAsync(fetchWithTimeout('/test'), {name: 'AbortError'}); 123 | }); 124 | 125 | test('withTimeout - should respect existing abort signal', async t => { 126 | const mockFetch = createTimedMockFetch(100); 127 | const fetchWithTimeout = withTimeout(mockFetch, 1000); 128 | const controller = new AbortController(); 129 | 130 | controller.abort(); 131 | 132 | await t.throwsAsync(fetchWithTimeout('/test', {signal: controller.signal}), {name: 'AbortError'}); 133 | }); 134 | 135 | test('withTimeout - should complete before timeout', async t => { 136 | const quickFetch = createTimedMockFetch(50); 137 | const fetchWithTimeout = withTimeout(quickFetch, 1000); 138 | 139 | const response = await fetchWithTimeout('/test'); 140 | t.deepEqual(response, { 141 | ok: true, 142 | status: 200, 143 | statusText: 'OK', 144 | url: '/test', 145 | }); 146 | }); 147 | --------------------------------------------------------------------------------