Retries and rate limits

Retries with growing delays, Retry-After honoured, and no retry after a network error, because a duplicate notification is worse than a missing one.

Every channel sends through the same transport, and it does three things you would otherwise write yourself. All three are on by default and all three can be turned off.

Copy this

server/api/hello.get.ts
export default defineEventHandler(async () => {
  await telegram.send('Deploy failed', {
    retries: 3,
    retryDelayMs: 1000,
    timeoutMs: 5000,
  })

  return { ok: true }
})

That runs, and it is the same three options on every channel and every verb.

OptionDefaultWhat it does
retries2Extra attempts after the first. 0 turns it off
retryDelayMs500Wait before the second attempt, doubled each time after
timeoutMs10000Per attempt, not for the whole thing. 0 disables it
retryOnNetworkErrorfalseSee rule three

With the defaults, a failing request takes three attempts and waits 500 then 1000 milliseconds in between.

Rule one: not every failure is worth repeating

A 500 is a bad moment. A 404 is a bad url, and trying it again only wastes the time your user is waiting.

StatusRetried
408, 409, 425, 429
500, 502, 503, 504
400, 401, 403, 404❌ your request is wrong, repeating it changes nothing
2xxnot a failure

Rule two: what the service asks for beats what we would pick

If the answer carries Retry-After, that wins over the doubling. The service knows when it will be ready and we do not.

Telegram puts the same number in the body rather than the header:

{ "ok": false, "error_code": 429, "parameters": { "retry_after": 30 } }

So the channel hands its own reading down to the transport, and the transport stays free of any knowledge about Telegram. A very large value is trimmed to 60 seconds, because waiting longer helps nobody.

Rule three: no answer means no retry

This is the one worth knowing.

When a request never comes back, there is no way to tell whether it arrived. The service may have taken it and died before answering. Sending again could deliver the message twice, and a duplicate notification at three in the morning is worse than a missing one.

So a network error is reported rather than repeated. If your case is different, for example an idempotent endpoint of your own, say so:

await webhook.send(body, { retryOnNetworkError: true })

Where a service offers a real guard, use that instead. Mastodon has one:

await mastodon.post(text, { idempotencyKey: deployId })

The same key twice creates no second post, so retrying is safe by construction.

Seeing it rather than believing it

The playground has a card for this. Pick a status, press, and the elapsed time is the proof: nothing counts attempts inside the transport, the clock does it from outside.

3 attempts, waiting 500 + 1000 = 1500ms in between
→ Answered 500 in 1841ms

What will cost you an hour

  • timeoutMs is per attempt. Three attempts at ten seconds can take thirty, plus the waiting. For a request a user is waiting on, lower it.
  • A 429 is not always a retry. If the service asks for 30 seconds, the module waits 30 seconds. That is correct and it is also a long time to hold a request open.
  • Rate limits are per service, not per module. Telegram allows roughly 30 messages a second globally and about 20 a minute into one group. Nothing here queues for you.
  • Do not make a user wait for a notification. If the message is not what they asked for, send it after the response rather than before it.

Read more