feat(fetch): timeout option and default timeout (#8762)

This commit is contained in:
Yury Semikhatsky 2021-09-08 10:01:40 -07:00 committed by GitHub
parent 4e8d26c622
commit 77b3b0965a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 66 additions and 4 deletions

View File

@ -216,7 +216,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
}); });
} }
async _fetch(url: string, options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer } = {}): Promise<network.FetchResponse> { async _fetch(url: string, options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer, timeout?: number } = {}): Promise<network.FetchResponse> {
return this._wrapApiCall(async (channel: channels.BrowserContextChannel) => { return this._wrapApiCall(async (channel: channels.BrowserContextChannel) => {
const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData;
const result = await channel.fetch({ const result = await channel.fetch({
@ -224,6 +224,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
method: options.method, method: options.method,
headers: options.headers ? headersObjectToArray(options.headers) : undefined, headers: options.headers ? headersObjectToArray(options.headers) : undefined,
postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined, postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined,
timeout: options.timeout,
}); });
if (result.error) if (result.error)
throw new Error(`Request failed: ${result.error}`); throw new Error(`Request failed: ${result.error}`);

View File

@ -113,6 +113,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
method: params.method, method: params.method,
headers: params.headers ? headersArrayToObject(params.headers, false) : undefined, headers: params.headers ? headersArrayToObject(params.headers, false) : undefined,
postData: params.postData ? Buffer.from(params.postData, 'base64') : undefined, postData: params.postData ? Buffer.from(params.postData, 'base64') : undefined,
timeout: params.timeout,
}); });
let response; let response;
if (fetchResponse) { if (fetchResponse) {

View File

@ -858,11 +858,13 @@ export type BrowserContextFetchParams = {
method?: string, method?: string,
headers?: NameValue[], headers?: NameValue[],
postData?: Binary, postData?: Binary,
timeout?: number,
}; };
export type BrowserContextFetchOptions = { export type BrowserContextFetchOptions = {
method?: string, method?: string,
headers?: NameValue[], headers?: NameValue[],
postData?: Binary, postData?: Binary,
timeout?: number,
}; };
export type BrowserContextFetchResult = { export type BrowserContextFetchResult = {
response?: FetchResponse, response?: FetchResponse,

View File

@ -621,6 +621,7 @@ BrowserContext:
type: array? type: array?
items: NameValue items: NameValue
postData: binary? postData: binary?
timeout: number?
returns: returns:
response: FetchResponse? response: FetchResponse?
error: string? error: string?

View File

@ -397,6 +397,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
method: tOptional(tString), method: tOptional(tString),
headers: tOptional(tArray(tType('NameValue'))), headers: tOptional(tArray(tType('NameValue'))),
postData: tOptional(tBinary), postData: tOptional(tBinary),
timeout: tOptional(tNumber),
}); });
scheme.BrowserContextGrantPermissionsParams = tObject({ scheme.BrowserContextGrantPermissionsParams = tObject({
permissions: tArray(tString), permissions: tArray(tString),

View File

@ -22,6 +22,7 @@ import * as https from 'https';
import { BrowserContext } from './browserContext'; import { BrowserContext } from './browserContext';
import * as types from './types'; import * as types from './types';
import { pipeline, Readable, Transform } from 'stream'; import { pipeline, Readable, Transform } from 'stream';
import { monotonicTime } from '../utils/utils';
export async function playwrightFetch(context: BrowserContext, params: types.FetchOptions): Promise<{fetchResponse?: types.FetchResponse, error?: string}> { export async function playwrightFetch(context: BrowserContext, params: types.FetchOptions): Promise<{fetchResponse?: types.FetchResponse, error?: string}> {
try { try {
@ -50,11 +51,16 @@ export async function playwrightFetch(context: BrowserContext, params: types.Fet
agent = new HttpsProxyAgent(proxyOpts); agent = new HttpsProxyAgent(proxyOpts);
} }
const timeout = context._timeoutSettings.timeout(params);
const deadline = monotonicTime() + timeout;
const fetchResponse = await sendRequest(context, new URL(params.url, context._options.baseURL), { const fetchResponse = await sendRequest(context, new URL(params.url, context._options.baseURL), {
method, method,
headers, headers,
agent, agent,
maxRedirects: 20 maxRedirects: 20,
timeout,
deadline
}, params.postData); }, params.postData);
return { fetchResponse }; return { fetchResponse };
} catch (e) { } catch (e) {
@ -95,7 +101,7 @@ async function updateRequestCookieHeader(context: BrowserContext, url: URL, opti
} }
} }
async function sendRequest(context: BrowserContext, url: URL, options: http.RequestOptions & { maxRedirects: number }, postData?: Buffer): Promise<types.FetchResponse>{ async function sendRequest(context: BrowserContext, url: URL, options: http.RequestOptions & { maxRedirects: number, deadline: number }, postData?: Buffer): Promise<types.FetchResponse>{
await updateRequestCookieHeader(context, url, options); await updateRequestCookieHeader(context, url, options);
return new Promise<types.FetchResponse>((fulfill, reject) => { return new Promise<types.FetchResponse>((fulfill, reject) => {
const requestConstructor: ((url: URL, options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void) => http.ClientRequest) const requestConstructor: ((url: URL, options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void) => http.ClientRequest)
@ -125,11 +131,13 @@ async function sendRequest(context: BrowserContext, url: URL, options: http.Requ
delete headers[`content-type`]; delete headers[`content-type`];
} }
const redirectOptions: http.RequestOptions & { maxRedirects: number } = { const redirectOptions: http.RequestOptions & { maxRedirects: number, deadline: number } = {
method, method,
headers, headers,
agent: options.agent, agent: options.agent,
maxRedirects: options.maxRedirects - 1, maxRedirects: options.maxRedirects - 1,
timeout: options.timeout,
deadline: options.deadline
}; };
// HTTP-redirect fetch step 4: If locationURL is null, then return response. // HTTP-redirect fetch step 4: If locationURL is null, then return response.
@ -189,6 +197,16 @@ async function sendRequest(context: BrowserContext, url: URL, options: http.Requ
body.on('error',reject); body.on('error',reject);
}); });
request.on('error', reject); request.on('error', reject);
const rejectOnTimeout = () => {
reject(new Error(`Request timed out after ${options.timeout}ms`));
request.abort();
};
const remaining = options.deadline - monotonicTime();
if (remaining <= 0) {
rejectOnTimeout();
return;
}
request.setTimeout(remaining, rejectOnTimeout);
if (postData) if (postData)
request.write(postData); request.write(postData);
request.end(); request.end();

View File

@ -377,6 +377,7 @@ export type FetchOptions = {
method?: string, method?: string,
headers?: { [name: string]: string }, headers?: { [name: string]: string },
postData?: Buffer, postData?: Buffer,
timeout?: number,
}; };
export type FetchResponse = { export type FetchResponse = {

View File

@ -193,6 +193,16 @@ it('should add cookies from Set-Cookie header', async ({context, page, server})
expect((await page.evaluate(() => document.cookie)).split(';').map(s => s.trim()).sort()).toEqual(['foo=bar', 'session=value']); expect((await page.evaluate(() => document.cookie)).split(';').map(s => s.trim()).sort()).toEqual(['foo=bar', 'session=value']);
}); });
it('should not lose body while handling Set-Cookie header', async ({context, page, server}) => {
server.setRoute('/setcookie.html', (req, res) => {
res.setHeader('Set-Cookie', ['session=value', 'foo=bar; max-age=3600']);
res.end('text content');
});
// @ts-expect-error
const response = await context._fetch(server.PREFIX + '/setcookie.html');
expect(await response.text()).toBe('text content');
});
it('should handle cookies on redirects', async ({context, server, browserName, isWindows}) => { it('should handle cookies on redirects', async ({context, server, browserName, isWindows}) => {
server.setRoute('/redirect1', (req, res) => { server.setRoute('/redirect1', (req, res) => {
res.setHeader('Set-Cookie', 'r1=v1;SameSite=Lax'); res.setHeader('Set-Cookie', 'r1=v1;SameSite=Lax');
@ -576,3 +586,30 @@ it('should throw informatibe error on corrupted deflate body', async function({c
expect(error.message).toContain(`failed to decompress 'deflate' encoding`); expect(error.message).toContain(`failed to decompress 'deflate' encoding`);
}); });
it('should support timeout option', async function({context, server}) {
server.setRoute('/slow', (req, res) => {
res.writeHead(200, {
'content-length': 4096,
'content-type': 'text/html',
});
});
// @ts-expect-error
const error = await context._fetch(server.PREFIX + '/slow', { timeout: 10 }).catch(e => e);
expect(error.message).toContain(`Request timed out after 10ms`);
});
it('should respect timeout after redirects', async function({context, server}) {
server.setRedirect('/redirect', '/slow');
server.setRoute('/slow', (req, res) => {
res.writeHead(200, {
'content-length': 4096,
'content-type': 'text/html',
});
});
context.setDefaultTimeout(100);
// @ts-expect-error
const error = await context._fetch(server.PREFIX + '/redirect').catch(e => e);
expect(error.message).toContain(`Request timed out after 100ms`);
});