What this is about
A pipeline does not produce messages evenly but in bursts: a new feed delivers many entries, one event triggers several alarms, or an error repeats in a loop. The Telegram Bot API limits how fast a bot may send messages, especially to a single chat. If the limit is exceeded, the server answers with status 429 and a waiting time.
The exact numbers change and are found in the official documentation of the Bot API. They are not decisive for building the pipeline. What matters is delivery that respects limits instead of running against them.
What happens when the limit is exceeded
The response with status 429 contains a field retry_after in the body that gives the waiting time in seconds. If you ignore this value and send again immediately, you may extend the block in the worst case. So stick to the reported value.
Building blocks of robust delivery
- Queue: messages are not sent directly but put into a queue. A single sender works through it.
- Rate limiting: the sender keeps a minimum gap between messages.
- Retry with backoff: on errors it waits longer, on 429 exactly the requested time.
- Combining: many similar messages are bundled into one.
- Prioritization: alarms overtake routine.
A simple sender with a queue
import asyncio
import logging
import random
import aiohttp
log = logging.getLogger("telegram")
async def sender(queue: asyncio.Queue, token: str, chat_id: str,
min_gap: float = 1.1) -> None:
url = f"https://api.telegram.org/bot{token}/sendMessage"
async with aiohttp.ClientSession() as session:
while True:
text = await queue.get()
attempt = 0
while True:
async with session.post(url, json={"chat_id": chat_id,
"text": text}) as resp:
if resp.status == 200:
break
if resp.status == 429:
data = await resp.json()
wait = data.get("parameters", {}).get("retry_after", 5)
else:
attempt += 1
wait = min(60, 2 ** attempt) + random.random()
log.warning("Status %s, warte %.1f s", resp.status, wait)
await asyncio.sleep(wait)
queue.task_done()
await asyncio.sleep(min_gap)
The value min_gap is an example that you adapt to the documentation and to your chat. The token belongs in an environment file and not in the code.
Backoff with jitter
Without a random component, several senders would ask again at the same moment after an error. The small random value in the example (random.random()) spreads the attempts. The waiting time grows exponentially but is capped so that it does not run away.
Coalescing: bundling messages
Fifty messages with the same cause are useless to a human. Collect them briefly, say ten seconds, and send a single message with the count and the most important lines.
def coalesce(messages: list[str], limit: int = 5) -> str:
head = messages[:limit]
rest = len(messages) - len(head)
text = "\n".join(head)
if rest > 0:
text += f"\n... und {rest} weitere"
return text
This relieves the limit and the reader alike. Keep the full details in the database so that nothing is lost.
Prioritization
Not every message is equally urgent. With an asyncio.PriorityQueue alarms overtake routine. Make sure routine does not starve permanently: the heartbeat should run in its own channel or with guaranteed delivery.
Common mistakes
retry_afterwas ignored and the message was resent in a tight loop.- Several processes send in parallel with the same bot, so that the limit is exceeded jointly.
- Failed messages are lost because there is no retry.
- Messages are sent untruncated and fail because of the length limit.
When it is worth it (and when not)
The effort is worth it when bursts occur: several channels, many feeds or third-party webhooks whose volume you do not control. Then a queue with backoff and bundling is the stable solution.
With a few messages a day to a single chat, a short pause between messages is enough. A message broker or elaborate routing would be overkill here. For pure alarms without a reply function, a service such as ntfy is also worth a look.