Skip to content

Commit 0811780

Browse files
add an abort controller to signal request timeouts
1 parent 1c48074 commit 0811780

File tree

9 files changed

+175
-22
lines changed

9 files changed

+175
-22
lines changed

docs/generated/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ interface Request_2 extends Request_3 {
116116
rawBody?: Buffer;
117117
spanId?: string;
118118
traceId?: string;
119+
abortController?: AbortController;
119120
}
120121
export { Request_2 as Request }
121122

src/functions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export interface Request extends ExpressRequest {
4444
* Cloud Trace span ID.
4545
*/
4646
spanId?: string;
47+
/**
48+
* An AbortController used to signal cancellation of a function invocation (e.g. in case of time out).
49+
*/
50+
abortController?: AbortController;
4751
}
4852

4953
/**

src/main.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,14 @@ export const main = async () => {
4949
// eslint-disable-next-line no-process-exit
5050
process.exit(1);
5151
}
52+
5253
const {userFunction, signatureType} = loadedFunction;
54+
// It is possible to overwrite the configured signature type in code so we
55+
// reset it here based on what we loaded.
56+
options.signatureType = signatureType;
5357
const server = getServer(
5458
userFunction!,
55-
signatureType,
56-
options.enableExecutionId
59+
options,
5760
);
5861
const errorHandler = new ErrorHandler(server);
5962
server

src/middleware/timeout.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Request, Response } from '../functions';
2+
import { NextFunction } from 'express';
3+
4+
export const timeoutMiddleware = (timeoutMilliseconds: number) => {
5+
return (req: Request, res: Response, next: NextFunction) => {
6+
// In modern versions of Node.js that support the AbortController API we add one to
7+
// signal function timeout.
8+
if (timeoutMilliseconds > 0 && AbortController) {
9+
req.abortController = new AbortController();
10+
req.setTimeout(timeoutMilliseconds);
11+
let executionComplete = false;
12+
res.on('timeout', () => {
13+
// This event is triggered when the underlying socket times out due to inactivity.
14+
if (!executionComplete) {
15+
executionComplete = true;
16+
req.abortController?.abort('timeout');
17+
}
18+
});
19+
req.on('close', () => {
20+
// This event is triggered when the underlying HTTP connection is closed. This can
21+
// happen if the data plane times out the request, the client disconnects or the
22+
// response is complete.
23+
if (!executionComplete) {
24+
executionComplete = true;
25+
req.abortController?.abort('request closed');
26+
}
27+
});
28+
req.on('end', () => {
29+
// This event is triggered when the function execution completes and we
30+
// write an HTTP response.
31+
executionComplete = true;
32+
});
33+
}
34+
// Always call next to continue middleware processing.
35+
next();
36+
};
37+
};
38+
39+

src/options.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export interface FrameworkOptions {
5252
* Whether or not to enable execution id support.
5353
*/
5454
enableExecutionId: boolean;
55+
/**
56+
* The request timeout.
57+
*/
58+
timeoutMilliseconds: number;
5559
}
5660

5761
/**
@@ -112,6 +116,20 @@ const SignatureOption = new ConfigurableOption(
112116
);
113117
}
114118
);
119+
const TimeoutOption = new ConfigurableOption(
120+
'timeout',
121+
'CLOUD_RUN_TIMEOUT_SECONDS',
122+
0,
123+
(x: string | number) => {
124+
if (typeof x === 'string') {
125+
x = parseInt(x, 10)
126+
}
127+
if (isNaN(x) || x < 0) {
128+
throw new OptionsError("Timeout must be a positive integer");
129+
}
130+
return Math.floor(x * 1000);
131+
}
132+
)
115133

116134
export const requiredNodeJsVersionForLogExecutionID = '13.0.0';
117135
const ExecutionIdOption = new ConfigurableOption(
@@ -158,13 +176,15 @@ export const parseOptions = (
158176
FunctionTargetOption.cliOption,
159177
SignatureOption.cliOption,
160178
SourceLocationOption.cliOption,
179+
TimeoutOption.cliOption,
161180
],
162181
});
163182
return {
164183
port: PortOption.parse(argv, envVars),
165184
target: FunctionTargetOption.parse(argv, envVars),
166185
sourceLocation: SourceLocationOption.parse(argv, envVars),
167186
signatureType: SignatureOption.parse(argv, envVars),
187+
timeoutMilliseconds: TimeoutOption.parse(argv, envVars),
168188
printHelp: cliArgs[2] === '-h' || cliArgs[2] === '--help',
169189
enableExecutionId: ExecutionIdOption.parse(argv, envVars),
170190
};

src/server.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,29 @@ import * as express from 'express';
1717
import * as http from 'http';
1818
import * as onFinished from 'on-finished';
1919
import {HandlerFunction, Request, Response} from './functions';
20-
import {SignatureType} from './types';
2120
import {setLatestRes} from './invoker';
2221
import {legacyPubSubEventMiddleware} from './pubsub_middleware';
2322
import {cloudEventToBackgroundEventMiddleware} from './middleware/cloud_event_to_background_event';
2423
import {backgroundEventToCloudEventMiddleware} from './middleware/background_event_to_cloud_event';
24+
import {timeoutMiddleware} from './middleware/timeout';
2525
import {wrapUserFunction} from './function_wrappers';
2626
import {asyncLocalStorageMiddleware} from './async_local_storage';
2727
import {executionContextMiddleware} from './execution_context';
2828
import {errorHandler} from './logger';
29+
import {FrameworkOptions} from './options';
2930

3031
/**
3132
* Creates and configures an Express application and returns an HTTP server
3233
* which will run it.
3334
* @param userFunction User's function.
34-
* @param functionSignatureType Type of user's function signature.
35+
* @param options the configured Function Framework options.
3536
* @return HTTP server.
3637
*/
3738
export function getServer(
3839
userFunction: HandlerFunction,
39-
functionSignatureType: SignatureType,
40-
enableExecutionId: boolean
40+
options: FrameworkOptions
4141
): http.Server {
42+
4243
// App to use for function executions.
4344
const app = express();
4445

@@ -89,7 +90,7 @@ export function getServer(
8990
};
9091

9192
// Apply middleware
92-
if (functionSignatureType !== 'typed') {
93+
if (options.signatureType !== 'typed') {
9394
// If the function is not typed then JSON parsing can be done automatically, otherwise the
9495
// functions format must determine deserialization.
9596
app.use(bodyParser.json(cloudEventsBodySavingOptions));
@@ -120,23 +121,23 @@ export function getServer(
120121
app.use(asyncLocalStorageMiddleware);
121122

122123
if (
123-
functionSignatureType === 'event' ||
124-
functionSignatureType === 'cloudevent'
124+
options.signatureType === 'event' ||
125+
options.signatureType === 'cloudevent'
125126
) {
126127
// If a Pub/Sub subscription is configured to invoke a user's function directly, the request body
127128
// needs to be marshalled into the structure that wrapEventFunction expects. This unblocks local
128129
// development with the Pub/Sub emulator
129130
app.use(legacyPubSubEventMiddleware);
130131
}
131132

132-
if (functionSignatureType === 'event') {
133+
if (options.signatureType === 'event') {
133134
app.use(cloudEventToBackgroundEventMiddleware);
134135
}
135-
if (functionSignatureType === 'cloudevent') {
136+
if (options.signatureType === 'cloudevent') {
136137
app.use(backgroundEventToCloudEventMiddleware);
137138
}
138139

139-
if (functionSignatureType === 'http') {
140+
if (options.signatureType === 'http') {
140141
app.use('/favicon.ico|/robots.txt', (req, res) => {
141142
// Neither crawlers nor browsers attempting to pull the icon find the body
142143
// contents particularly useful, so we send nothing in the response body.
@@ -151,16 +152,18 @@ export function getServer(
151152
});
152153
}
153154

155+
app.use(timeoutMiddleware(options.timeoutMilliseconds));
156+
154157
// Set up the routes for the user's function
155-
const requestHandler = wrapUserFunction(userFunction, functionSignatureType);
156-
if (functionSignatureType === 'http') {
158+
const requestHandler = wrapUserFunction(userFunction, options.signatureType);
159+
if (options.signatureType === 'http') {
157160
app.all('/*', requestHandler);
158161
} else {
159162
app.post('/*', requestHandler);
160163
}
161164

162165
// Error Handler
163-
if (enableExecutionId) {
166+
if (options.enableExecutionId) {
164167
app.use(errorHandler);
165168
}
166169

test/integration/legacy_event.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as functions from '../../src/functions';
1717
import * as sinon from 'sinon';
1818
import {getServer} from '../../src/server';
1919
import * as supertest from 'supertest';
20+
import { SignatureType } from '../../src/types';
2021

2122
const TEST_CLOUD_EVENT = {
2223
specversion: '1.0',
@@ -31,6 +32,16 @@ const TEST_CLOUD_EVENT = {
3132
},
3233
};
3334

35+
const testOptions = {
36+
signatureType: "event" as SignatureType,
37+
enableExecutionId: false,
38+
timeoutMilliseconds: 0,
39+
port: "0",
40+
target: "",
41+
sourceLocation: "",
42+
printHelp: false,
43+
};
44+
3445
describe('Event Function', () => {
3546
beforeEach(() => {
3647
// Prevent log spew from the PubSub emulator request.
@@ -186,8 +197,7 @@ describe('Event Function', () => {
186197
receivedData = data;
187198
receivedContext = context as functions.CloudFunctionsContext;
188199
},
189-
'event',
190-
/*enableExecutionId=*/ false
200+
testOptions
191201
);
192202
const requestHeaders = {
193203
'Content-Type': 'application/json',
@@ -208,8 +218,7 @@ describe('Event Function', () => {
208218
() => {
209219
throw 'I crashed';
210220
},
211-
'event',
212-
/*enableExecutionId=*/ false
221+
testOptions,
213222
);
214223
await supertest(server)
215224
.post('/')

test/middleware/timeout.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import {NextFunction} from 'express';
4+
import {Request, Response} from '../../src/functions';
5+
6+
import {timeoutMiddleware} from '../../src/middleware/timeout';
7+
8+
describe('timeoutMiddleware', () => {
9+
let request: Request;
10+
let response: Response;
11+
let next: NextFunction;
12+
beforeEach(() => {
13+
request = {
14+
setTimeout: sinon.spy(),
15+
on: sinon.spy(),
16+
} as unknown as Request;
17+
response = {
18+
on: sinon.spy(),
19+
} as unknown as Response;
20+
next = sinon.spy();
21+
});
22+
23+
it('calls the next function', () => {
24+
const middleware = timeoutMiddleware(1000);
25+
middleware(request, response, next);
26+
assert.strictEqual((next as sinon.SinonSpy).called, true);
27+
});
28+
29+
it('adds an abort controller to the request', () => {
30+
const middleware = timeoutMiddleware(1000);
31+
middleware(request, response, next);
32+
assert.strictEqual(!!request.abortController, true);
33+
});
34+
35+
it('adds an abort controller to the request', () => {
36+
const middleware = timeoutMiddleware(1000);
37+
middleware(request, response, next);
38+
assert.strictEqual(!!request.abortController, true);
39+
});
40+
41+
it('sets the request timeout', () => {
42+
const middleware = timeoutMiddleware(1000);
43+
middleware(request, response, next);
44+
assert.strictEqual((request.setTimeout as sinon.SinonSpy).calledWith(1000), true);
45+
});
46+
});

0 commit comments

Comments
 (0)