Skip to content

Commit

Permalink
Make calculateDelay promisable (#1266)
Browse files Browse the repository at this point in the history
  • Loading branch information
Giotino committed May 16, 2020
1 parent df333dd commit 3745efc
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 20 deletions.
4 changes: 2 additions & 2 deletions readme.md
Expand Up @@ -412,7 +412,7 @@ This also accepts an `object` with the following fields to constrain the duratio
Type: `number | object`\
Default:
- limit: `2`
- calculateDelay: `({attemptCount, retryOptions, error, computedValue}) => computedValue`
- calculateDelay: `({attemptCount, retryOptions, error, computedValue}) => computedValue | Promise<computedValue>`
- 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) [`521`](https://support.cloudflare.com/hc/en-us/articles/115003011431#521error) [`522`](https://support.cloudflare.com/hc/en-us/articles/115003011431#522error) [`524`](https://support.cloudflare.com/hc/en-us/articles/115003011431#524error)
- maxRetryAfter: `undefined`
Expand All @@ -427,7 +427,7 @@ If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Ret

Delays between retries counts with function `1000 * Math.pow(2, retry) + Math.random() * 100`, where `retry` is attempt number (starts from 1).

The `calculateDelay` property is a `function` that receives an object with `attemptCount`, `retryOptions`, `error` and `computedValue` properties for current retry count, the retry options, error and default computed value. The function must return a delay in milliseconds (`0` return value cancels retry).
The `calculateDelay` property is a `function` that receives an object with `attemptCount`, `retryOptions`, `error` and `computedValue` properties for current retry count, the retry options, error and default computed value. The function must return a delay in milliseconds (or a Promise resolving with it) (`0` return value cancels retry).

By default, it retries *only* on the specified methods, status codes, and on these network errors:
- `ETIMEDOUT`: One of the [timeout](#timeout) limits were reached.
Expand Down
4 changes: 3 additions & 1 deletion source/as-promise/calculate-retry-delay.ts
Expand Up @@ -6,13 +6,15 @@ import {
RetryFunction
} from './types';

type Returns<T extends (...args: any) => unknown, V> = (...args: Parameters<T>) => V;

const retryAfterStatusCodes: ReadonlySet<number> = new Set([413, 429, 503]);

const isErrorWithResponse = (error: RetryObject['error']): error is HTTPError | ParseError | MaxRedirectsError => (
error instanceof HTTPError || error instanceof ParseError || error instanceof MaxRedirectsError
);

const calculateRetryDelay: RetryFunction = ({attemptCount, retryOptions, error}) => {
const calculateRetryDelay: Returns<RetryFunction, number> = ({attemptCount, retryOptions, error}) => {
if (attemptCount > retryOptions.limit) {
return 0;
}
Expand Down
15 changes: 10 additions & 5 deletions source/as-promise/index.ts
Expand Up @@ -58,7 +58,7 @@ export default function asPromise<T>(options: NormalizedOptions): CancelableRequ

globalRequest = request;

request.once('response', async (response: Response) => {
const onResponse = async (response: Response) => {
response.retryCount = retryCount;

if (response.request.aborted) {
Expand Down Expand Up @@ -146,9 +146,11 @@ export default function asPromise<T>(options: NormalizedOptions): CancelableRequ
globalResponse = response;

resolve(options.resolveBodyOnly ? response.body as T : response as unknown as T);
});
};

request.once('error', (error: RequestError) => {
request.once('response', onResponse);

request.once('error', async (error: RequestError) => {
if (promise.isCanceled) {
return;
}
Expand All @@ -158,12 +160,14 @@ export default function asPromise<T>(options: NormalizedOptions): CancelableRequ
return;
}

request.off('response', onResponse);

let backoff: number;

retryCount++;

try {
backoff = options.retry.calculateDelay({
backoff = await options.retry.calculateDelay({
attemptCount: retryCount,
retryOptions: options.retry,
error,
Expand Down Expand Up @@ -213,7 +217,8 @@ export default function asPromise<T>(options: NormalizedOptions): CancelableRequ
retryCount--;

if (error instanceof HTTPError) {
// It will be handled by the `response` event
// The error will be handled by the `response` event
onResponse(request._response as Response);
return;
}

Expand Down
2 changes: 1 addition & 1 deletion source/as-promise/types.ts
Expand Up @@ -51,7 +51,7 @@ export interface RetryObject {
computedValue: number;
}

export type RetryFunction = (retryObject: RetryObject) => number;
export type RetryFunction = (retryObject: RetryObject) => number | Promise<number>;

export interface RequiredRetryOptions {
limit: number;
Expand Down
27 changes: 18 additions & 9 deletions source/core/index.ts
Expand Up @@ -104,7 +104,7 @@ export type HookEvent = 'init' | 'beforeRequest' | 'beforeRedirect' | 'beforeErr

export const knownHookEvents: HookEvent[] = ['init', 'beforeRequest', 'beforeRedirect', 'beforeError'];

type AcceptableResponse = IncomingMessage | ResponseLike;
type AcceptableResponse = IncomingMessageWithTimings | ResponseLike;
type AcceptableRequestResult = AcceptableResponse | ClientRequest | Promise<AcceptableResponse | ClientRequest> | undefined;

export type RequestFunction = (url: URL, options: RequestOptions, callback?: (response: AcceptableResponse) => void) => AcceptableRequestResult;
Expand Down Expand Up @@ -462,8 +462,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
[kStartedReading]?: boolean;
[kCancelTimeouts]?: () => void;
[kResponseSize]?: number;
[kResponse]?: IncomingMessage;
[kOriginalResponse]?: IncomingMessage;
[kResponse]?: IncomingMessageWithTimings;
[kOriginalResponse]?: IncomingMessageWithTimings;
[kRequest]?: ClientRequest;
_noPipe?: boolean;
_progressCallbacks: Array<() => void>;
Expand Down Expand Up @@ -772,7 +772,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
if (cache) {
if (!cacheableStore.has(cache)) {
cacheableStore.set(cache, new CacheableRequest(
((requestOptions: RequestOptions, handler?: (response: IncomingMessage) => void): ClientRequest => (requestOptions as Pick<NormalizedOptions, typeof kRequest>)[kRequest](requestOptions, handler)) as HttpRequestFunction,
((requestOptions: RequestOptions, handler?: (response: IncomingMessageWithTimings) => void): ClientRequest => (requestOptions as Pick<NormalizedOptions, typeof kRequest>)[kRequest](requestOptions, handler)) as HttpRequestFunction,
cache as CacheableRequest.StorageAdapter
));
}
Expand Down Expand Up @@ -954,7 +954,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
this[kBodySize] = Number(headers['content-length']) || undefined;
}

async _onResponse(response: IncomingMessage): Promise<void> {
async _onResponse(response: IncomingMessageWithTimings): Promise<void> {
const {options} = this;
const {url} = options;

Expand Down Expand Up @@ -1167,7 +1167,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {

const responseEventName = options.cache ? 'cacheableResponse' : 'response';

request.once(responseEventName, (response: IncomingMessage) => {
request.once(responseEventName, (response: IncomingMessageWithTimings) => {
this._onResponse(response);
});

Expand Down Expand Up @@ -1231,7 +1231,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {

// This is ugly
const cacheRequest = cacheableStore.get((options as any).cache)!(options, response => {
const typedResponse = response as unknown as IncomingMessage & {req: ClientRequest};
const typedResponse = response as unknown as IncomingMessageWithTimings & {req: ClientRequest};
const {req} = typedResponse;

// TODO: Fix `cacheable-response`
Expand Down Expand Up @@ -1351,14 +1351,14 @@ export default class Request extends Duplex implements RequestEvents<Request> {
// Emit the response after the stream has been ended
} else if (this.writable) {
this.once('finish', () => {
this._onResponse(requestOrResponse as IncomingMessage);
this._onResponse(requestOrResponse as IncomingMessageWithTimings);
});

this._unlockWrite();
this.end();
this._lockWrite();
} else {
this._onResponse(requestOrResponse as IncomingMessage);
this._onResponse(requestOrResponse as IncomingMessageWithTimings);
}
} catch (error) {
if (error instanceof CacheableRequest.CacheError) {
Expand Down Expand Up @@ -1477,6 +1477,11 @@ export default class Request extends Duplex implements RequestEvents<Request> {
return;
}

if (this[kRequest]!.destroyed) {
callback();
return;
}

this[kRequest]!.end((error?: Error | null) => {
if (!error) {
this[kBodySize] = this[kUploadedSize];
Expand Down Expand Up @@ -1568,6 +1573,10 @@ export default class Request extends Duplex implements RequestEvents<Request> {
return this[kIsFromCache];
}

get _response(): Response | undefined {
return this[kResponse] as Response;
}

pipe<T extends NodeJS.WritableStream>(destination: T, options?: {end?: boolean}): T {
if (this[kStartedReading]) {
throw new Error('Failed to pipe. The response has been emitted already.');
Expand Down
38 changes: 36 additions & 2 deletions test/retry.ts
Expand Up @@ -116,9 +116,43 @@ test('custom retries', withServer, async (t, server, got) => {
}

return 0;
}, methods: [
},
methods: [
'GET'
],
statusCodes: [
500
]
}
}));
t.is(error.response.statusCode, 500);
t.true(hasTried);
});

test('custom retries async', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 500;
response.end();
});

let hasTried = false;
const error = await t.throwsAsync<HTTPError>(got({
throwHttpErrors: true,
retry: {
calculateDelay: async ({attemptCount}) => {
/* eslint-disable-next-line promise/param-names */
await new Promise((resolve, _) => setTimeout(resolve, 1000));
if (attemptCount === 1) {
hasTried = true;
return 1;
}

return 0;
},
methods: [
'GET'
], statusCodes: [
],
statusCodes: [
500
]
}
Expand Down

0 comments on commit 3745efc

Please sign in to comment.