From ab19bafa8478bddb660dc52b968a2b2a525d261d Mon Sep 17 00:00:00 2001 From: Frank van Wijk Date: Mon, 12 Dec 2022 22:17:53 +0100 Subject: [PATCH] Add `retry.backoffLimit` option (#454) Co-authored-by: Sindre Sorhus --- readme.md | 8 ++++-- source/core/Ky.ts | 2 +- source/types/retry.ts | 16 +++++++++++ source/utils/normalize.ts | 1 + test/retry.ts | 59 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index 30c47d88..e7dee17d 100644 --- a/readme.md +++ b/readme.md @@ -170,6 +170,7 @@ Default: - `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) - `maxRetryAfter`: `undefined` +- `backoffLimit`: `undefined` 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. @@ -177,7 +178,9 @@ If `retry` is a number, it will be used as `limit` and other defaults will remai 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. -Delays between retries is calculated with the function `0.3 * (2 ** (retry - 1)) * 1000`, where `retry` is the attempt number (starts from 1). +The `backoffLimit` option is the upper limit of the delay per retry in milliseconds. +To clamp the delay, set `backoffLimit` to 1000, for example. +By default, the delay is calculated with `0.3 * (2 ** (attemptCount - 1)) * 1000`. The delay increases exponentially. Retries are not triggered following a [timeout](#timeout). @@ -188,7 +191,8 @@ const json = await ky('https://example.com', { retry: { limit: 10, methods: ['get'], - statusCodes: [413] + statusCodes: [413], + backoffLimit: 3000 } }).json(); ``` diff --git a/source/core/Ky.ts b/source/core/Ky.ts index b793bc4e..5662f45e 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -235,7 +235,7 @@ export class Ky { } const BACKOFF_FACTOR = 0.3; - return BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000; + return Math.min(this._options.retry.backoffLimit, BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000); } return 0; diff --git a/source/types/retry.ts b/source/types/retry.ts index 6449168e..746a1ec6 100644 --- a/source/types/retry.ts +++ b/source/types/retry.ts @@ -33,4 +33,20 @@ export interface RetryOptions { @default Infinity */ maxRetryAfter?: number; + + /** + The upper limit of the delay per retry in milliseconds. + To clamp the delay, set `backoffLimit` to 1000, for example. + + By default, the delay is calculated in the following way: + + ``` + 0.3 * (2 ** (attemptCount - 1)) * 1000 + ``` + + The delay increases exponentially. + + @default Infinity + */ + backoffLimit?: number; } diff --git a/source/utils/normalize.ts b/source/utils/normalize.ts index 9f8c9dbb..504c575d 100644 --- a/source/utils/normalize.ts +++ b/source/utils/normalize.ts @@ -17,6 +17,7 @@ const defaultRetryOptions: Required = { statusCodes: retryStatusCodes, afterStatusCodes: retryAfterStatusCodes, maxRetryAfter: Number.POSITIVE_INFINITY, + backoffLimit: Number.POSITIVE_INFINITY, }; export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Required => { diff --git a/test/retry.ts b/test/retry.ts index 5b59655f..5dfefc27 100644 --- a/test/retry.ts +++ b/test/retry.ts @@ -1,3 +1,5 @@ +import {performance, PerformanceObserver} from 'node:perf_hooks'; +import process from 'node:process'; import test from 'ava'; import ky from '../source/index.js'; import {createHttpTestServer} from './helpers/create-http-test-server.js'; @@ -440,3 +442,60 @@ test('throws when retry.statusCodes is not an array', async t => { await server.close(); }); + +test('respect maximum backoff', async t => { + const retryCount = 5; + let requestCount = 0; + + const server = await createHttpTestServer(); + server.get('/', (_request, response) => { + requestCount++; + + if (requestCount === retryCount) { + response.end(fixture); + } else { + response.sendStatus(500); + } + }); + + // We allow the test to take more time on CI than locally, to reduce flakiness + const allowedOffset = process.env.CI ? 1000 : 300; + + // Register observer that asserts on duration when a measurement is performed + const obs = new PerformanceObserver(items => { + const measurements = items.getEntries(); + + const duration = measurements[0].duration ?? Number.NaN; + const expectedDuration = {default: 300 + 600 + 1200 + 2400, custom: 300 + 600 + 1000 + 1000}[measurements[0].name] ?? Number.NaN; + + t.true(Math.abs(duration - expectedDuration) < allowedOffset, `Duration of ${duration}ms is not close to expected duration ${expectedDuration}ms`); // Allow for 300ms difference + + if (measurements[0].name === 'custom') { + obs.disconnect(); + } + }); + obs.observe({entryTypes: ['measure']}); + + // Start measuring + performance.mark('start'); + t.is(await ky(server.url, { + retry: retryCount, + }).text(), fixture); + performance.mark('end'); + + performance.mark('start-custom'); + requestCount = 0; + t.is(await ky(server.url, { + retry: { + limit: retryCount, + backoffLimit: 1000, + }, + }).text(), fixture); + + performance.mark('end-custom'); + + performance.measure('default', 'start', 'end'); + performance.measure('custom', 'start-custom', 'end-custom'); + + await server.close(); +});