Allow routes to specify the idle socket timeout in addition to the payload timeout (#73730)

* Route options timeout -> timeout.payload

* timeout.idleSocket can now be specified per route

* Removing nested ternary

* Fixing integration tests

* Trying to actually fix the integration tests. Existing tests are hitting
idle socket timeout, not the payload timeout

* Fixing payload post timeout integration test

* Fixing PUT and DELETE payload sending too long tests

* Fixing type-script errors

* GET routes can't specify the payload timeout, they can't accept payloads

* Removing some redundancy in the tests

* Adding 'Transfer-Encoding: chunked' to the POST test

* Fixing POST/GET/PUT quick tests

* Adding idleSocket timeout test

* Removing unnecessary `isSafeMethod` call

* Updating documentation

* Removing PUT/DELETE integration tests

* Working around the HapiJS bug

* Deleting unused type import

* The socket can be undefined...

This occurs when using @hapi/shot directly or indirectly via
Server.inject. In these scenarios, there isn't a socket. This can also
occur when a "fake request" is used by the hacky background jobs:
Reporting and Alerting...

* Update src/core/server/http/http_server.ts

Co-authored-by: Josh Dover <me@joshdover.com>

* Adding payload timeout functional tests

* Adding idle socket timeout functional tests

* Adding better comments, using ?? instead of ||

* Fixing the plugin fixture TS

* Fixing some typescript errors

* Fixing plugin fixture tsconfig.json

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Josh Dover <me@joshdover.com>
This commit is contained in:
Brandon Kobel 2020-08-18 10:46:08 -07:00 committed by GitHub
parent f0430f298f
commit 83bc5004e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 860 additions and 234 deletions

View file

@ -19,6 +19,6 @@ export interface RouteConfigOptions<Method extends RouteMethod>
| [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | <code>boolean &#124; 'optional'</code> | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.<!-- -->Defaults to <code>true</code> if an auth mechanism is registered. |
| [body](./kibana-plugin-core-server.routeconfigoptions.body.md) | <code>Method extends 'get' &#124; 'options' ? undefined : RouteConfigOptionsBody</code> | Additional body options [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md)<!-- -->. |
| [tags](./kibana-plugin-core-server.routeconfigoptions.tags.md) | <code>readonly string[]</code> | Additional metadata tag strings to attach to the route. |
| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | <code>number</code> | Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes |
| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | <code>{</code><br/><code> payload?: Method extends 'get' &#124; 'options' ? undefined : number;</code><br/><code> idleSocket?: number;</code><br/><code> }</code> | Defines per-route timeouts. |
| [xsrfRequired](./kibana-plugin-core-server.routeconfigoptions.xsrfrequired.md) | <code>Method extends 'get' ? never : boolean</code> | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain <code>kbn-xsrf</code> header. - false. Disables xsrf protection.<!-- -->Set to true by default |

View file

@ -4,10 +4,13 @@
## RouteConfigOptions.timeout property
Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes
Defines per-route timeouts.
<b>Signature:</b>
```typescript
timeout?: number;
timeout?: {
payload?: Method extends 'get' | 'options' ? undefined : number;
idleSocket?: number;
};
```

View file

@ -799,6 +799,7 @@ test('exposes route details of incoming request to a route handler', async () =>
authRequired: true,
xsrfRequired: false,
tags: [],
timeout: {},
},
});
});
@ -906,6 +907,9 @@ test('exposes route details of incoming request to a route handler (POST + paylo
authRequired: true,
xsrfRequired: true,
tags: [],
timeout: {
payload: 10000,
},
body: {
parse: true, // hapi populates the default
maxBytes: 1024, // hapi populates the default
@ -993,129 +997,249 @@ describe('body options', () => {
});
describe('timeout options', () => {
test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a POST', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
describe('payload timeout', () => {
test('POST routes set the payload timeout', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.post(
{
path: '/',
validate: false,
options: {
timeout: {
payload: 300000,
},
},
},
(context, req, res) => {
try {
return res.ok({
body: {
timeout: req.route.options.timeout,
},
});
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.post('/')
.send({ test: 1 })
.expect(200, {
timeout: {
payload: 300000,
},
});
});
test('DELETE routes set the payload timeout', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.delete(
{
path: '/',
validate: false,
options: {
timeout: {
payload: 300000,
},
},
},
(context, req, res) => {
try {
return res.ok({
body: {
timeout: req.route.options.timeout,
},
});
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.delete('/')
.expect(200, {
timeout: {
payload: 300000,
},
});
});
test('PUT routes set the payload timeout and automatically adjusts the idle socket timeout', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.put(
{
path: '/',
validate: false,
options: {
timeout: {
payload: 300000,
},
},
},
(context, req, res) => {
try {
return res.ok({
body: {
timeout: req.route.options.timeout,
},
});
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.put('/')
.expect(200, {
timeout: {
payload: 300000,
},
});
});
test('PATCH routes set the payload timeout and automatically adjusts the idle socket timeout', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.patch(
{
path: '/',
validate: false,
options: {
timeout: {
payload: 300000,
},
},
},
(context, req, res) => {
try {
return res.ok({
body: {
timeout: req.route.options.timeout,
},
});
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.patch('/')
.expect(200, {
timeout: {
payload: 300000,
},
});
});
});
describe('idleSocket timeout', () => {
test('uses server socket timeout when not specified in the route', async () => {
const { registerRouter, server: innerServer } = await server.setup({
...config,
socketTimeout: 11000,
});
const router = new Router('', logger, enhanceWithContext);
router.get(
{
path: '/',
validate: { body: schema.any() },
},
(context, req, res) => {
return res.ok({
body: {
timeout: req.route.options.timeout,
},
});
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.send()
.expect(200, {
timeout: {
idleSocket: 11000,
},
});
});
test('sets the socket timeout when specified in the route', async () => {
const { registerRouter, server: innerServer } = await server.setup({
...config,
socketTimeout: 11000,
});
const router = new Router('', logger, enhanceWithContext);
router.get(
{
path: '/',
validate: { body: schema.any() },
options: { timeout: { idleSocket: 12000 } },
},
(context, req, res) => {
return res.ok({
body: {
timeout: req.route.options.timeout,
},
});
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.send()
.expect(200, {
timeout: {
idleSocket: 12000,
},
});
});
});
test(`idleSocket timeout can be smaller than the payload timeout`, async () => {
const { registerRouter } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.post(
{
path: '/',
validate: false,
options: { timeout: 300000 },
validate: { body: schema.any() },
options: {
timeout: {
payload: 1000,
idleSocket: 10,
},
},
},
(context, req, res) => {
try {
return res.ok({ body: { timeout: req.route.options.timeout } });
} catch (err) {
return res.internalError({ body: err.message });
}
return res.ok({ body: { timeout: req.route.options.timeout } });
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, {
timeout: 300000,
});
});
test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a GET', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.get(
{
path: '/',
validate: false,
options: { timeout: 300000 },
},
(context, req, res) => {
try {
return res.ok({ body: { timeout: req.route.options.timeout } });
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).get('/').expect(200, {
timeout: 300000,
});
});
test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a DELETE', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.delete(
{
path: '/',
validate: false,
options: { timeout: 300000 },
},
(context, req, res) => {
try {
return res.ok({ body: { timeout: req.route.options.timeout } });
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).delete('/').expect(200, {
timeout: 300000,
});
});
test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PUT', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.put(
{
path: '/',
validate: false,
options: { timeout: 300000 },
},
(context, req, res) => {
try {
return res.ok({ body: { timeout: req.route.options.timeout } });
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).put('/').expect(200, {
timeout: 300000,
});
});
test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PATCH', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.patch(
{
path: '/',
validate: false,
options: { timeout: 300000 },
},
(context, req, res) => {
try {
return res.ok({ body: { timeout: req.route.options.timeout } });
} catch (err) {
return res.internalError({ body: err.message });
}
}
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).patch('/').expect(200, {
timeout: 300000,
});
});
});

View file

@ -163,13 +163,17 @@ export class HttpServer {
const validate = isSafeMethod(route.method) ? undefined : { payload: true };
const { authRequired, tags, body = {}, timeout } = route.options;
const { accepts: allow, maxBytes, output, parse } = body;
// Hapi does not allow timeouts on payloads to be specified for 'head' or 'get' requests
const payloadTimeout = isSafeMethod(route.method) || timeout == null ? undefined : timeout;
const kibanaRouteState: KibanaRouteState = {
xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
};
// To work around https://github.com/hapijs/hapi/issues/4122 until v20, set the socket
// timeout on the route to a fake timeout only when the payload timeout is specified.
// Within the onPreAuth lifecycle of the route itself, we'll override the timeout with the
// real socket timeout.
const fakeSocketTimeout = timeout?.payload ? timeout.payload + 1 : undefined;
this.server.route({
handler: route.handler,
method: route.method,
@ -177,13 +181,29 @@ export class HttpServer {
options: {
auth: this.getAuthOption(authRequired),
app: kibanaRouteState,
ext: {
onPreAuth: {
method: (request, h) => {
// At this point, the socket timeout has only been set to work-around the HapiJS bug.
// We need to either set the real per-route timeout or use the default idle socket timeout
if (timeout?.idleSocket) {
request.raw.req.socket.setTimeout(timeout.idleSocket);
} else if (fakeSocketTimeout) {
// NodeJS uses a socket timeout of `0` to denote "no timeout"
request.raw.req.socket.setTimeout(this.config!.socketTimeout ?? 0);
}
return h.continue;
},
},
},
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
// We are telling Hapi that NP routes can accept any payload, so that it can bypass the default
// validation applied in ./http_tools#getServerOptions
// (All NP routes are already required to specify their own validation in order to access the payload)
validate,
payload: [allow, maxBytes, output, parse, payloadTimeout].some(
payload: [allow, maxBytes, output, parse, timeout?.payload].some(
(v) => typeof v !== 'undefined'
)
? {
@ -191,15 +211,12 @@ export class HttpServer {
maxBytes,
output,
parse,
timeout: payloadTimeout,
timeout: timeout?.payload,
}
: undefined,
timeout:
timeout != null
? {
socket: timeout + 1, // Hapi server requires the socket to be greater than payload settings so we add 1 millisecond
}
: undefined,
timeout: {
socket: fakeSocketTimeout,
},
},
});
}

View file

@ -304,126 +304,204 @@ describe('Options', () => {
});
describe('timeout', () => {
it('should timeout if configured with a small timeout value for a POST', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
const writeBodyCharAtATime = (request: supertest.Test, body: string, interval: number) => {
return new Promise((resolve, reject) => {
let i = 0;
const intervalId = setInterval(() => {
if (i < body.length) {
request.write(body[i++]);
} else {
clearInterval(intervalId);
request.end((err, res) => {
resolve(res);
});
}
}, interval);
request.on('error', (err) => {
clearInterval(intervalId);
reject(err);
});
});
};
router.post(
{ path: '/a', validate: false, options: { timeout: 1000 } },
async (context, req, res) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return res.ok({});
}
);
router.post({ path: '/b', validate: false }, (context, req, res) => res.ok({}));
await server.start();
expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up');
await supertest(innerServer.listener).post('/b').expect(200, {});
describe('payload', () => {
it('should timeout if POST payload sending is too slow', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
router.post(
{
options: {
body: {
accepts: ['application/json'],
},
timeout: { payload: 100 },
},
path: '/a',
validate: false,
},
async (context, req, res) => {
return res.ok({});
}
);
await server.start();
// start the request
const request = supertest(innerServer.listener)
.post('/a')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked');
const result = writeBodyCharAtATime(request, '{"foo":"bar"}', 10);
await expect(result).rejects.toMatchInlineSnapshot(`[Error: Request Timeout]`);
});
it('should not timeout if POST payload sending is quick', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
router.post(
{
path: '/a',
validate: false,
options: { body: { accepts: 'application/json' }, timeout: { payload: 10000 } },
},
async (context, req, res) => res.ok({})
);
await server.start();
// start the request
const request = supertest(innerServer.listener)
.post('/a')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked');
const result = writeBodyCharAtATime(request, '{}', 10);
await expect(result).resolves.toHaveProperty('status', 200);
});
});
it('should timeout if configured with a small timeout value for a PUT', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
describe('idleSocket', () => {
it('should timeout if payload sending has too long of an idle period', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
router.put(
{ path: '/a', validate: false, options: { timeout: 1000 } },
async (context, req, res) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return res.ok({});
}
);
router.put({ path: '/b', validate: false }, (context, req, res) => res.ok({}));
await server.start();
router.post(
{
path: '/a',
validate: false,
options: {
body: {
accepts: ['application/json'],
},
timeout: { idleSocket: 10 },
},
},
async (context, req, res) => {
return res.ok({});
}
);
expect(supertest(innerServer.listener).put('/a')).rejects.toThrow('socket hang up');
await supertest(innerServer.listener).put('/b').expect(200, {});
});
await server.start();
it('should timeout if configured with a small timeout value for a DELETE', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
// start the request
const request = supertest(innerServer.listener)
.post('/a')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked');
router.delete(
{ path: '/a', validate: false, options: { timeout: 1000 } },
async (context, req, res) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return res.ok({});
}
);
router.delete({ path: '/b', validate: false }, (context, req, res) => res.ok({}));
await server.start();
expect(supertest(innerServer.listener).delete('/a')).rejects.toThrow('socket hang up');
await supertest(innerServer.listener).delete('/b').expect(200, {});
});
const result = writeBodyCharAtATime(request, '{}', 20);
it('should timeout if configured with a small timeout value for a GET', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
await expect(result).rejects.toThrow('socket hang up');
});
router.get(
// Note: There is a bug within Hapi Server where it cannot set the payload timeout for a GET call but it also cannot configure a timeout less than the payload body
// so the least amount of possible time to configure the timeout is 10 seconds.
{ path: '/a', validate: false, options: { timeout: 100000 } },
async (context, req, res) => {
// Cause a wait of 20 seconds to cause the socket hangup
await new Promise((resolve) => setTimeout(resolve, 200000));
return res.ok({});
}
);
router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({}));
await server.start();
it(`should not timeout if payload sending doesn't have too long of an idle period`, async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up');
await supertest(innerServer.listener).get('/b').expect(200, {});
});
router.post(
{
path: '/a',
validate: false,
options: {
body: {
accepts: ['application/json'],
},
timeout: { idleSocket: 1000 },
},
},
async (context, req, res) => {
return res.ok({});
}
);
it('should not timeout if configured with a 5 minute timeout value for a POST', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
await server.start();
router.post(
{ path: '/a', validate: false, options: { timeout: 300000 } },
async (context, req, res) => res.ok({})
);
await server.start();
await supertest(innerServer.listener).post('/a').expect(200, {});
});
// start the request
const request = supertest(innerServer.listener)
.post('/a')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked');
it('should not timeout if configured with a 5 minute timeout value for a PUT', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
const result = writeBodyCharAtATime(request, '{}', 10);
router.put(
{ path: '/a', validate: false, options: { timeout: 300000 } },
async (context, req, res) => res.ok({})
);
await server.start();
await expect(result).resolves.toHaveProperty('status', 200);
});
await supertest(innerServer.listener).put('/a').expect(200, {});
});
it('should timeout if servers response is too slow', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
it('should not timeout if configured with a 5 minute timeout value for a DELETE', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
router.post(
{
path: '/a',
validate: false,
options: {
body: {
accepts: ['application/json'],
},
timeout: { idleSocket: 1000, payload: 100 },
},
},
async (context, req, res) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return res.ok({});
}
);
router.delete(
{ path: '/a', validate: false, options: { timeout: 300000 } },
async (context, req, res) => res.ok({})
);
await server.start();
await supertest(innerServer.listener).delete('/a').expect(200, {});
});
await server.start();
await expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up');
});
it('should not timeout if configured with a 5 minute timeout value for a GET', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
it('should not timeout if servers response is quick', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
router.get(
{ path: '/a', validate: false, options: { timeout: 300000 } },
async (context, req, res) => res.ok({})
);
await server.start();
await supertest(innerServer.listener).get('/a').expect(200, {});
router.post(
{
path: '/a',
validate: false,
options: {
body: {
accepts: ['application/json'],
},
timeout: { idleSocket: 2000, payload: 100 },
},
},
async (context, req, res) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return res.ok({});
}
);
await server.start();
await expect(supertest(innerServer.listener).post('/a')).resolves.toHaveProperty(
'status',
200
);
});
});
});
});

View file

@ -211,15 +211,21 @@ export class KibanaRequest<
private getRouteInfo(request: Request): KibanaRequestRoute<Method> {
const method = request.method as Method;
const { parse, maxBytes, allow, output } = request.route.settings.payload || {};
const timeout = request.route.settings.timeout?.socket;
const { parse, maxBytes, allow, output, timeout: payloadTimeout } =
request.route.settings.payload || {};
// net.Socket#timeout isn't documented, yet, and isn't part of the types... https://github.com/nodejs/node/pull/34543
// the socket is also undefined when using @hapi/shot, or when a "fake request" is used
const socketTimeout = (request.raw.req.socket as any)?.timeout;
const options = ({
authRequired: this.getAuthRequired(request),
// some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true,
tags: request.route.settings.tags || [],
timeout: typeof timeout === 'number' ? timeout - 1 : undefined, // We are forced to have the timeout be 1 millisecond greater than the server and payload so we subtract one here to give the user consist settings
timeout: {
payload: payloadTimeout,
idleSocket: socketTimeout === 0 ? undefined : socketTimeout,
},
body: isSafeMethod(method)
? undefined
: {

View file

@ -146,10 +146,19 @@ export interface RouteConfigOptions<Method extends RouteMethod> {
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
/**
* Timeouts for processing durations. Response timeout is in milliseconds.
* Default value: 2 minutes
* Defines per-route timeouts.
*/
timeout?: number;
timeout?: {
/**
* Milliseconds to receive the payload
*/
payload?: Method extends 'get' | 'options' ? undefined : number;
/**
* Milliseconds the socket can be idle before it's closed
*/
idleSocket?: number;
};
}
/**

View file

@ -1886,7 +1886,10 @@ export interface RouteConfigOptions<Method extends RouteMethod> {
authRequired?: boolean | 'optional';
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
tags?: readonly string[];
timeout?: number;
timeout?: {
payload?: Method extends 'get' | 'options' ? undefined : number;
idleSocket?: number;
};
xsrfRequired?: Method extends 'get' ? never : boolean;
}

View file

@ -31,6 +31,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
testFiles: [
require.resolve('./test_suites/core'),
require.resolve('./test_suites/custom_visualizations'),
require.resolve('./test_suites/panel_actions'),
require.resolve('./test_suites/core_plugins'),

View file

@ -0,0 +1,8 @@
{
"id": "core_plugin_route_timeouts",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["core_plugin_route_timeouts"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,17 @@
{
"name": "core_plugin_route_timeouts",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/core_plugin_route_timeouts",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && tsc"
},
"devDependencies": {
"typescript": "3.9.5"
}
}

View file

@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CorePluginRouteTimeoutsPlugin } from './plugin';
export { PluginARequestContext } from './plugin';
export const plugin = () => new CorePluginRouteTimeoutsPlugin();

View file

@ -0,0 +1,124 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Plugin, CoreSetup } from 'kibana/server';
import { schema } from '@kbn/config-schema';
export interface PluginARequestContext {
ping: () => Promise<string>;
}
declare module 'kibana/server' {
interface RequestHandlerContext {
pluginA?: PluginARequestContext;
}
}
export class CorePluginRouteTimeoutsPlugin implements Plugin {
public setup(core: CoreSetup, deps: {}) {
const { http } = core;
const router = http.createRouter();
router.post(
{
options: {
body: {
accepts: ['application/json'],
},
timeout: { payload: 100 },
},
path: '/short_payload_timeout',
validate: false,
},
async (context, req, res) => {
return res.ok({});
}
);
router.post(
{
options: {
body: {
accepts: ['application/json'],
},
timeout: { payload: 10000 },
},
path: '/longer_payload_timeout',
validate: false,
},
async (context, req, res) => {
return res.ok({});
}
);
router.post(
{
options: {
body: {
accepts: ['application/json'],
},
timeout: { idleSocket: 10 },
},
path: '/short_idle_socket_timeout',
validate: {
body: schema.maybe(
schema.object({
responseDelay: schema.maybe(schema.number()),
})
),
},
},
async (context, req, res) => {
if (req.body?.responseDelay) {
await new Promise((resolve) => setTimeout(resolve, req.body!.responseDelay));
}
return res.ok({});
}
);
router.post(
{
options: {
body: {
accepts: ['application/json'],
},
timeout: { idleSocket: 5000 },
},
path: '/longer_idle_socket_timeout',
validate: {
body: schema.maybe(
schema.object({
responseDelay: schema.maybe(schema.number()),
})
),
},
},
async (context, req, res) => {
if (req.body?.responseDelay) {
await new Promise((resolve) => setTimeout(resolve, req.body!.responseDelay));
}
return res.ok({});
}
);
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,12 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"server/**/*.ts",
"../../../../typings/**/*"
],
"exclude": []
}

View file

@ -0,0 +1,25 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginFunctionalProviderContext } from '../../services';
export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
describe('core', function () {
loadTestFile(require.resolve('./route'));
});
}

View file

@ -0,0 +1,174 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { Test } from 'supertest';
import { PluginFunctionalProviderContext } from '../../services';
export default function ({ getService }: PluginFunctionalProviderContext) {
const supertest = getService('supertest');
describe('route', function () {
describe('timeouts', function () {
const writeBodyCharAtATime = (request: Test, body: string, interval: number) => {
return new Promise((resolve, reject) => {
let i = 0;
const intervalId = setInterval(() => {
if (i < body.length) {
request.write(body[i++]);
} else {
clearInterval(intervalId);
request.end((err, res) => {
resolve(res);
});
}
}, interval);
request.on('error', (err) => {
clearInterval(intervalId);
reject(err);
});
});
};
describe('payload', function () {
it(`should timeout if POST payload sending is too slow`, async () => {
// start the request
const request = supertest
.post('/short_payload_timeout')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked')
.set('kbn-xsrf', 'true');
const result = writeBodyCharAtATime(request, '{"foo":"bar"}', 10);
await result.then(
(res) => {
expect(res).to.be(undefined);
},
(err) => {
expect(err.message).to.be('Request Timeout');
}
);
});
it(`should not timeout if POST payload sending is quick`, async () => {
// start the request
const request = supertest
.post('/longer_payload_timeout')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked')
.set('kbn-xsrf', 'true');
const result = writeBodyCharAtATime(request, '{"foo":"bar"}', 10);
await result.then(
(res) => {
expect(res).to.have.property('statusCode', 200);
},
(err) => {
expect(err).to.be(undefined);
}
);
});
});
describe('idle socket', function () {
it('should timeout if payload sending has too long of an idle period', async function () {
// start the request
const request = supertest
.post('/short_idle_socket_timeout')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked')
.set('kbn-xsrf', 'true');
const result = writeBodyCharAtATime(request, '{"responseDelay":100}', 20);
await result.then(
(res) => {
expect(res).to.be(undefined);
},
(err) => {
expect(err.message).to.be('socket hang up');
}
);
});
it('should not timeout if payload sending does not have too long of an idle period', async function () {
// start the request
const request = supertest
.post('/longer_idle_socket_timeout')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked')
.set('kbn-xsrf', 'true');
const result = writeBodyCharAtATime(request, '{"responseDelay":0}', 10);
await result.then(
(res) => {
expect(res).to.have.property('statusCode', 200);
},
(err) => {
expect(err).to.be(undefined);
}
);
});
it('should timeout if servers response is too slow', async function () {
// start the request
const request = supertest
.post('/short_idle_socket_timeout')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked')
.set('kbn-xsrf', 'true');
const result = writeBodyCharAtATime(request, '{"responseDelay":100}', 0);
await result.then(
(res) => {
expect(res).to.be(undefined);
},
(err) => {
expect(err.message).to.be('socket hang up');
}
);
});
it('should not timeout if servers response is fast enough', async function () {
// start the request
const request = supertest
.post('/longer_idle_socket_timeout')
.set('Content-Type', 'application/json')
.set('Transfer-Encoding', 'chunked')
.set('kbn-xsrf', 'true');
const result = writeBodyCharAtATime(request, '{"responseDelay":100}', 0);
await result.then(
(res) => {
expect(res).to.have.property('statusCode', 200);
},
(err) => {
expect(err).to.be(undefined);
}
);
});
});
});
});
}

View file

@ -27,7 +27,9 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void =
parse: false,
},
tags: ['access:lists-all'],
timeout: config.importTimeout.asMilliseconds(),
timeout: {
payload: config.importTimeout.asMilliseconds(),
},
},
path: `${LIST_ITEM_URL}/_import`,
validate: {