Architecture¶
TelegramMe is a thin, typed layer over t.me's public web previews. Python holds the business logic; the expensive parts — network I/O, TLS, HTTP/2, HTML parsing, JSON, the HTTP server — run in native code.
Layers¶
flowchart TB
subgraph granian["granian (Rust) — HTTP server, worker processes"]
subgraph app["FastAPI application"]
MW["middleware<br/>rate limit · headers · CORS"]
R["api/routes.py"]
MW --> R
end
end
R --> CS["services/channels.py<br/>ChannelService"]
R --> FS["services/feed.py<br/>FeedService"]
FS --> CS
RF["FeedRefresher<br/>(embedded or worker)"] --> CS
CS --> C["cache.py<br/>in-process (Rust) + Redis"]
CS --> SF["services/coalesce.py<br/>SingleFlight"]
SF --> TC["telegram/client.py<br/>TelegramClient"]
TC --> F["BrowserFetcher<br/>wreq (Rust: tokio · BoringSSL)"]
F --> T(("t.me"))
CS --> P["telegram/parsing<br/>telegram-me-native (Rust)"]
CS --> V["schemas<br/>pydantic-core (Rust)"]
FS --> NF["build_feed<br/>telegram-me-native (Rust)"]
| Package | Responsibility |
|---|---|
api/ |
HTTP concerns only: routes, parameter validation, feature flags, error → status mapping, pure-ASGI middleware |
services/ |
Business logic: fetching, caching and validating pages, the feed, scoring, popularity, request coalescing |
telegram/ |
Everything t.me-specific: the HTTP client with its retry and concurrency rules, the HTML parsers, error types |
schemas/ |
Public response and request models |
cache.py |
In-process and Redis levels with entry age, circuit breaker |
container.py |
Creates and closes long-lived objects; the only place that knows how things are wired |
app.py, server.py |
Application factory with lifespan; process entry points |
Dependencies point downwards only: api → services → telegram, cache.
Nothing below api imports FastAPI.
Request lifecycle¶
Taking GET /v1/body/durov as an example:
- granian accepts the connection and hands the request to a worker.
- Middleware: rate limiting (if enabled), then response headers
(
X-App-Node,X-Process-Time, CloudflareCache-Control), then CORS. - FastAPI validates
channelagainst the username pattern (→422). ChannelService.body_jsonlooks for the rendered response in the cache. A hit is returned as bytes — no parsing, no validation.- On a miss, SingleFlight makes sure only one coroutine fetches
t.me/s/durov; concurrent requests await the same task. With Redis, that coroutine takes a short lease so that only one process of all servers fetches it; the others pick its result up from the cache (see Caching). TelegramClienttakes a slot of the per-process upstream gate (fair across channels and feed requests,503when its queue is full) and asksBrowserFetcherfor the page; wreq performs the request on a pooled HTTP/2 connection in Rust, outside the GIL, with the TLS handshake and headers of one browser (HTTP client), and returns the decoded body. A dropped connection is retried once.- On one of two parsing threads,
parse_channel_pagehands the HTML to the Rust parser (GIL released), which builds the tree once with html5ever and extracts the channel profile, pagination and every post; a post that fails is skipped and logged. - Still on that thread, the parsed page is validated against
ChannelBody(posts that do not validate are dropped) and serialized by pydantic-core; back on the event loop it is cached (for/v1/postand the feed) together with the rendered response. - The route returns the JSON bytes directly, bypassing FastAPI's own response validation.
Lifespan¶
create_app() builds the FastAPI app; its lifespan opens a
Container
(cache, HTTP client, services) and, if the feed is enabled in embedded
mode, starts the refresher task. On shutdown everything is closed in reverse
order: refresher, HTTP client, cache.
Process model¶
telegram-me serve starts granian with WEB_CONCURRENCY worker processes.
Each worker has its own event loop (uvloop), HTTP client and in-process
cache (in front of Redis when it is set). granian restarts crashed workers, can recycle workers by
memory, and forwards SIGTERM for a graceful shutdown.
telegram-me worker runs only the feed refresher, for deployments that keep
background work off the API processes.
Native components¶
| Concern | Library | Implemented in |
|---|---|---|
| HTTP server, worker supervision | granian | Rust |
| Event loop | uvloop | C (libuv) |
| Outgoing HTTP/2, TLS, pooling, decompression | wreq (telegram-me-native); pyreqwest, not used in production |
Rust (tokio, BoringSSL) |
| HTML parsing and extraction | telegram-me-native (html5ever, selectors, regex) |
Rust |
| Validation, JSON (responses and cache) | pydantic-core | Rust |
| Redis protocol | hiredis | C |
| Feed assembly: signals, ranking, duplicates, topics, response | telegram-me-native (this project) |
Rust (PyO3) |
| In-process cache | telegram-me-native (moka, libzstd) |
Rust (PyO3), C |
| Request scheduling: flights, fair upstream gate, renewal points | telegram-me-native (this project) |
Rust (PyO3) |
Native code written for this project lives in the Rust crate
rust/telegram-me-native,
built with maturin as a uv workspace member. Python stays the orchestration
layer: fetching, caching, routing and gluing results together. What is left
on the hot path in Python, and the order in which it moves to Rust, is in the
Rust roadmap.
The measured effect is on the Performance page.