grammyjs / grammyjs/website

getUpdates long-poll hangs forever with no error when a proxied connection stalls (node-fetch v2 + custom Agent)

Open Beginner friendly
#1,312 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.