Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…le-retry-after-statuses into kesills-better-extend-hooks

Manual resolutions required:

- Conflicts between my updated `extend()` tests in `test/main.ts`
  and the tests added with the new function form of `extend()`. Resolved by
  accepting their modified base test and just adding my changes, then putting my
  new `extend()` test below all their new cases.
- Trivial resolution for import formatting in `source/core/Ky.ts`.
- A change to `newHookValue` in `source/utils/merge.ts`, using
  `Object.hasOwn` per the linter's suggestion.
  • Loading branch information
Kenneth-Sills committed Jul 26, 2024
2 parents 7947d02 + 786a9de commit ca1ca72
Show file tree
Hide file tree
Showing 21 changed files with 406 additions and 206 deletions.
23 changes: 12 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ky",
"version": "1.3.0",
"description": "Tiny and elegant HTTP client based on the browser Fetch API",
"version": "1.5.0",
"description": "Tiny and elegant HTTP client based on the Fetch API",
"license": "MIT",
"repository": "sindresorhus/ky",
"funding": "https://github.com/sindresorhus/ky?sponsor=1",
Expand All @@ -22,7 +22,7 @@
"node": ">=18"
},
"scripts": {
"test": "xo && npm run build && ava --timeout=10m --serial",
"test": "xo && npm run build && ava",
"debug": "PWDEBUG=1 ava --timeout=2m",
"release": "np",
"build": "del-cli distribution && tsc --project tsconfig.dist.json",
Expand Down Expand Up @@ -54,25 +54,25 @@
"node-fetch"
],
"devDependencies": {
"@sindresorhus/tsconfig": "^4.0.0",
"@sindresorhus/tsconfig": "^6.0.0",
"@type-challenges/utils": "^0.1.1",
"@types/body-parser": "^1.19.2",
"@types/busboy": "^1.5.0",
"@types/express": "^4.17.17",
"@types/node": "^20.5.7",
"@types/node": "^20.14.12",
"ava": "^5.3.1",
"body-parser": "^1.20.2",
"busboy": "^1.6.0",
"del-cli": "^5.1.0",
"delay": "^6.0.0",
"expect-type": "^0.16.0",
"expect-type": "^0.19.0",
"express": "^4.18.2",
"pify": "^6.1.0",
"playwright": "^1.40.1",
"playwright": "^1.45.3",
"raw-body": "^2.5.2",
"tsx": "^4.7.0",
"typescript": "^5.2.2",
"xo": "^0.56.0"
"tsx": "^4.16.2",
"typescript": "^5.5.4",
"xo": "^0.58.0"
},
"xo": {
"envs": [
Expand All @@ -85,7 +85,8 @@
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/naming-convention": "off"
"@typescript-eslint/naming-convention": "off",
"n/no-unsupported-features/node-builtins": "off"
}
},
"ava": {
Expand Down
33 changes: 27 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@
<br>
</div>

> Ky is a tiny and elegant HTTP client based on the browser [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
> Ky is a tiny and elegant HTTP client based on the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
[![Coverage Status](https://codecov.io/gh/sindresorhus/ky/branch/main/graph/badge.svg)](https://codecov.io/gh/sindresorhus/ky)
[![](https://badgen.net/bundlephobia/minzip/ky)](https://bundlephobia.com/result?p=ky)

Ky targets [modern browsers](#browser-support), Node.js, and Deno.
Ky targets [modern browsers](#browser-support), Node.js, Bun, and Deno.

It's just a tiny file with no dependencies.
It's just a tiny package with no dependencies.

## Benefits over plain `fetch`

Expand All @@ -71,6 +71,7 @@ It's just a tiny file with no dependencies.
- URL prefix option
- Instances with custom defaults
- Hooks
- TypeScript niceties (e.g. `.json()` resolves to `unknown`, not `any`; `.json<T>()` can be used too)

## Install

Expand Down Expand Up @@ -212,15 +213,18 @@ Default:
- `limit`: `2`
- `methods`: `get` `put` `head` `delete` `options` `trace`
- `statusCodes`: [`408`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413) [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) [`500`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) [`502`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) [`504`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
- `afterStatusCodes`: [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413), [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429), [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503)
- `maxRetryAfter`: `undefined`
- `backoffLimit`: `undefined`
- `delay`: `attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000`

An object representing `limit`, `methods`, `statusCodes` and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.
An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.

If `retry` is a number, it will be used as `limit` and other defaults will remain in place.

If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`. If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header is greater than `maxRetryAfter`, it will cancel the request.
If the response provides an HTTP status contained in `afterStatusCodes`, Ky will wait until the date or timeout given in the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header has passed to retry the request. If the provided status code is not in the list, the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header will be ignored.

If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`. If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header is greater than `maxRetryAfter`, it will use `maxRetryAfter`.

The `backoffLimit` option is the upper limit of the delay per retry in milliseconds.
To clamp the delay, set `backoffLimit` to 1000, for example.
Expand Down Expand Up @@ -522,6 +526,22 @@ console.log('unicorn' in response);
//=> true
```

You can also refer to parent defaults by providing a function to `.extend()`.

```js
import ky from 'ky';

const api = ky.create({prefixUrl: 'https://example.com/api'});

const usersApi = api.extend((options) => ({prefixUrl: `${options.prefixUrl}/users`}));

const response = await usersApi.get('123');
//=> 'https://example.com/api/users/123'

const response = await api.get('version');
//=> 'https://example.com/api/version'
```

### ky.create(defaultOptions)

Create a new Ky instance with complete new defaults.
Expand Down Expand Up @@ -697,7 +717,7 @@ import ky from 'https://unpkg.com/ky/distribution/index.js';
const json = await ky('https://jsonplaceholder.typicode.com/todos/1').json();
console.log(json.title);
//=> 'delectus aut autem
//=> 'delectus aut autem'
</script>
```

Expand Down Expand Up @@ -729,6 +749,7 @@ Node.js 18 and later.

## Related

- [fetch-extras](https://github.com/sindresorhus/fetch-extras) - Useful utilities for working with Fetch
- [got](https://github.com/sindresorhus/got) - Simplified HTTP requests for Node.js
- [ky-hooks-change-case](https://github.com/alice-health/ky-hooks-change-case) - Ky hooks to modify cases on requests and responses of objects

Expand Down
106 changes: 55 additions & 51 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {HTTPError} from '../errors/HTTPError.js';
import {TimeoutError} from '../errors/TimeoutError.js';
import type {Input, InternalOptions, NormalizedOptions, Options, SearchParamsInit} from '../types/options.js';
import type {
Input,
InternalOptions,
NormalizedOptions,
Options,
SearchParamsInit,
} from '../types/options.js';
import {type ResponsePromise} from '../types/ResponsePromise.js';
import {mergeHeaders, mergeHooks} from '../utils/merge.js';
import {normalizeRequestMethod, normalizeRetryOptions} from '../utils/normalize.js';
Expand Down Expand Up @@ -178,6 +184,11 @@ export class Ky {
this._options.duplex = 'half';
}

if (this._options.json !== undefined) {
this._options.body = this._options.stringifyJson?.(this._options.json) ?? JSON.stringify(this._options.json);
this._options.headers.set('content-type', this._options.headers.get('content-type') ?? 'application/json');
}

this.request = new globalThis.Request(this._input, this._options);

if (this._options.searchParams) {
Expand All @@ -200,49 +211,38 @@ export class Ky {
// The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws.
this.request = new globalThis.Request(new globalThis.Request(url, {...this.request}), this._options as RequestInit);
}

if (this._options.json !== undefined) {
this._options.body = this._options.stringifyJson?.(this._options.json) ?? JSON.stringify(this._options.json);
this.request.headers.set('content-type', this._options.headers.get('content-type') ?? 'application/json');
this.request = new globalThis.Request(this.request, {body: this._options.body});
}
}

protected _calculateRetryDelay(error: unknown) {
this._retryCount++;

if (this._retryCount <= this._options.retry.limit && !(error instanceof TimeoutError)) {
if (error instanceof HTTPError) {
if (!this._options.retry.statusCodes.includes(error.response.status)) {
return 0;
}

const retryAfter = error.response.headers.get('Retry-After');
if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) {
let after = Number(retryAfter);
if (Number.isNaN(after)) {
after = Date.parse(retryAfter) - Date.now();
} else {
after *= 1000;
}
if (this._retryCount > this._options.retry.limit || error instanceof TimeoutError) {
throw error;
}

if (this._options.retry.maxRetryAfter !== undefined && after > this._options.retry.maxRetryAfter) {
return 0;
}
if (error instanceof HTTPError) {
if (!this._options.retry.statusCodes.includes(error.response.status)) {
throw error;
}

return after;
const retryAfter = error.response.headers.get('Retry-After');
if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) {
let after = Number(retryAfter) * 1000;
if (Number.isNaN(after)) {
after = Date.parse(retryAfter) - Date.now();
}

if (error.response.status === 413) {
return 0;
}
const max = this._options.retry.maxRetryAfter ?? after;
return after < max ? after : max;
}

const retryDelay = this._options.retry.delay(this._retryCount);
return Math.min(this._options.retry.backoffLimit, retryDelay);
if (error.response.status === 413) {
throw error;
}
}

return 0;
const retryDelay = this._options.retry.delay(this._retryCount);
return Math.min(this._options.retry.backoffLimit, retryDelay);
}

protected _decorateResponse(response: Response): Response {
Expand All @@ -258,28 +258,28 @@ export class Ky {
return await function_();
} catch (error) {
const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout);
if (ms !== 0 && this._retryCount > 0) {
await delay(ms, {signal: this._options.signal});
if (this._retryCount < 1) {
throw error;
}

for (const hook of this._options.hooks.beforeRetry) {
// eslint-disable-next-line no-await-in-loop
const hookResult = await hook({
request: this.request,
options: (this._options as unknown) as NormalizedOptions,
error: error as Error,
retryCount: this._retryCount,
});

// If `stop` is returned from the hook, the retry process is stopped
if (hookResult === stop) {
return;
}
}
await delay(ms, {signal: this._options.signal});

for (const hook of this._options.hooks.beforeRetry) {
// eslint-disable-next-line no-await-in-loop
const hookResult = await hook({
request: this.request,
options: (this._options as unknown) as NormalizedOptions,
error: error as Error,
retryCount: this._retryCount,
});

return this._retry(function_);
// If `stop` is returned from the hook, the retry process is stopped
if (hookResult === stop) {
return;
}
}

throw error;
return this._retry(function_);
}
}

Expand All @@ -300,11 +300,15 @@ export class Ky {

const nonRequestOptions = findUnknownOptions(this.request, this._options);

// Cloning is done here to prepare in advance for retries
const mainRequest = this.request;
this.request = mainRequest.clone();

if (this._options.timeout === false) {
return this._options.fetch(this.request.clone(), nonRequestOptions);
return this._options.fetch(mainRequest, nonRequestOptions);
}

return timeout(this.request.clone(), nonRequestOptions, this.abortController, this._options as TimeoutOptions);
return timeout(mainRequest, nonRequestOptions, this.abortController, this._options as TimeoutOptions);
}

/* istanbul ignore next */
Expand Down
27 changes: 18 additions & 9 deletions source/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@ export const supportsRequestStreams = (() => {
const supportsRequest = typeof globalThis.Request === 'function';

if (supportsReadableStream && supportsRequest) {
hasContentType = new globalThis.Request('https://empty.invalid', {
body: new globalThis.ReadableStream(),
method: 'POST',
// @ts-expect-error - Types are outdated.
get duplex() {
duplexAccessed = true;
return 'half';
},
}).headers.has('Content-Type');
try {
hasContentType = new globalThis.Request('https://empty.invalid', {
body: new globalThis.ReadableStream(),
method: 'POST',
// @ts-expect-error - Types are outdated.
get duplex() {
duplexAccessed = true;
return 'half';
},
}).headers.has('Content-Type');
} catch (error) {
// QQBrowser on iOS throws "unsupported BodyInit type" error (see issue #581)
if (error instanceof Error && error.message === 'unsupported BodyInit type') {
return false;
}

throw error;
}
}

return duplexAccessed && !hasContentType;
Expand Down
6 changes: 4 additions & 2 deletions source/errors/HTTPError.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {NormalizedOptions} from '../types/options.js';
import type {KyRequest} from '../types/request.js';
import type {KyResponse} from '../types/response.js';

// eslint-lint-disable-next-line @typescript-eslint/naming-convention
export class HTTPError extends Error {
public response: Response;
public request: Request;
public response: KyResponse;
public request: KyRequest;
public options: NormalizedOptions;

constructor(response: Response, request: Request, options: NormalizedOptions) {
Expand Down
4 changes: 3 additions & 1 deletion source/errors/TimeoutError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type {KyRequest} from '../types/request.js';

export class TimeoutError extends Error {
public request: Request;
public request: KyRequest;

constructor(request: Request) {
super(`Request timed out: ${request.method} ${request.url}`);
Expand Down
10 changes: 9 additions & 1 deletion source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ const createInstance = (defaults?: Partial<Options>): KyInstance => {
}

ky.create = (newDefaults?: Partial<Options>) => createInstance(validateAndMerge(newDefaults));
ky.extend = (newDefaults?: Partial<Options>) => createInstance(validateAndMerge(defaults, newDefaults));
ky.extend = (newDefaults?: Partial<Options> | ((parentDefaults: Partial<Options>) => Partial<Options>)) => {
if (typeof newDefaults === 'function') {
newDefaults = newDefaults(defaults ?? {});
}

return createInstance(validateAndMerge(defaults, newDefaults));
};

ky.stop = stop;

return ky as KyInstance;
Expand Down Expand Up @@ -48,6 +55,7 @@ export type {
} from './types/hooks.js';

export type {ResponsePromise} from './types/ResponsePromise.js';
export type {KyRequest} from './types/request.js';
export type {KyResponse} from './types/response.js';
export {HTTPError} from './errors/HTTPError.js';
export {TimeoutError} from './errors/TimeoutError.js';
Loading

0 comments on commit ca1ca72

Please sign in to comment.