feat(fetch): maxRetries for fetch

Fixes https://github.com/microsoft/playwright/issues/30978
This commit is contained in:
Yury Semikhatsky 2024-06-19 14:16:28 -07:00
parent f6972c1e23
commit 1eb18daab0
11 changed files with 127 additions and 5 deletions

View file

@ -344,6 +344,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/
### option: APIRequestContext.fetch.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.fetch.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.fetch.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.get ## async method: APIRequestContext.get
* since: v1.16 * since: v1.16
- returns: <[APIResponse]> - returns: <[APIResponse]>
@ -433,6 +436,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query
### option: APIRequestContext.get.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.get.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.get.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.head ## async method: APIRequestContext.head
* since: v1.16 * since: v1.16
- returns: <[APIResponse]> - returns: <[APIResponse]>
@ -486,6 +492,9 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.head.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.head.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.head.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.patch ## async method: APIRequestContext.patch
* since: v1.16 * since: v1.16
- returns: <[APIResponse]> - returns: <[APIResponse]>
@ -539,6 +548,9 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.patch.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.patch.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.patch.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.post ## async method: APIRequestContext.post
* since: v1.16 * since: v1.16
- returns: <[APIResponse]> - returns: <[APIResponse]>
@ -713,6 +725,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar
### option: APIRequestContext.post.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.post.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.post.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.put ## async method: APIRequestContext.put
* since: v1.16 * since: v1.16
- returns: <[APIResponse]> - returns: <[APIResponse]>
@ -766,6 +781,9 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.put.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.put.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.put.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.storageState ## async method: APIRequestContext.storageState
* since: v1.16 * since: v1.16
- returns: <[Object]> - returns: <[Object]>

View file

@ -458,6 +458,12 @@ Whether to ignore HTTPS errors when sending network requests. Defaults to `false
Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is exceeded. Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is exceeded.
Defaults to `20`. Pass `0` to not follow redirects. Defaults to `20`. Pass `0` to not follow redirects.
## js-python-csharp-fetch-option-maxretries
* langs: js, python, csharp
- `maxRetries` <[int]>
Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
## evaluate-expression ## evaluate-expression
- `expression` <[string]> - `expression` <[string]>

View file

@ -41,6 +41,7 @@ export type FetchOptions = {
failOnStatusCode?: boolean, failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
maxRedirects?: number, maxRedirects?: number,
maxRetries?: number,
}; };
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'storageState' | 'tracesDir'> & { type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'storageState' | 'tracesDir'> & {
@ -168,11 +169,11 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
throw new TargetClosedError(this._closeReason); throw new TargetClosedError(this._closeReason);
assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request'); assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request');
assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`); assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`);
assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' should be greater than or equal to '0'`); assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' must be greater than or equal to '0'`);
assert(options.maxRetries === undefined || options.maxRetries >= 0, `'maxRetries' must be greater than or equal to '0'`);
const url = options.url !== undefined ? options.url : options.request!.url(); const url = options.url !== undefined ? options.url : options.request!.url();
const params = objectToArray(options.params); const params = objectToArray(options.params);
const method = options.method || options.request?.method(); const method = options.method || options.request?.method();
const maxRedirects = options.maxRedirects;
// Cannot call allHeaders() here as the request may be paused inside route handler. // Cannot call allHeaders() here as the request may be paused inside route handler.
const headersObj = options.headers || options.request?.headers() ; const headersObj = options.headers || options.request?.headers() ;
const headers = headersObj ? headersObjectToArray(headersObj) : undefined; const headers = headersObj ? headersObjectToArray(headersObj) : undefined;
@ -234,7 +235,8 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
timeout: options.timeout, timeout: options.timeout,
failOnStatusCode: options.failOnStatusCode, failOnStatusCode: options.failOnStatusCode,
ignoreHTTPSErrors: options.ignoreHTTPSErrors, ignoreHTTPSErrors: options.ignoreHTTPSErrors,
maxRedirects: maxRedirects, maxRedirects: options.maxRedirects,
maxRetries: options.maxRetries,
...fixtures ...fixtures
}); });
return new APIResponse(this, result.response); return new APIResponse(this, result.response);

View file

@ -336,7 +336,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
}); });
} }
async fetch(options: FallbackOverrides & { maxRedirects?: number, timeout?: number } = {}): Promise<APIResponse> { async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise<APIResponse> {
return await this._wrapApiCall(async () => { return await this._wrapApiCall(async () => {
return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options }); return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
}); });

View file

@ -182,6 +182,7 @@ scheme.APIRequestContextFetchParams = tObject({
failOnStatusCode: tOptional(tBoolean), failOnStatusCode: tOptional(tBoolean),
ignoreHTTPSErrors: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean),
maxRedirects: tOptional(tNumber), maxRedirects: tOptional(tNumber),
maxRetries: tOptional(tNumber),
}); });
scheme.APIRequestContextFetchResult = tObject({ scheme.APIRequestContextFetchResult = tObject({
response: tType('APIResponse'), response: tType('APIResponse'),

View file

@ -201,7 +201,7 @@ export abstract class APIRequestContext extends SdkObject {
setHeader(headers, 'content-length', String(postData.byteLength)); setHeader(headers, 'content-length', String(postData.byteLength));
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
const fetchResponse = await controller.run(progress => { const fetchResponse = await controller.run(progress => {
return this._sendRequest(progress, requestUrl, options, postData); return this._sendRequestWithRetries(progress, requestUrl, options, postData);
}); });
const fetchUid = this._storeResponseBody(fetchResponse.body); const fetchUid = this._storeResponseBody(fetchResponse.body);
this.fetchLog.set(fetchUid, controller.metadata.log); this.fetchLog.set(fetchUid, controller.metadata.log);
@ -247,6 +247,26 @@ export abstract class APIRequestContext extends SdkObject {
} }
} }
private async _sendRequestWithRetries(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise<Omit<channels.APIResponse, 'fetchUid'> & { body: Buffer }>{
const maxRetries = 5;
let backoff = 250;
for (let i = 0; i <= maxRetries; i++) {
try {
return await this._sendRequest(progress, url, options, postData);
} catch (e) {
if (i === maxRetries - 1 || (options.deadline && monotonicTime() + backoff > options.deadline))
throw new Error(`Failed after ${i + 1} attempt(s): ${e}`);
// Retry on connection reset only.
if (e.code !== 'ECONNRESET')
throw e;
progress.log(`Received ECONNRESET, will retry after ${backoff}ms.`);
await new Promise(f => setTimeout(f, backoff));
backoff *= 2;
}
}
throw new Error('Unreachable');
}
private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise<Omit<channels.APIResponse, 'fetchUid'> & { body: Buffer }>{ private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise<Omit<channels.APIResponse, 'fetchUid'> & { body: Buffer }>{
await this._updateRequestCookieHeader(url, options.headers); await this._updateRequestCookieHeader(url, options.headers);

View file

@ -15963,6 +15963,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error
* will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
*/
maxRetries?: number;
/** /**
* If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or * If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or
* [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST)). If not specified, GET method is used. * [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST)). If not specified, GET method is used.
@ -16063,6 +16069,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error
* will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
*/
maxRetries?: number;
/** /**
* Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
@ -16143,6 +16155,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error
* will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
*/
maxRetries?: number;
/** /**
* Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
@ -16223,6 +16241,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error
* will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
*/
maxRetries?: number;
/** /**
* Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
@ -16345,6 +16369,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error
* will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
*/
maxRetries?: number;
/** /**
* Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
@ -16425,6 +16455,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error are retried. An error
* will be thrown if the limit is exceeded. Defaults to `5`. Pass `0` to disable retries.
*/
maxRetries?: number;
/** /**
* Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless

View file

@ -324,6 +324,7 @@ export type APIRequestContextFetchParams = {
failOnStatusCode?: boolean, failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
maxRedirects?: number, maxRedirects?: number,
maxRetries?: number,
}; };
export type APIRequestContextFetchOptions = { export type APIRequestContextFetchOptions = {
params?: NameValue[], params?: NameValue[],
@ -337,6 +338,7 @@ export type APIRequestContextFetchOptions = {
failOnStatusCode?: boolean, failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
maxRedirects?: number, maxRedirects?: number,
maxRetries?: number,
}; };
export type APIRequestContextFetchResult = { export type APIRequestContextFetchResult = {
response: APIResponse, response: APIResponse,

View file

@ -299,6 +299,7 @@ APIRequestContext:
failOnStatusCode: boolean? failOnStatusCode: boolean?
ignoreHTTPSErrors: boolean? ignoreHTTPSErrors: boolean?
maxRedirects: number? maxRedirects: number?
maxRetries: number?
returns: returns:
response: APIResponse response: APIResponse

View file

@ -1287,3 +1287,21 @@ it('should not work after context dispose', async ({ context, server }) => {
await context.close({ reason: 'Test ended.' }); await context.close({ reason: 'Test ended.' });
expect(await context.request.get(server.EMPTY_PAGE).catch(e => e.message)).toContain('Test ended.'); expect(await context.request.get(server.EMPTY_PAGE).catch(e => e.message)).toContain('Test ended.');
}); });
it('should retrty ECONNRESET', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' }
}, async ({ context, server }) => {
let requestCount = 0;
server.setRoute('/test', (req, res) => {
if (requestCount++ < 3) {
req.socket.destroy();
return;
}
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('Hello!');
});
const response = await context.request.get(server.PREFIX + '/test', { maxRetries: 3 });
expect(response.status()).toBe(200);
expect(await response.text()).toBe('Hello!');
expect(requestCount).toBe(4);
});

View file

@ -483,3 +483,21 @@ it('should throw after dispose', async ({ playwright, server }) => {
await request.dispose(); await request.dispose();
await expect(request.get(server.EMPTY_PAGE)).rejects.toThrow('Target page, context or browser has been closed'); await expect(request.get(server.EMPTY_PAGE)).rejects.toThrow('Target page, context or browser has been closed');
}); });
it('should retrty ECONNRESET', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' }
}, async ({ context, server }) => {
let requestCount = 0;
server.setRoute('/test', (req, res) => {
if (requestCount++ < 3) {
req.socket.destroy();
return;
}
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('Hello!');
});
const response = await context.request.fetch(server.PREFIX + '/test', { maxRetries: 3 });
expect(response.status()).toBe(200);
expect(await response.text()).toBe('Hello!');
expect(requestCount).toBe(4);
});