Skip to content

Commit a750cfb

Browse files
fuleinistkoala73
andauthored
fix(relay): prevent unbounded recursion in sendTelegram on sustained 429 (koala73#3478)
* fix(relay): prevent unbounded recursion in sendTelegram on sustained 429 The 429 handler in sendTelegram() called itself unconditionally with no retry counter. Under sustained Telegram rate limiting, this could recurse indefinitely and crash the Railway relay process. Changes: - Add _retryCount parameter (default 0) to sendTelegram() - Add guard: if (_retryCount ?? 0) >= 1, log and return false immediately - Pass (_retryCount ?? 0) + 1 on the recursive 429 call (single retry only) - Non-429 paths (200, 400, 403, 401) are unaffected Testing: - Add regression test (static analysis of source, matching repo patterns) - All 4 new assertions pass: param signature, guard + return false, incremented recursive call, single call site - All 27 existing relay tests continue to pass Fixes koala73#3060 * fix(relay): address review feedback on sendTelegram 429 guard - Drop redundant `?? 0` — the `_retryCount = 0` default already guarantees the value is a number inside the function body. - Cancel the 429 response body on the bail path. Without this the undici socket can be held open until the body stream is GC'd, which under sustained rate limiting could pressure the connection pool. - Replace the static-source-text test with a proper integration test that invokes `sendTelegram` against a mocked `globalThis.fetch`. The new test asserts call count and return value across 200, 429→200, sustained 429 (bail), 403, and 401, plus body-cancel hygiene on the bail path. Source formatting changes that don't alter behaviour will no longer break it. Required surgery in the relay module: - Wrap the SIGTERM handler + `subscribe()` invocation in `if (require.main === module)` so requiring the script from a test does not start the poll loop. - Export `{ sendTelegram }` for direct invocation in tests. The relay's third-party deps (`resend`, `convex/browser`) live in `scripts/package.json` and are only installed in the Railway container, so the test stubs them at the loader level via `Module._load`. All 147 existing relay tests pass; 6 new assertions added. --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
1 parent b10464e commit a750cfb

2 files changed

Lines changed: 181 additions & 10 deletions

File tree

scripts/notification-relay.cjs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ async function processFlushQuietHeld(event) {
316316

317317
// ── Delivery: Telegram ────────────────────────────────────────────────────────
318318

319-
async function sendTelegram(userId, chatId, text) {
319+
async function sendTelegram(userId, chatId, text, _retryCount = 0) {
320320
if (!TELEGRAM_BOT_TOKEN) {
321321
console.warn('[relay] Telegram: TELEGRAM_BOT_TOKEN not set — skipping');
322322
return false;
@@ -337,10 +337,15 @@ async function sendTelegram(userId, chatId, text) {
337337
return false;
338338
}
339339
if (res.status === 429) {
340+
if (_retryCount >= 1) {
341+
res.body?.cancel();
342+
console.warn(`[relay] Telegram 429 retry limit reached for ${userId} — bailing`);
343+
return false;
344+
}
340345
const body = await res.json().catch(() => ({}));
341346
const wait = ((body.parameters?.retry_after ?? 5) + 1) * 1000;
342347
await new Promise(r => setTimeout(r, wait));
343-
return sendTelegram(userId, chatId, text); // single retry
348+
return sendTelegram(userId, chatId, text, _retryCount + 1); // single retry
344349
}
345350
if (res.status === 401) {
346351
console.error('[relay] Telegram 401 Unauthorized — TELEGRAM_BOT_TOKEN is invalid or belongs to a different bot; correct the Railway env var to restore Telegram delivery');
@@ -1100,12 +1105,16 @@ async function subscribe() {
11001105
}
11011106
}
11021107

1103-
process.on('SIGTERM', () => {
1104-
console.log('[relay] SIGTERM received — shutting down');
1105-
process.exit(0);
1106-
});
1108+
if (require.main === module) {
1109+
process.on('SIGTERM', () => {
1110+
console.log('[relay] SIGTERM received — shutting down');
1111+
process.exit(0);
1112+
});
1113+
1114+
subscribe().catch(err => {
1115+
console.error('[relay] Fatal error:', err);
1116+
process.exit(1);
1117+
});
1118+
}
11071119

1108-
subscribe().catch(err => {
1109-
console.error('[relay] Fatal error:', err);
1110-
process.exit(1);
1111-
});
1120+
module.exports = { sendTelegram };
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Regression test: scripts/notification-relay.cjs sendTelegram() must NOT
3+
* recurse infinitely on sustained 429 responses.
4+
*
5+
* Before the fix, the 429 handler called sendTelegram() unconditionally with
6+
* no retry counter, creating unbounded recursion during sustained rate
7+
* limiting. This could stack-overflow the Railway relay process.
8+
*
9+
* The fix adds a `_retryCount` parameter (default 0) and bails after one
10+
* retry. This test exercises the actual function via a mocked global fetch
11+
* and asserts on call count + return value, so it survives source-formatting
12+
* changes that don't alter behaviour.
13+
*
14+
* Run: node --test tests/notification-relay-telegram-retry.test.mjs
15+
*/
16+
17+
import { describe, it, before, beforeEach, afterEach } from 'node:test';
18+
import assert from 'node:assert/strict';
19+
import Module from 'node:module';
20+
import { createRequire } from 'node:module';
21+
import { dirname, resolve } from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
24+
const __dirname = dirname(fileURLToPath(import.meta.url));
25+
const require = createRequire(import.meta.url);
26+
27+
// Stub env vars BEFORE requiring the relay module so the top-of-file
28+
// validation block does not call process.exit(1).
29+
process.env.UPSTASH_REDIS_REST_URL ??= 'https://stub.upstash.io';
30+
process.env.UPSTASH_REDIS_REST_TOKEN ??= 'stub-token';
31+
process.env.CONVEX_URL ??= 'https://stub.convex.cloud';
32+
process.env.RELAY_SHARED_SECRET ??= 'stub-secret';
33+
process.env.TELEGRAM_BOT_TOKEN ??= 'stub-bot-token';
34+
35+
// The relay's runtime deps (`resend`, `convex/browser`) live in
36+
// scripts/package.json and are only installed in the Railway container, so
37+
// they are not on the resolution path when running tests from the repo root.
38+
// Stub them at the loader level — `sendTelegram` only uses `fetch`, not these
39+
// modules, so empty shims are sufficient.
40+
const originalLoad = Module._load;
41+
Module._load = function patchedLoad(request, parent, ...rest) {
42+
if (request === 'resend') return { Resend: class { constructor() {} } };
43+
if (request === 'convex/browser') {
44+
return { ConvexHttpClient: class { constructor() {} async query() {} } };
45+
}
46+
return originalLoad.call(this, request, parent, ...rest);
47+
};
48+
49+
let sendTelegram;
50+
let originalFetch;
51+
52+
before(() => {
53+
// The relay only starts its poll loop when require.main === module, so
54+
// requiring it from a test is a side-effect-free import.
55+
({ sendTelegram } = require(
56+
resolve(__dirname, '..', 'scripts', 'notification-relay.cjs'),
57+
));
58+
assert.equal(typeof sendTelegram, 'function', 'sendTelegram export missing');
59+
});
60+
61+
function makeRes(status, body = {}) {
62+
let cancelled = false;
63+
return {
64+
status,
65+
ok: status >= 200 && status < 300,
66+
json: async () => body,
67+
body: { cancel() { cancelled = true; } },
68+
get _cancelled() { return cancelled; },
69+
};
70+
}
71+
72+
beforeEach(() => {
73+
originalFetch = globalThis.fetch;
74+
});
75+
76+
afterEach(() => {
77+
globalThis.fetch = originalFetch;
78+
});
79+
80+
describe('notification-relay sendTelegram retry discipline', () => {
81+
it('returns true on first-try 200 (no retry)', async () => {
82+
let callCount = 0;
83+
globalThis.fetch = async () => {
84+
callCount++;
85+
return makeRes(200, { ok: true });
86+
};
87+
const ok = await sendTelegram('user-1', 'chat-1', 'hello');
88+
assert.equal(ok, true, 'sendTelegram should return true on 200');
89+
assert.equal(callCount, 1, 'fetch should be called exactly once on 200');
90+
});
91+
92+
it('retries once on 429 then succeeds (returns true, fetch called twice)', async () => {
93+
const responses = [
94+
makeRes(429, { parameters: { retry_after: 0 } }),
95+
makeRes(200, { ok: true }),
96+
];
97+
let callCount = 0;
98+
globalThis.fetch = async () => {
99+
callCount++;
100+
return responses.shift();
101+
};
102+
const ok = await sendTelegram('user-1', 'chat-1', 'hello');
103+
assert.equal(ok, true, '429 → 200 should return true');
104+
assert.equal(callCount, 2, 'fetch should be called exactly twice (initial + 1 retry)');
105+
});
106+
107+
it('bails after exactly one retry on sustained 429 (no infinite recursion)', async () => {
108+
let callCount = 0;
109+
globalThis.fetch = async () => {
110+
callCount++;
111+
return makeRes(429, { parameters: { retry_after: 0 } });
112+
};
113+
const ok = await sendTelegram('user-1', 'chat-1', 'hello');
114+
assert.equal(ok, false, 'sustained 429 should return false');
115+
assert.equal(callCount, 2, 'fetch must be called exactly 2 times (initial + 1 retry, then bail)');
116+
});
117+
118+
it('cancels the response body on the bail path (no socket leak)', async () => {
119+
const responses = [];
120+
globalThis.fetch = async () => {
121+
const res = makeRes(429, { parameters: { retry_after: 0 } });
122+
responses.push(res);
123+
return res;
124+
};
125+
await sendTelegram('user-1', 'chat-1', 'hello');
126+
// The 2nd 429 response is the one that hits the bail branch — its
127+
// body must be cancelled to free the underlying socket.
128+
assert.equal(responses.length, 2, 'expected 2 responses (initial + 1 retry)');
129+
assert.equal(responses[1]._cancelled, true, 'bail-path response body must be cancelled');
130+
});
131+
132+
it('returns false on 403 without retry', async () => {
133+
// 403 triggers deactivateChannel() which itself calls fetch against
134+
// CONVEX_SITE_URL. We only care about Telegram-API calls here, so
135+
// count requests by hostname.
136+
let telegramCalls = 0;
137+
globalThis.fetch = async (url) => {
138+
const u = String(url);
139+
if (u.includes('api.telegram.org')) {
140+
telegramCalls++;
141+
return makeRes(403, { description: 'Forbidden: bot was blocked by the user' });
142+
}
143+
// deactivateChannel hits Convex — return a benign 200 so the call
144+
// doesn't throw and pollute this assertion.
145+
return makeRes(200, { ok: true });
146+
};
147+
const ok = await sendTelegram('user-1', 'chat-1', 'hello');
148+
assert.equal(ok, false, '403 should return false');
149+
assert.equal(telegramCalls, 1, '403 must not retry the Telegram call');
150+
});
151+
152+
it('returns false on 401 without retry', async () => {
153+
let callCount = 0;
154+
globalThis.fetch = async () => {
155+
callCount++;
156+
return makeRes(401, {});
157+
};
158+
const ok = await sendTelegram('user-1', 'chat-1', 'hello');
159+
assert.equal(ok, false, '401 should return false');
160+
assert.equal(callCount, 1, '401 must not retry');
161+
});
162+
});

0 commit comments

Comments
 (0)