Skip to content

Commit 4071ce8

Browse files
authored
fix(runner): serialize error correctly (NangoHQ#2567)
## Describe your changes Fixes https://linear.app/nango/issue/NAN-1529/error-serialization-is-broken-in-runnerjobs - Correctly serialize error from runner to jobs I already had fixed this a few weeks ago but the recent migration broke it again. Hopefully, at some point, we'll have some e2e tests. The main idea is to keep looking for known patterns and fallback to std error. I also ejected from `formatScriptError` since dryrun is already too different vs regular exec. At some point we'll need to find a way to use the same code. ### Test ```ts import type { NangoSync } from '../../models'; export default async function runAction(nango: NangoSync): Promise<void> { throw new Error('foobar'); throw new nango.ActionError('foobar'); } ```
1 parent a368e34 commit 4071ce8

File tree

7 files changed

+226
-41
lines changed

7 files changed

+226
-41
lines changed

packages/jobs/lib/execution/operations/output.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { orchestratorClient } from '../../clients.js';
22
import type { JsonValue } from 'type-fest';
33
import { logger } from '../../logger.js';
4-
import { NangoError, formatScriptError } from '@nangohq/shared';
4+
import { NangoError } from '@nangohq/shared';
55
import type { NangoProps } from '@nangohq/shared';
66
import { handleSyncError, handleSyncSuccess } from '../sync.js';
77
import { handleActionError, handleActionSuccess } from '../action.js';
88
import { handleWebhookError, handleWebhookSuccess } from '../webhook.js';
99
import { handlePostConnectionError, handlePostConnectionSuccess } from '../postConnection.js';
1010
import type { ApiError } from '@nangohq/types';
1111
import type { ClientError } from '@nangohq/nango-orchestrator';
12+
import { toNangoError } from './utils/errors.js';
1213

1314
export async function handleSuccess({ taskId, nangoProps, output }: { taskId: string; nangoProps: NangoProps; output: JsonValue }): Promise<void> {
1415
switch (nangoProps.scriptType) {
@@ -45,7 +46,12 @@ export async function handleError({
4546
status: number;
4647
};
4748
}): Promise<void> {
48-
const { error: formattedError } = formatScriptError(error.payload, `${nangoProps.scriptType}_script_failure`, nangoProps.syncConfig.sync_name);
49+
const formattedError = toNangoError({
50+
err: error,
51+
defaultErrorType: `${nangoProps.scriptType}_script_failure`,
52+
scriptName: nangoProps.syncConfig.sync_name
53+
});
54+
4955
switch (nangoProps.scriptType) {
5056
case 'sync':
5157
await handleSyncError({ nangoProps, error: formattedError });
@@ -60,6 +66,7 @@ export async function handleError({
6066
await handlePostConnectionError({ nangoProps, error: formattedError });
6167
break;
6268
}
69+
6370
const setFailed = await orchestratorClient.failed({ taskId, error: formattedError });
6471
if (setFailed.isErr()) {
6572
await handlePayloadTooBigError({ taskId, error: setFailed.error, nangoProps });
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NangoError, deserializeNangoError } from '@nangohq/shared';
2+
import { stringifyError } from '@nangohq/utils';
3+
4+
export function toNangoError({ err, defaultErrorType, scriptName }: { err: unknown; defaultErrorType: string; scriptName: string }): NangoError {
5+
if (typeof err !== 'object') {
6+
return new NangoError('script_invalid_error', err);
7+
}
8+
if (!err) {
9+
return new NangoError('script_invalid_error', null as any);
10+
}
11+
12+
const tmp = deserializeNangoError(err);
13+
if (tmp) {
14+
return tmp;
15+
}
16+
17+
if ('response' in err && 'data' in (err.response as any)) {
18+
const res = err.response as { data: any };
19+
return new NangoError(defaultErrorType, res.data);
20+
}
21+
22+
if ('message' in err) {
23+
return new NangoError(defaultErrorType, stringifyError(err));
24+
}
25+
26+
return new NangoError(defaultErrorType, `Script for '${scriptName}' failed to execute with unknown error, ${JSON.stringify(err)}`);
27+
}

packages/jobs/nodemon.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
{
22
"watch": [
3-
"lib",
3+
"dist",
44
"../shared/dist",
55
"../utils/dist",
66
"../records/dist",
77
"../data-ingestion/dist",
88
"../logs/dist",
99
"../orchestrator/dist",
10+
"../runner/dist",
1011
"../webhooks/dist",
1112
"../../.env"
1213
],

packages/runner/lib/exec.ts

+38-10
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
88
import * as zod from 'zod';
99
import * as botbuilder from 'botbuilder';
1010
import tracer from 'dd-trace';
11-
import { metrics, stringifyError } from '@nangohq/utils';
11+
import { errorToObject, metrics } from '@nangohq/utils';
1212
import { logger } from './utils.js';
1313

1414
export async function exec(
@@ -39,7 +39,7 @@ export async function exec(
3939

4040
try {
4141
const script = new vm.Script(wrappedCode);
42-
const sandbox = {
42+
const sandbox: vm.Context = {
4343
console,
4444
require: (moduleName: string) => {
4545
switch (moduleName) {
@@ -56,7 +56,8 @@ export async function exec(
5656
}
5757
},
5858
Buffer,
59-
setTimeout
59+
setTimeout,
60+
Error
6061
};
6162

6263
const context = vm.createContext(sandbox);
@@ -143,7 +144,9 @@ export async function exec(
143144
},
144145
response: null
145146
};
146-
} else if (error instanceof NangoError) {
147+
}
148+
149+
if (error instanceof NangoError) {
147150
span.setTag('error', error);
148151
return {
149152
success: false,
@@ -154,26 +157,51 @@ export async function exec(
154157
},
155158
response: null
156159
};
157-
} else {
160+
} else if (error instanceof AxiosError) {
158161
span.setTag('error', error);
159-
if (error instanceof AxiosError && error.response?.data) {
162+
if (error.response?.data) {
160163
const errorResponse = error.response.data.payload || error.response.data;
161164
return {
162165
success: false,
163166
error: {
164-
type: 'http_error',
167+
type: 'script_http_error',
165168
payload: typeof errorResponse === 'string' ? { message: errorResponse } : errorResponse,
166169
status: error.response.status
167170
},
168171
response: null
169172
};
173+
} else {
174+
const tmp = errorToObject(error);
175+
return {
176+
success: false,
177+
error: {
178+
type: 'script_http_error',
179+
payload: { name: tmp.name || 'Error', code: tmp.code, message: tmp.message },
180+
status: 500
181+
},
182+
response: null
183+
};
170184
}
171-
span.setTag('error', error);
185+
} else if (error instanceof Error) {
186+
const tmp = errorToObject(error);
187+
span.setTag('error', tmp);
188+
return {
189+
success: false,
190+
error: {
191+
type: 'script_internal_error',
192+
payload: { name: tmp.name || 'Error', code: tmp.code, message: tmp.message },
193+
status: 500
194+
},
195+
response: null
196+
};
197+
} else {
198+
const tmp = errorToObject(!error || typeof error !== 'object' ? new Error(JSON.stringify(error)) : error);
199+
span.setTag('error', tmp);
172200
return {
173201
success: false,
174202
error: {
175-
type: 'internal_error',
176-
payload: { message: `Error executing code '${stringifyError(error)}'` },
203+
type: 'script_internal_error',
204+
payload: { name: tmp.name || 'Error', code: tmp.code, message: tmp.message },
177205
status: 500
178206
},
179207
response: null

packages/runner/lib/exec.unit.test.ts

+133-26
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,38 @@ import { expect, describe, it } from 'vitest';
22
import { exec } from './exec.js';
33
import type { NangoProps, SyncConfig } from '@nangohq/shared';
44

5+
function getNangoProps(): NangoProps {
6+
return {
7+
scriptType: 'sync',
8+
host: 'http://localhost:3003',
9+
connectionId: 'connection-id',
10+
environmentId: 1,
11+
providerConfigKey: 'provider-config-key',
12+
provider: 'provider',
13+
activityLogId: '1',
14+
secretKey: 'secret-key',
15+
nangoConnectionId: 1,
16+
syncId: 'sync-id',
17+
syncJobId: 1,
18+
lastSyncDate: new Date(),
19+
dryRun: true,
20+
attributes: {},
21+
track_deletes: false,
22+
logMessages: {
23+
counts: { updated: 0, added: 0, deleted: 0 },
24+
messages: []
25+
},
26+
syncConfig: {} as SyncConfig,
27+
debug: false,
28+
startedAt: new Date(),
29+
runnerFlags: {} as any,
30+
stubbedMetadata: {}
31+
};
32+
}
33+
534
describe('Exec', () => {
635
it('execute code', async () => {
7-
const nangoProps: NangoProps = {
8-
scriptType: 'sync',
9-
host: 'http://localhost:3003',
10-
connectionId: 'connection-id',
11-
environmentId: 1,
12-
providerConfigKey: 'provider-config-key',
13-
provider: 'provider',
14-
activityLogId: '1',
15-
secretKey: 'secret-key',
16-
nangoConnectionId: 1,
17-
syncId: 'sync-id',
18-
syncJobId: 1,
19-
lastSyncDate: new Date(),
20-
dryRun: true,
21-
attributes: {},
22-
track_deletes: false,
23-
logMessages: {
24-
counts: { updated: 0, added: 0, deleted: 0 },
25-
messages: []
26-
},
27-
syncConfig: {} as SyncConfig,
28-
debug: false,
29-
startedAt: new Date(),
30-
runnerFlags: {} as any,
31-
stubbedMetadata: {}
32-
};
36+
const nangoProps = getNangoProps();
3337
const jsCode = `
3438
f = async (nango) => {
3539
const s = nango.lastSyncDate.toISOString();
@@ -42,4 +46,107 @@ describe('Exec', () => {
4246
expect(res.error).toEqual(null);
4347
expect(res.success).toEqual(true);
4448
});
49+
50+
it('should return a formatted error when receiving an Error', async () => {
51+
const nangoProps = getNangoProps();
52+
const jsCode = `
53+
fn = async (nango) => {
54+
throw new Error('foobar')
55+
};
56+
exports.default = fn
57+
`;
58+
const res = await exec(nangoProps, jsCode);
59+
expect(res.error).toEqual({
60+
payload: {
61+
message: 'foobar',
62+
name: 'Error'
63+
},
64+
status: 500,
65+
type: 'script_internal_error'
66+
});
67+
expect(res.success).toEqual(false);
68+
});
69+
70+
it('should return a formatted error when receiving an ActionError', async () => {
71+
const nangoProps = getNangoProps();
72+
const jsCode = `
73+
fn = async (nango) => {
74+
throw new nango.ActionError({ message: 'foobar', prop: 'foobar' })
75+
};
76+
exports.default = fn
77+
`;
78+
const res = await exec(nangoProps, jsCode);
79+
expect(res.error).toEqual({
80+
payload: {
81+
message: 'foobar',
82+
prop: 'foobar'
83+
},
84+
status: 500,
85+
type: 'action_script_runtime_error'
86+
});
87+
expect(res.success).toEqual(false);
88+
});
89+
90+
it('should return a formatted error when receiving an ActionError with an array', async () => {
91+
const nangoProps = getNangoProps();
92+
const jsCode = `
93+
fn = async (nango) => {
94+
throw new nango.ActionError([{id: "foobar"}])
95+
};
96+
exports.default = fn
97+
`;
98+
const res = await exec(nangoProps, jsCode);
99+
expect(res.error).toEqual({
100+
payload: {
101+
message: [{ id: 'foobar' }]
102+
},
103+
status: 500,
104+
type: 'action_script_runtime_error'
105+
});
106+
expect(res.success).toEqual(false);
107+
});
108+
109+
it('should return a formatted error when receiving an invalid error', async () => {
110+
const nangoProps = getNangoProps();
111+
const jsCode = `
112+
fn = async (nango) => {
113+
throw new Object({})
114+
};
115+
exports.default = fn
116+
`;
117+
const res = await exec(nangoProps, jsCode);
118+
expect(res.error).toEqual({
119+
payload: {
120+
name: 'Error'
121+
},
122+
status: 500,
123+
type: 'script_internal_error'
124+
});
125+
expect(res.success).toEqual(false);
126+
});
127+
128+
it('should return a formatted error when receiving an AxiosError (without a body)', async () => {
129+
const nangoProps = getNangoProps();
130+
const jsCode = `
131+
fn = async (nango) => {
132+
await nango.get({
133+
endpoint: '/',
134+
baseUrl: 'https://example.dev/'
135+
})
136+
};
137+
exports.default = fn
138+
`;
139+
const res = await exec(nangoProps, jsCode);
140+
141+
// NB: it will fail because Nango is not running not because the website is not reachable
142+
// NB2: the message is different depending on the system running Node
143+
expect(res.error).toMatchObject({
144+
payload: {
145+
code: 'ECONNREFUSED'
146+
},
147+
status: 500,
148+
type: 'script_http_error'
149+
});
150+
expect(res.success).toEqual(false);
151+
});
45152
});

packages/runner/nodemon.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"watch": ["lib", "../shared/dist", "../utils/dist", "../../.env"],
2+
"watch": ["dist", "../shared/dist", "../utils/dist", "../../.env"],
33
"ext": "js,ts,json",
44
"ignore": ["lib/**/*.test.ts"],
55
"exec": "tsc && tsx -r dotenv/config lib/app.ts dotenv_config_path=./../../.env"

packages/shared/lib/utils/error.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,21 @@ export class NangoError extends Error {
622622
this.message = `The sync job results could not be updated: ${this.payload}`;
623623
break;
624624

625+
case 'script_invalid_error':
626+
this.status = 500;
627+
this.message = `An invalid error of type: ${typeof this.payload}`;
628+
break;
629+
630+
case 'script_http_error':
631+
this.status = 500;
632+
this.message = `An error occurred during an HTTP call`;
633+
break;
634+
635+
case 'script_internal_error':
636+
this.status = 500;
637+
this.message = `An internal error occurred during the script execution`;
638+
break;
639+
625640
default:
626641
this.status = 500;
627642
this.type = 'unhandled_' + type;
@@ -645,7 +660,7 @@ export const formatScriptError = (err: any, errorType: string, scriptName: strin
645660
}
646661
} else if (err.message) {
647662
errorMessage = err.message;
648-
} else if (typeof err === 'object' && Object.keys(err as object).length > 0) {
663+
} else if (err && typeof err === 'object' && Object.keys(err as object).length > 0) {
649664
errorMessage = stringifyError(err, { pretty: true, stack: true });
650665
} else {
651666
errorMessage = String(err);

0 commit comments

Comments
 (0)