Skip to content

Commit 06c1674

Browse files
Improve error handling of errors thrown from functions (#5)
1 parent 3d75525 commit 06c1674

File tree

9 files changed

+242
-7
lines changed

9 files changed

+242
-7
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ Changes to be included in the next upcoming release
66

77
- Add support for JSDoc descriptions from object types ([#3](https://github.com/hasura/ndc-nodejs-lambda/pull/3))
88
- Fix type name conflicts when using generic interfaces ([#4](https://github.com/hasura/ndc-nodejs-lambda/pull/4))
9+
- Improve error handling of errors thrown from functions ([#5](https://github.com/hasura/ndc-nodejs-lambda/pull/5))
10+
- The entire causal stack trace is now captured as an error detail for unhandled errors
11+
- `sdk.Forbidden`, `sdk.Conflict`, `sdk.UnprocessableContent` can be thrown to return error details to GraphQL API clients
912

1013
## v0.11.0
1114
- Add support for parallel execution of readonly functions ([#2](https://github.com/hasura/ndc-nodejs-lambda/pull/2))

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,68 @@ These types are unsupported as function parameter types or return types for func
147147
* [`void`](https://www.typescriptlang.org/docs/handbook/2/functions.html#void), [`object`](https://www.typescriptlang.org/docs/handbook/2/functions.html#object), [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), [`never`](https://www.typescriptlang.org/docs/handbook/2/functions.html#never), [`any`](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#any) types - to accept and return arbitrary JSON, use `sdk.JSONValue` instead
148148
* `null` and `undefined` - unless used in a union with a single other type
149149

150+
### Error handling
151+
By default, unhandled errors thrown from functions are caught by the Lambda SDK host, and an `InternalServerError` is returned to Hasura. The details of the uncaught error (the message and stack trace) is captured and will be logged in the OpenTelemetry trace associated with the GraphQL request. However, the GraphQL API caller will receive a generic "internal error" response to their query. This is to ensure internal error details are not leaked to GraphQL API clients.
152+
153+
If you want to return specific error details to the GraphQL API client, you can deliberately throw one of the below error classes (these error types correspond with the error status codes in the [NDC Specification](https://hasura.github.io/ndc-spec/specification/error-handling.html)):
154+
155+
| Error Class | Used When |
156+
|--------------------------|-----------|
157+
| sdk.Forbidden | A permission check failed - for example, a mutation might fail because a check constraint was not met. |
158+
| sdk.Conflict | A conflicting state would be created for the data source - for example, a mutation might fail because a foreign key constraint was not met. |
159+
| sdk.UnprocessableContent | There was something semantically incorrect in the request. For example, an invalid value for a function argument was received. |
160+
161+
```typescript
162+
import * as sdk from "@hasura/ndc-lambda-sdk"
163+
164+
/** @readonly */
165+
export function divide(x: number, y: number): number {
166+
if (y === 0) {
167+
throw new sdk.UnprocessableContent("Cannot divide by zero", { myErrorMetadata: "stuff", x, y })
168+
}
169+
return x / y;
170+
}
171+
```
172+
173+
The GraphQL API will return the error from the API looking similar to this:
174+
175+
```json
176+
{
177+
"data": null,
178+
"errors": [
179+
{
180+
"message": "ndc: Cannot divide by zero",
181+
"path": ["divide"],
182+
"extensions": {
183+
"details": {
184+
"myErrorMetadata": "stuff",
185+
"x": 10,
186+
"y": 0
187+
}
188+
}
189+
}
190+
]
191+
}
192+
```
193+
194+
If you must include stack traces in the GraphQL API response, you can collect and add them to the error details yourself using a helper function (`sdk.getErrorDetails`). However, it is not recommended to expose stack traces to API end users. Instead, API administrators can look in the GraphQL API tracing to find the stack traces logged.
195+
196+
```typescript
197+
import * as sdk from "@hasura/ndc-lambda-sdk"
198+
199+
/** @readonly */
200+
export function getStrings(): Promise<string[]> {
201+
try {
202+
return await queryForStrings();
203+
} catch (e) {
204+
const details = e instanceof Error
205+
? sdk.getErrorDetails(e) // Returns { message: string, stack: string }
206+
: {};
207+
throw new sdk.UnprocessableContent("Something went wrong :/", details)
208+
}
209+
}
210+
```
211+
150212
### Parallel execution
151213
If functions are involved remote relationships in your Hasura metadata, then they may be queried in a [batch-based fashion](https://hasura.github.io/ndc-spec/specification/queries/variables.html). In this situation, any async functions that are marked with the `@readonly` JSDoc tag may be executed in parallel. The default degree of parallelism per query request to the connector is 10, but you may customise this by using the `@paralleldegree` JSDoc tag on your function.
152214

ndc-lambda-sdk/.mocharc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"require": "ts-node/register",
2+
"require": ["ts-node/register", "test/init.ts"],
33
"extensions": ["ts", "tsx"],
44
"spec": [
55
"test/**/*.test.ts"

ndc-lambda-sdk/package-lock.json

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ndc-lambda-sdk/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@
4343
},
4444
"devDependencies": {
4545
"@types/chai": "^4.3.11",
46+
"@types/chai-as-promised": "^7.1.8",
4647
"@types/mocha": "^10.0.6",
4748
"chai": "^4.3.7",
49+
"chai-as-promised": "^7.1.1",
4850
"mocha": "^10.2.0",
4951
"node-emoji": "^2.1.3",
5052
"node-postgres": "^0.6.2"

ndc-lambda-sdk/src/execution.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EOL } from "os";
12
import * as sdk from "@hasura/ndc-sdk-typescript"
23
import pLimit from "p-limit";
34
import * as schema from "./schema"
@@ -157,8 +158,10 @@ async function invokeFunction(func: Function, preparedArgs: unknown[], functionN
157158
}
158159
return result;
159160
} catch (e) {
160-
if (e instanceof Error) {
161-
throw new sdk.InternalServerError(`Error encountered when invoking function '${functionName}'`, { message: e.message, stack: e.stack });
161+
if (e instanceof sdk.ConnectorError) {
162+
throw e;
163+
} else if (e instanceof Error) {
164+
throw new sdk.InternalServerError(`Error encountered when invoking function '${functionName}'`, getErrorDetails(e));
162165
} else if (typeof e === "string") {
163166
throw new sdk.InternalServerError(`Error encountered when invoking function '${functionName}'`, { message: e });
164167
} else {
@@ -167,6 +170,47 @@ async function invokeFunction(func: Function, preparedArgs: unknown[], functionN
167170
}
168171
}
169172

173+
export type ErrorDetails = {
174+
message: string
175+
stack: string
176+
}
177+
178+
export function getErrorDetails(error: Error): ErrorDetails {
179+
return {
180+
message: error.message,
181+
stack: buildCausalStackTrace(error),
182+
}
183+
}
184+
185+
function buildCausalStackTrace(error: Error): string {
186+
let seenErrs: Error[] = [];
187+
let currErr: Error | undefined = error;
188+
let stackTrace = "";
189+
190+
while (currErr) {
191+
seenErrs.push(currErr);
192+
193+
if (currErr.stack) {
194+
stackTrace += `${currErr.stack}${EOL}`;
195+
} else {
196+
stackTrace += `${currErr.toString()}${EOL}`;
197+
}
198+
if (currErr.cause instanceof Error) {
199+
if (seenErrs.includes(currErr.cause)) {
200+
stackTrace += "<circular error cause loop>"
201+
currErr = undefined;
202+
} else {
203+
stackTrace += `caused by `;
204+
currErr = currErr.cause;
205+
}
206+
207+
} else {
208+
currErr = undefined;
209+
}
210+
}
211+
return stackTrace;
212+
}
213+
170214
export function reshapeResultToNdcResponseValue(value: unknown, type: schema.TypeDefinition, valuePath: string[], fields: Record<string, sdk.Field> | "AllColumns", objectTypes: schema.ObjectTypeDefinitions): unknown {
171215
switch (type.type) {
172216
case "array":

ndc-lambda-sdk/src/sdk.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
import { JSONValue } from "./schema";
2-
3-
export { JSONValue };
1+
export { JSONValue } from "./schema";
2+
export { ConnectorError, BadRequest, Forbidden, Conflict, UnprocessableContent, InternalServerError, NotSupported, BadGateway } from "@hasura/ndc-sdk-typescript";
3+
export { ErrorDetails, getErrorDetails } from "./execution";

ndc-lambda-sdk/test/execution/execute-query.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it } from "mocha";
2-
import { assert } from "chai";
2+
import { assert, expect } from "chai";
33
import * as sdk from "@hasura/ndc-sdk-typescript"
44
import { executeQuery } from "../../src/execution";
55
import { FunctionNdcKind, FunctionsSchema } from "../../src/schema";
@@ -271,4 +271,97 @@ describe("execute query", function() {
271271
]);
272272
assert.deepStrictEqual(functionCallCompletions, ["first", "third", "fourth", "second"]);
273273
});
274+
275+
describe("function error handling", function() {
276+
const functionSchema: FunctionsSchema = {
277+
functions: {
278+
"theFunction": {
279+
ndcKind: FunctionNdcKind.Function,
280+
description: null,
281+
parallelDegree: null,
282+
arguments: [],
283+
resultType: {
284+
type: "named",
285+
kind: "scalar",
286+
name: "String"
287+
}
288+
}
289+
},
290+
objectTypes: {},
291+
scalarTypes: {
292+
"String": {}
293+
}
294+
};
295+
const queryRequest: sdk.QueryRequest = {
296+
collection: "theFunction",
297+
query: {
298+
fields: {},
299+
},
300+
arguments: {
301+
"param": {
302+
type: "literal",
303+
value: "test"
304+
}
305+
},
306+
collection_relationships: {}
307+
};
308+
309+
it("Error -> sdk.InternalServerError", async function() {
310+
const runtimeFunctions = {
311+
"theFunction": () => {
312+
throw new Error("BOOM!");
313+
}
314+
};
315+
316+
await expect(executeQuery(queryRequest, functionSchema, runtimeFunctions))
317+
.to.be.rejectedWith(sdk.InternalServerError, "Error encountered when invoking function 'theFunction'")
318+
.which.eventually.has.property("details")
319+
.which.include.keys("stack")
320+
.and.has.property("message", "BOOM!");
321+
});
322+
323+
it("string -> sdk.InternalServerError", async function() {
324+
const runtimeFunctions = {
325+
"theFunction": () => {
326+
throw "A bad way to throw errors";
327+
}
328+
};
329+
330+
await expect(executeQuery(queryRequest, functionSchema, runtimeFunctions))
331+
.to.be.rejectedWith(sdk.InternalServerError, "Error encountered when invoking function 'theFunction'")
332+
.which.eventually.has.property("details")
333+
.and.has.property("message", "A bad way to throw errors");
334+
});
335+
336+
it("unknown -> sdk.InternalServerError", async function() {
337+
const runtimeFunctions = {
338+
"theFunction": () => {
339+
throw 666; // What are you even doing? 👊
340+
}
341+
};
342+
343+
await expect(executeQuery(queryRequest, functionSchema, runtimeFunctions))
344+
.to.be.rejectedWith(sdk.InternalServerError, "Error encountered when invoking function 'theFunction'");
345+
});
346+
347+
describe("sdk exceptions are passed through", function() {
348+
const exceptions = [
349+
sdk.BadRequest, sdk.Forbidden, sdk.Conflict, sdk.UnprocessableContent, sdk.InternalServerError, sdk.NotSupported, sdk.BadGateway
350+
];
351+
352+
for (const exceptionCtor of exceptions) {
353+
it(`sdk.${exceptionCtor.name}`, async function() {
354+
const runtimeFunctions = {
355+
"theFunction": () => {
356+
throw new exceptionCtor("Nope!", { deets: "stuff" });
357+
}
358+
};
359+
360+
await expect(executeQuery(queryRequest, functionSchema, runtimeFunctions))
361+
.to.be.rejectedWith(exceptionCtor, "Nope!")
362+
.and.eventually.property("details").deep.equals({"deets": "stuff"});
363+
});
364+
}
365+
});
366+
})
274367
});

ndc-lambda-sdk/test/init.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// This file is hooked into mocha via .mocharc.json
2+
3+
import chai from "chai";
4+
import chaiAsPromised from "chai-as-promised"
5+
6+
export const mochaGlobalSetup = function() {
7+
chai.use(chaiAsPromised);
8+
};

0 commit comments

Comments
 (0)