getUpdates long-poll hangs forever with no error when a proxied connection stalls (node-fetch v2 + custom Agent)
- Dominant language
- TypeScript
- Stars
- 52
- Forks
- 138
- PR merge metrics
- No merged PRs in 30d
Description
## Title
`getUpdates` long-poll hangs forever with no error when a proxied connection stalls (node-fetch v2 + custom Agent, e.g. socks-proxy-agent)
## Summary
When `client.baseFetchConfig.agent` is set to a custom `http(s).Agent` (e.g. `SocksProxyAgent`, per the documented proxy setup at grammy.dev/advanced/proxy), a `getUpdates` long-poll request can hang **indefinitely with zero error** if the underlying connection stalls mid-request - not just on a slow/dead network, but reliably in any setup where the proxy's outbound connection can silently die (SSH `-D` dynamic SOCKS5 forwards being the common case, since OpenSSH's `ServerAliveInterval` is off by default).
`client.timeoutSeconds` (default 500s) does **not** help here, because the hang isn't in grammY's own logic - it's inside `node-fetch` v2's underlying request, which itself is stuck inside the custom agent's `connect()` method with no cancellation path at all once it's already `await`ing there.
## Root cause
Traced by reading the actual installed source of each library involved (not guessing from docs):
1. **`socks-proxy-agent`'s `connect()`** (`dist/index.js`) does:
```js
const { socket } = await socks_1.SocksClient.createConnection(socksOpts);
if (timeout !== null) {
socket.setTimeout(timeout);
socket.on('timeout', () => cleanup());
}
```
The `socket.setTimeout()` call only happens **after** `createConnection()` resolves. If that `await` itself never resolves (stalled TCP handshake, dead upstream), there is no timeout, no abort hook, nothing - the promise just never settles. This is unconditional; it happens whether or not you configure `timeout` on the agent (that option only protects the socket *after* connection, not the connection attempt itself).
2. **`node-fetch` v2 defaults `timeout: 0`** (disabled), and grammY doesn't set it. grammY's own `AbortController`+`setTimeout` mechanism (`client.timeoutSeconds`, default 500s) wraps the *outer* fetch promise, but doesn't reach into cancelling the agent's in-flight `connect()` - so even a low `timeoutSeconds` doesn't fully solve it; it just changes how long you wait before grammY gives up on that one promise, while the orphaned `connect()` may still be sitting there consuming the agent's connection slot.
3. Reproduced with a real SSH `-D` SOCKS5 tunnel to a remote VPS: `getMe()` and any `getUpdates` call that has data already pending succeed reliably (fast, connection likely reused or short-lived). The **first genuinely idle long-poll wait** (nothing pending, server holding the connection open) is the one that hangs - consistent with the tunnel's underlying TCP stream dying silently sometime during the idle wait, with nothing downstream able to notice.
## Repro
```ts
import { Bot } from "grammy";
import { SocksProxyAgent } from "socks-proxy-agent";
const socksAgent = new SocksProxyAgent("socks5://:");
const bot = new Bot(token, {
client: { baseFetchConfig: { agent: socksAgent, compress: true } },
});
bot.start({
onStart: (info) => console.log(`Polling started: @${info.username}`), // fires fine
});
// ...bot goes completely silent forever on the first idle long-poll,
// no error, no log, nothing - even six live messages sent to the bot
// during the hang produce zero reaction.
```
## What actually fixed it (for us)
Bypassing `node-fetch` v2 entirely and using a raw `https.request`-based custom `fetch`, passed via `client.fetch` instead of `client.baseFetchConfig.agent`, socket-level `timeout` set explicitly on the request options (so a real, working timeout exists at the layer that can actually observe the stall):
```ts
function createHttpsFetch(agent: https.Agent): typeof fetch {
return (async (url, init) => {
return new Promise((resolve, reject) => {
const req = https.request({ agent, /* ...url parts... */, timeout: 25000 }, (res) => {
/* collect body, resolve(new Response(...)) */
});
req.on("timeout", () => req.destroy(new Error("timed out")));
req.on("error", reject);
req.end();
});
}) as typeof fetch;
}
```
This mirrors what a different Node Telegram bot framework (Telegraf) does by passing the agent straight to `https.request()` with no fetch-style wrapper in between - which we confirmed works reliably in production through the exact same tunnel/agent our grammY bot was stuck on.
## Suggested improvements to grammY
- Document (grammy.dev/advanced/proxy) that `client.baseFetchConfig.agent` alone provides **no protection** against a stalled connection attempt, and that a socket-level `timeout` (via `baseFetchConfig` or agent construction) is load-bearing, not optional, when proxying.
- Consider whether `client.timeoutSeconds` could/should also forward an `AbortSignal` in a way that a custom agent's `connect()` can observe (e.g. checking `signal.aborted` between an internal retry loop, or documenting that agents used with grammY should implement their own abort-aware `connect()`).
- A minimal repro + this write-up available if useful for a test case.
Happy to open a PR against the proxy docs page if that's welcome.
Contributor guide
Research direction
Start at the grammy.dev/advanced/proxy documentation page and review its guidance for client.baseFetchConfig.agent and proxy setup. Update the page to explain that a custom agent may not protect against stalled connection attempts and that an appropriate socket-level timeout is important; done means the limitation and mitigation are clear to proxy users.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- documentation
- Issue type
- Documentation
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 70/100