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-%%
* since: v1.26
### option: APIRequestContext.fetch.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.get
* since: v1.16
- 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-%%
* since: v1.26
### option: APIRequestContext.get.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.head
* since: v1.16
- 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-%%
* since: v1.26
### option: APIRequestContext.head.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.patch
* since: v1.16
- 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-%%
* since: v1.26
### option: APIRequestContext.patch.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.post
* since: v1.16
- 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-%%
* since: v1.26
### option: APIRequestContext.post.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.put
* since: v1.16
- 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-%%
* since: v1.26
### option: APIRequestContext.put.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.storageState
* since: v1.16
- 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.
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
- `expression` <[string]>

View file

@ -41,6 +41,7 @@ export type FetchOptions = {
failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean,
maxRedirects?: number,
maxRetries?: number,
};
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'storageState' | 'tracesDir'> & {
@ -168,11 +169,11 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
throw new TargetClosedError(this._closeReason);
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.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 params = objectToArray(options.params);
const method = options.method || options.request?.method();
const maxRedirects = options.maxRedirects;
// Cannot call allHeaders() here as the request may be paused inside route handler.
const headersObj = options.headers || options.request?.headers() ;
const headers = headersObj ? headersObjectToArray(headersObj) : undefined;
@ -234,7 +235,8 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
timeout: options.timeout,
failOnStatusCode: options.failOnStatusCode,
ignoreHTTPSErrors: options.ignoreHTTPSErrors,
maxRedirects: maxRedirects,
maxRedirects: options.maxRedirects,
maxRetries: options.maxRetries,
...fixtures
});
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._context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
});

View file

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

View file

@ -201,7 +201,7 @@ export abstract class APIRequestContext extends SdkObject {
setHeader(headers, 'content-length', String(postData.byteLength));
const controller = new ProgressController(metadata, this);
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);
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 }>{
await this._updateRequestCookieHeader(url, options.headers);

View file

@ -15963,6 +15963,12 @@ export interface APIRequestContext {
*/
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
* [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;
/**
* 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
* 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;
/**
* 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
* 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;
/**
* 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
* 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;
/**
* 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
* 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;
/**
* 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
* 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,
ignoreHTTPSErrors?: boolean,
maxRedirects?: number,
maxRetries?: number,
};
export type APIRequestContextFetchOptions = {
params?: NameValue[],
@ -337,6 +338,7 @@ export type APIRequestContextFetchOptions = {
failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean,
maxRedirects?: number,
maxRetries?: number,
};
export type APIRequestContextFetchResult = {
response: APIResponse,

View file

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

View file

@ -1287,3 +1287,21 @@ it('should not work after context dispose', async ({ context, server }) => {
await context.close({ reason: '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 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);
});