Skip to content

Commit 206c838

Browse files
authored
feat: add async error handling to onRequest (#1755)
BREAKING CHANGE: Unhandled errors in async onRequest handlers now immediately return a 500 Internal Server Error instead of hanging until timeout. This may affect clients relying on 504 Gateway Timeout for retry logic.
1 parent afe2f71 commit 206c838

File tree

6 files changed

+136
-3
lines changed

6 files changed

+136
-3
lines changed

spec/helper.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ export interface RunHandlerResult {
4040
* data populated into the response.
4141
*/
4242
export function runHandler(
43-
handler: express.Handler,
43+
handler: (
44+
req: https.Request,
45+
res: express.Response,
46+
next?: express.NextFunction
47+
) => void | Promise<void>,
4448
request: https.Request
4549
): Promise<RunHandlerResult> {
4650
return new Promise((resolve) => {
@@ -119,7 +123,7 @@ export function runHandler(
119123
}
120124
}
121125
const response = new MockResponse();
122-
handler(request, response as any, () => undefined);
126+
return void handler(request, response as any, () => undefined);
123127
});
124128
}
125129

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as https from "../../../src/v1/providers/https";
4+
import * as logger from "../../../src/logger";
5+
import { MockRequest } from "../../fixtures/mockrequest";
6+
import { runHandler } from "../../helper";
7+
8+
describe("CloudHttpsBuilder async onRequest", () => {
9+
let loggerSpy: sinon.SinonSpy;
10+
11+
beforeEach(() => {
12+
loggerSpy = sinon.spy(logger, "error");
13+
});
14+
15+
afterEach(() => {
16+
loggerSpy.restore();
17+
});
18+
19+
it("should catch and log unhandled rejections in async onRequest handlers", async () => {
20+
const err = new Error("boom");
21+
const fn = https.onRequest(async (_req, _res) => {
22+
await Promise.resolve();
23+
throw err;
24+
});
25+
26+
const req = new MockRequest({}, {});
27+
req.method = "GET";
28+
29+
const result = await runHandler(fn, req as any);
30+
31+
expect(loggerSpy.calledWith("Unhandled error", err)).to.be.true;
32+
expect(result.status).to.equal(500);
33+
expect(result.body).to.equal("Internal Server Error");
34+
});
35+
36+
it("should not log if handler completes successfully", async () => {
37+
const fn = https.onRequest(async (_req, res) => {
38+
await Promise.resolve();
39+
res.send(200);
40+
});
41+
42+
const req = new MockRequest({}, {});
43+
req.method = "GET";
44+
45+
await runHandler(fn, req as any);
46+
47+
expect(loggerSpy.called).to.be.false;
48+
});
49+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as https from "../../../src/v2/providers/https";
4+
import * as logger from "../../../src/logger";
5+
import { MockRequest } from "../../fixtures/mockrequest";
6+
import { runHandler } from "../../helper";
7+
8+
describe("v2.https.onRequest async", () => {
9+
let loggerSpy: sinon.SinonSpy;
10+
11+
beforeEach(() => {
12+
loggerSpy = sinon.spy(logger, "error");
13+
});
14+
15+
afterEach(() => {
16+
loggerSpy.restore();
17+
});
18+
19+
it("should catch and log unhandled rejections in async onRequest handlers", async () => {
20+
const err = new Error("boom");
21+
const fn = https.onRequest(async (_req, _res) => {
22+
await Promise.resolve();
23+
throw err;
24+
});
25+
26+
const req = new MockRequest({}, {});
27+
req.method = "GET";
28+
29+
const result = await runHandler(fn, req as any);
30+
31+
expect(loggerSpy.calledWith("Unhandled error", err)).to.be.true;
32+
expect(result.status).to.equal(500);
33+
expect(result.body).to.equal("Internal Server Error");
34+
});
35+
36+
it("should not log if handler completes successfully", async () => {
37+
const fn = https.onRequest(async (_req, res) => {
38+
await Promise.resolve();
39+
res.send(200);
40+
});
41+
42+
const req = new MockRequest({}, {});
43+
req.method = "GET";
44+
45+
await runHandler(fn, req as any);
46+
47+
expect(loggerSpy.called).to.be.false;
48+
});
49+
});

src/common/providers/https.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,3 +952,30 @@ function wrapOnCallHandler<Req = any, Res = any, Stream = unknown>(
952952
}
953953
};
954954
}
955+
956+
/**
957+
* Wraps an HTTP handler with a safety net for unhandled errors.
958+
*
959+
* This wrapper catches both synchronous errors and rejected Promises from `async` handlers.
960+
* Without this, an unhandled error in an `async` handler would cause the request to hang
961+
* until the platform timeout, as Express (v4) does not await handlers.
962+
*
963+
* It logs the error and returns a 500 Internal Server Error to the client if the response
964+
* headers have not yet been sent.
965+
*
966+
* @internal
967+
*/
968+
export function withErrorHandler(
969+
handler: (req: Request, res: express.Response) => void | Promise<void>
970+
): (req: Request, res: express.Response) => Promise<void> {
971+
return async (req: Request, res: express.Response) => {
972+
try {
973+
await handler(req, res);
974+
} catch (err) {
975+
logger.error("Unhandled error", err);
976+
if (!res.headersSent) {
977+
res.status(500).send("Internal Server Error");
978+
}
979+
}
980+
};
981+
}

src/v1/providers/https.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
HttpsError,
3030
onCallHandler,
3131
Request,
32+
withErrorHandler,
3233
} from "../../common/providers/https";
3334
import { HttpsFunction, optionsToEndpoint, optionsToTrigger, Runnable } from "../cloud-functions";
3435
import { DeploymentOptions } from "../function-configuration";
@@ -66,7 +67,7 @@ export function _onRequestWithOptions(
6667
): HttpsFunction {
6768
// lets us add __endpoint without altering handler:
6869
const cloudFunction: any = (req: Request, res: express.Response) => {
69-
return wrapTraceContext(withInit(handler))(req, res);
70+
return wrapTraceContext(withInit(withErrorHandler(handler)))(req, res);
7071
};
7172
cloudFunction.__trigger = {
7273
...optionsToTrigger(options),

src/v2/providers/https.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
onCallHandler,
4040
Request,
4141
AuthData,
42+
withErrorHandler,
4243
} from "../../common/providers/https";
4344
import { initV2Endpoint, ManifestEndpoint } from "../../runtime/manifest";
4445
import { GlobalOptions, SupportedRegion } from "../options";
@@ -319,6 +320,8 @@ export function onRequest(
319320
opts = optsOrHandler as HttpsOptions;
320321
}
321322

323+
handler = withErrorHandler(handler);
324+
322325
if (isDebugFeatureEnabled("enableCors") || "cors" in opts) {
323326
let origin = opts.cors instanceof Expression ? opts.cors.value() : opts.cors;
324327
if (isDebugFeatureEnabled("enableCors")) {

0 commit comments

Comments
 (0)