Skip to content

feat: propagate framework errors to user client application #685

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ ignored.
| `--source` | `FUNCTION_SOURCE` | The path to the directory of your function. Default: `cwd` (the current working directory) |
| `--log-execution-id`| `LOG_EXECUTION_ID` | Enables execution IDs in logs, either `true` or `false`. When not specified, default to disable. Requires Node.js 13.0.0 or later. |
| `--ignored-routes`| `IGNORED_ROUTES` | A route expression for requests that should not be routed the function. An empty 404 response will be returned. This is set to `/favicon.ico|/robots.txt` by default for `http` functions. |
| `--propagate-framework-errors` | `PROPAGATE_FRAMEWORK_ERRORS` | Enables propagating framework errors to the client application error handler, either `true` or `false`. When not specified, default to disable.|

You can set command-line flags in your `package.json` via the `start` script.
For example:
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This directory contains advanced docs around the Functions Framework.
- [Running and Deploying Docker Containers](docker.md)
- [Writing a Function in Typescript](typescript.md)
- [ES Modules](esm/README.md)
- [Propagate internal framework errors](propagate-internal-framework-errors.md)

## Generated Docs

Expand Down
81 changes: 81 additions & 0 deletions docs/propagate-internal-framework-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Propagate Internal Framework Errors

The Functions Framework normally sends express level errors to the default express error handler which sends the error to the calling client with an optional stack trace if in a non-prod environment.

## Example

```ts
const app = express();

app.post("/", (req, res) => {
...
});

// User error handler
app.use((err, req, res, next) => {
logger.log(err);
res.send("Caught error!");
});

functions.http("helloWorld, app);
```

```ts
// Post request with bad JSON
http.post("/", "{"id": "Hello}");
```

Default express error handler:

```
SyntaxError: Expected double-quoted property name in JSON at position 20 (line 3 column 1)
at JSON.parse (<anonymous>)
at parse (functions-framework-nodejs/node_modules/body-parser/lib/types/json.js:92:19)
at functions-framework-nodejs/node_modules/body-parser/lib/read.js:128:18
at AsyncResource.runInAsyncScope (node:async_hooks:211:14)
at invokeCallback (functions-framework-nodejs/node_modules/raw-body/index.js:238:16)
at done (functions-framework-nodejs/node_modules/raw-body/index.js:227:7)
at IncomingMessage.onEnd (functions-framework-nodejs/node_modules/raw-body/index.js:287:7)
at IncomingMessage.emit (node:events:518:28)
at endReadableNT (node:internal/streams/readable:1698:12)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
```

## Propagating Errors

If you want to propgate internal express level errors to your application, enabling the propagate option and defining a custom error handler will allow your application to receive errors:

1. In your `package.json`, specify `--propagate-framework-errors=true"` for the `functions-framework`:

```sh
{
"scripts": {
"start": "functions-framework --target=helloWorld --propagate-framework-errors=true"
}
}
```

2. Define a express error handler:

```ts
const app = express();

// User error handler
app.use((err, req, res, next) => {
logger.log(err);
res.send("Caught error!");
});
```

Now your application will receive internal express level errors!

```ts
// Post request with bad JSON
http.post("/", "{"id": "Hello}");
```

The custom error handler logic executes:

```
Caught error!
```
101 changes: 101 additions & 0 deletions src/middleware/inject_user_function_error_handle_middleware_chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright 2019 Google LLC
//
// Licensed 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 {Express} from 'express';
import {HandlerFunction} from '../functions';
import {ILayer} from 'express-serve-static-core';

/**
* Common properties that exists on Express object instances. Extracted by calling
* `Object.getOwnPropertyNames` on an express instance.
*/
const COMMON_EXPRESS_OBJECT_PROPERTIES = [
'_router',
'use',
'get',
'post',
'put',
'delete',
];

/** The number of parameters on an express error handler. */
const EXPRESS_ERROR_HANDLE_PARAM_LENGTH = 4;

/**
* Injects the user function error handle middleware and its subsequent middleware
* chain into the framework. This enables users to handle framework errors that would
* otherwise be handled by the default Express error handle.
* @param frameworkApp - Framework app
* @param userFunction - User handler function
*/
export const injectUserFunctionErrorHandleMiddlewareChain = (
frameworkApp: Express,
userFunction: HandlerFunction
) => {
// Check if user function is an express app that can register middleware.
if (!isExpressApp(userFunction)) {
return;
}

// Get the index of the user's first error handle middleware.
const firstErrorHandleMiddlewareIndex =
getFirstUserFunctionErrorHandleMiddlewareIndex(userFunction);
if (!firstErrorHandleMiddlewareIndex) {
return;
}

// Inject middleware chain starting from the first error handle
// middleware into the framework app.
const middlewares = (userFunction as Express)._router.stack.slice(
firstErrorHandleMiddlewareIndex
);
for (const middleware of middlewares) {
// We don't care about routes.
if (middleware.route) {
continue;
}

frameworkApp.use(middleware.handle);
}
};

/**
* Returns if the user function contains common properties of an Express app.
* @param userFunction
*/
const isExpressApp = (userFunction: HandlerFunction): boolean => {
const userFunctionProperties = Object.getOwnPropertyNames(userFunction);
return COMMON_EXPRESS_OBJECT_PROPERTIES.every(prop =>
userFunctionProperties.includes(prop)
);
};

/**
* Returns the index of the first error handle middleware in the user function.
*/
const getFirstUserFunctionErrorHandleMiddlewareIndex = (
userFunction: HandlerFunction
): number | null => {
const middlewares: ILayer[] = (userFunction as Express)._router.stack;
for (let index = 0; index < middlewares.length; index++) {
const middleware = middlewares[index];
if (
middleware.handle &&
middleware.handle.length === EXPRESS_ERROR_HANDLE_PARAM_LENGTH
) {
return index;
}
}

return null;
};
23 changes: 22 additions & 1 deletion src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import * as minimist from 'minimist';
import * as semver from 'semver';
import {resolve} from 'path';
import {SignatureType, isValidSignatureType} from './types';
import {isValidSignatureType, SignatureType} from './types';

/**
* Error thrown when an invalid option is provided.
Expand Down Expand Up @@ -60,6 +60,10 @@ export interface FrameworkOptions {
* Routes that should return a 404 without invoking the function.
*/
ignoredRoutes: string | null;
/**
* Whether or not to propagate framework errors to the client.
*/
propagateFrameworkErrors: boolean;
}

/**
Expand Down Expand Up @@ -167,6 +171,18 @@ const ExecutionIdOption = new ConfigurableOption(
}
);

const PropagateFrameworkErrorsOption = new ConfigurableOption(
'propagate-framework-errors',
'PROPAGATE_FRAMEWORK_ERRORS',
false,
x => {
return (
(typeof x === 'boolean' && x) ||
(typeof x === 'string' && x.toLowerCase() === 'true')
);
}
);

export const helpText = `Example usage:
functions-framework --target=helloWorld --port=8080
Documentation:
Expand All @@ -191,6 +207,7 @@ export const parseOptions = (
SourceLocationOption.cliOption,
TimeoutOption.cliOption,
IgnoredRoutesOption.cliOption,
PropagateFrameworkErrorsOption.cliOption,
],
});
return {
Expand All @@ -202,5 +219,9 @@ export const parseOptions = (
printHelp: cliArgs[2] === '-h' || cliArgs[2] === '--help',
enableExecutionId: ExecutionIdOption.parse(argv, envVars),
ignoredRoutes: IgnoredRoutesOption.parse(argv, envVars),
propagateFrameworkErrors: PropagateFrameworkErrorsOption.parse(
argv,
envVars
),
};
};
5 changes: 5 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {wrapUserFunction} from './function_wrappers';
import {asyncLocalStorageMiddleware} from './async_local_storage';
import {executionContextMiddleware} from './execution_context';
import {FrameworkOptions} from './options';
import {injectUserFunctionErrorHandleMiddlewareChain} from './middleware/inject_user_function_error_handle_middleware_chain';

/**
* Creates and configures an Express application and returns an HTTP server
Expand Down Expand Up @@ -172,5 +173,9 @@ export function getServer(
app.post('/*', requestHandler);
}

if (options.propagateFrameworkErrors) {
injectUserFunctionErrorHandleMiddlewareChain(app, userFunction);
}

return http.createServer(app);
}
1 change: 1 addition & 0 deletions src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@ export const getTestServer = (functionName: string): Server => {
sourceLocation: '',
printHelp: false,
ignoredRoutes: null,
propagateFrameworkErrors: false,
});
};
87 changes: 87 additions & 0 deletions test/integration/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import * as supertest from 'supertest';

import * as functions from '../../src/index';
import {getTestServer} from '../../src/testing';
import {Request, Response, NextFunction} from 'express';
import * as express from 'express';
import {getServer} from '../../src/server';

describe('HTTP Function', () => {
let callCount = 0;
Expand Down Expand Up @@ -111,4 +114,88 @@ describe('HTTP Function', () => {
assert.strictEqual(callCount, test.expectedCallCount);
});
});

it('default error handler', async () => {
const app = express();
app.post('/foo', async (req, res) => {
res.send('Foo!');
});
app.use(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_err: Error, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).send('Caught error!');
}
);
functions.http('testHttpFunction', app);
const malformedBody = '{"key": "value", "anotherKey": }';
const st = supertest(
getServer(app, {
port: '',
target: '',
sourceLocation: '',
signatureType: 'http',
printHelp: false,
enableExecutionId: false,
timeoutMilliseconds: 0,
ignoredRoutes: null,
propagateFrameworkErrors: false,
})
);
const resBody =
'<!DOCTYPE html>\n' +
'<html lang="en">\n' +
'<head>\n' +
'<meta charset="utf-8">\n' +
'<title>Error</title>\n' +
'</head>\n' +
'<body>\n' +
'<pre>SyntaxError: Unexpected token &#39;}&#39;, ...&quot;therKey&quot;: }&quot; is not valid JSON<br> &nbsp; &nbsp;at JSON.parse (&lt;anonymous&gt;)<br> &nbsp; &nbsp;at parse (/Users/gregei/IdeaProjects/functions-framework-nodejs/node_modules/body-parser/lib/types/json.js:92:19)<br> &nbsp; &nbsp;at /Users/gregei/IdeaProjects/functions-framework-nodejs/node_modules/body-parser/lib/read.js:128:18<br> &nbsp; &nbsp;at AsyncResource.runInAsyncScope (node:async_hooks:211:14)<br> &nbsp; &nbsp;at invokeCallback (/Users/gregei/IdeaProjects/functions-framework-nodejs/node_modules/raw-body/index.js:238:16)<br> &nbsp; &nbsp;at done (/Users/gregei/IdeaProjects/functions-framework-nodejs/node_modules/raw-body/index.js:227:7)<br> &nbsp; &nbsp;at IncomingMessage.onEnd (/Users/gregei/IdeaProjects/functions-framework-nodejs/node_modules/raw-body/index.js:287:7)<br> &nbsp; &nbsp;at IncomingMessage.emit (node:events:518:28)<br> &nbsp; &nbsp;at IncomingMessage.emit (node:domain:552:15)<br> &nbsp; &nbsp;at endReadableNT (node:internal/streams/readable:1698:12)<br> &nbsp; &nbsp;at process.processTicksAndRejections (node:internal/process/task_queues:90:21)</pre>\n' +
'</body>\n' +
'</html>\n';

const response = await st
.post('/foo')
.set('Content-Type', 'application/json')
.send(malformedBody);

assert.strictEqual(response.status, 400);
assert.equal(response.text, resBody);
});

it('user application error handler', async () => {
const app = express();
const resBody = 'Caught error!';
app.post('/foo', async (req, res) => {
res.send('Foo!');
});
app.use(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_err: Error, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).send(resBody);
}
);
functions.http('testHttpFunction', app);
const malformedBody = '{"key": "value", "anotherKey": }';
const st = supertest(
getServer(app, {
port: '',
target: '',
sourceLocation: '',
signatureType: 'http',
printHelp: false,
enableExecutionId: false,
timeoutMilliseconds: 0,
ignoredRoutes: null,
propagateFrameworkErrors: true,
})
);

const response = await st
.post('/foo')
.set('Content-Type', 'application/json')
.send(malformedBody);

assert.strictEqual(response.status, 500);
assert.equal(response.text, resBody);
});
});
1 change: 1 addition & 0 deletions test/integration/legacy_event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const testOptions = {
sourceLocation: '',
printHelp: false,
ignoredRoutes: null,
propagateFrameworkErrors: false,
};

describe('Event Function', () => {
Expand Down
Loading