Skip to content

Commit 76ebb96

Browse files
authored
fix(n8n): surface terminal Trigger stream auth failures (#114)
Register stream health handlers before connect() and route auth_failure through emitError so a terminal SSE auth failure becomes a visible trigger error instead of leaving the workflow apparently active while silently producing no more items. closeFunction cleanup is now idempotent.
1 parent b2a26ba commit 76ebb96

3 files changed

Lines changed: 147 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@teslemetry/n8n-nodes-teslemetry": patch
3+
---
4+
5+
Surface a terminal Teslemetry stream auth failure on the Trigger node as a workflow-visible error instead of leaving the trigger apparently active but silently producing no more items. Stream health handlers are now registered before the stream connects, and `closeFunction` cleanup is idempotent.

packages/n8n-nodes-teslemetry/src/nodes/TeslemetryTrigger.node.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,6 @@ export class TeslemetryTrigger implements INodeType {
180180
const teslemetry = new Teslemetry(credentials.accessToken as string);
181181
const sse = teslemetry.sse;
182182

183-
// Start connection
184-
sse.connect();
185-
186183
let cleanup: () => void;
187184

188185
const emit = (data: any) => {
@@ -244,9 +241,34 @@ export class TeslemetryTrigger implements INodeType {
244241
}
245242
}
246243

244+
// Registered before connect() so a terminal auth failure on the very first
245+
// attempt still reaches emitError instead of racing an unattached stream.
246+
const onStreamError = (payload: { error: unknown; status?: number; retries: number }) => {
247+
this.logger.warn(
248+
`Teslemetry stream error (attempt ${payload.retries}): ${String(payload.error)}`,
249+
);
250+
};
251+
const onDisconnect = () => {
252+
this.logger.warn('Teslemetry stream disconnected');
253+
};
254+
const onAuthFailure = (error: Error) => {
255+
this.emitError(error);
256+
};
257+
sse.on('stream_error', onStreamError);
258+
sse.on('disconnect', onDisconnect);
259+
sse.on('auth_failure', onAuthFailure);
260+
261+
sse.connect();
262+
263+
let closed = false;
247264
async function closeFunction() {
265+
if (closed) return;
266+
closed = true;
248267
if (cleanup) cleanup();
249-
sse.disconnect();
268+
sse.off('stream_error', onStreamError);
269+
sse.off('disconnect', onDisconnect);
270+
sse.off('auth_failure', onAuthFailure);
271+
await sse.disconnect();
250272
}
251273

252274
return {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { TeslemetryTrigger } from "../src/nodes/TeslemetryTrigger.node.js";
4+
5+
function withMockedFetch<T>(
6+
handler: (request: Request) => Promise<Response> | Response,
7+
run: () => Promise<T>,
8+
): Promise<T> {
9+
const original = globalThis.fetch;
10+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) =>
11+
handler(new Request(input, init))) as typeof fetch;
12+
return run().finally(() => {
13+
globalThis.fetch = original;
14+
});
15+
}
16+
17+
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
18+
const start = Date.now();
19+
while (!condition()) {
20+
if (Date.now() - start > timeoutMs) {
21+
throw new Error("Timed out waiting for condition");
22+
}
23+
await new Promise((resolve) => setTimeout(resolve, 10));
24+
}
25+
}
26+
27+
function fakeTriggerContext(params: Record<string, unknown>) {
28+
const emitted: unknown[] = [];
29+
const emittedErrors: Error[] = [];
30+
const warnings: string[] = [];
31+
const context = {
32+
getCredentials: async () => ({ accessToken: "token" }),
33+
getNodeParameter: (name: string, fallback?: unknown) =>
34+
name in params ? params[name] : fallback,
35+
emit: (data: unknown) => emitted.push(data),
36+
emitError: (error: Error) => emittedErrors.push(error),
37+
logger: {
38+
info: () => {},
39+
warn: (message: string) => warnings.push(message),
40+
error: () => {},
41+
debug: () => {},
42+
},
43+
helpers: {
44+
returnJsonArray: (data: unknown) => data,
45+
},
46+
};
47+
return { context: context as never, emitted, emittedErrors, warnings };
48+
}
49+
50+
test("TeslemetryTrigger surfaces a terminal auth failure via emitError", async () => {
51+
const { context, emittedErrors } = fakeTriggerContext({
52+
resource: "vehicle",
53+
event: "all",
54+
vin: "",
55+
});
56+
const node = new TeslemetryTrigger();
57+
58+
const result = await withMockedFetch(
59+
() => new Response(null, { status: 401, statusText: "Unauthorized" }),
60+
() => node.trigger.call(context),
61+
);
62+
63+
await waitFor(() => emittedErrors.length > 0);
64+
assert.equal(emittedErrors.length, 1);
65+
assert.match(emittedErrors[0].message, /401|unauthorized/i);
66+
67+
// Idempotent cleanup: calling closeFunction twice must not throw.
68+
await result.closeFunction!();
69+
await result.closeFunction!();
70+
});
71+
72+
test("TeslemetryTrigger logs stream disconnects without surfacing them as trigger errors", async () => {
73+
const { context, emittedErrors, warnings } = fakeTriggerContext({
74+
resource: "vehicle",
75+
event: "all",
76+
vin: "",
77+
});
78+
const node = new TeslemetryTrigger();
79+
80+
const result = await withMockedFetch(
81+
() => new Response(null, { status: 500, statusText: "Server Error" }),
82+
() => node.trigger.call(context),
83+
);
84+
85+
await waitFor(() => warnings.some((w) => w.includes("disconnected")));
86+
assert.equal(emittedErrors.length, 0);
87+
88+
await result.closeFunction!();
89+
});
90+
91+
test("TeslemetryTrigger.closeFunction tears down listeners and stops the stream", async () => {
92+
const { context, emitted } = fakeTriggerContext({
93+
resource: "vehicle",
94+
event: "all",
95+
vin: "",
96+
});
97+
const node = new TeslemetryTrigger();
98+
99+
const result = await withMockedFetch(
100+
() =>
101+
new Response(`data: ${JSON.stringify({ vin: "5YJSA1E14FF000000", state: "online" })}\n\n`, {
102+
status: 200,
103+
headers: { "Content-Type": "text/event-stream" },
104+
}),
105+
() => node.trigger.call(context),
106+
);
107+
108+
await waitFor(() => emitted.length > 0);
109+
110+
await result.closeFunction!();
111+
const countAfterClose = emitted.length;
112+
113+
// A second close is a no-op, not a re-teardown attempt.
114+
await result.closeFunction!();
115+
assert.equal(emitted.length, countAfterClose);
116+
});

0 commit comments

Comments
 (0)