Skip to content

Reliability and concurrency

What keeps a bot alive in production: not falling over on a flaky network, not tripping Telegram's flood limits, and using the cores you have when a batch of updates piles up.

Retries are automatic

Every API call goes through a retry layer. Network errors, HTTP 5xx, and 429 rate-limits are retried with a short backoff; a 429 honours the retry_after value Telegram sends rather than guessing. Permanent client errors (400, 401, 403, 404) raise straight away, because retrying them never helps.

You don't wire any of this up. It's on by default for both JSON calls and file uploads. A response that isn't even valid JSON (a curl hiccup, a truncated body) is treated as a transient failure, so a single bad read doesn't crash the call.

Handling errors

API calls raise on permanent failure, so wrap the ones that can fail and decide what to do. In a poll loop, catch per update so one bad handler doesn't kill the process:

while True:
    var ups = dp.poll()
    for i in range(ups.len()):
        try:
            handle(dp.context(Update(ups.at(i))))
        except e:
            print("handler error:", e)     # log and keep serving

The error message carries Telegram's code and description, for example Telegram API error [400] on sendMessage: chat not found, so a log line is usually enough to see what went wrong.

Rate limiting

Telegram caps you at roughly 30 messages a second overall, and about one a second to a single chat. Burst past that and you get 429s. The retry layer absorbs the occasional 429, but if you're sending in a tight loop, throttle on your side with a RateLimiter. It's a token bucket: acquire() blocks just long enough to stay under the rate.

from mojogram import RateLimiter

# 25 msg/s, a little under the cap
var limiter = RateLimiter(rate=25.0, burst=25.0)

for chat_id in chat_ids:
    # waits if we're going too fast
    limiter.acquire()
    _ = bot.send_message(chat_id, "broadcast")

It's thread-safe (an Atomic spinlock inside), so you can share one limiter across parallel workers.

Parallel batches

Mojo 1.0 has no threads, but it has parallelize, a parallel-for over a thread pool with no GIL. mojogram uses it to process a poll batch across cores, so one slow handler doesn't hold up the rest. It's opt-in; the default loop is sequential.

from std.algorithm import parallelize

var ups = dp.poll()
var n = ups.len()

@parameter
def worker(i: Int):
    try:
        # pass i as the slot
        handle(dp.context(Update(ups.at(i)), i))
    except e:
        print(e)

parallelize[worker](n)

Two things make this safe. Passing the worker index as the slot gives each worker its own HTTP temp files, so concurrent curl calls don't clobber each other. And the FSM store guards its maps with a spinlock, so workers touching shared state can't corrupt it. The lock is held only for the microseconds of a Dict op, never across a handler's network call, so the parallelism is real. It's been stress-tested at 500 distinct-key writes (none lost) and 1000 contended read-modify-writes (exact).

One caveat: the store protects against torn state, but a get-then-set for the same chat is still two operations. That's fine for one-update-per-chat FSM flows, which is the normal case.

Speaking more than one language

I18n is a small translation catalog. Add your strings per locale, then look them up with a fallback: the requested locale, then the default locale, then the fallback text you pass in.

from mojogram import I18n

var i18n = I18n(default_locale="en")
i18n.add("en", "hello", "Hello")
i18n.add("uz", "hello", "Salom")

var lang = msg.from_user().language_code()    # "uz", "en", ...
_ = ctx.answer(i18n.t(lang, "hello", "Hello"))

language_code() comes straight off the user, so you can pick a locale per person without asking them.