ADR 0253 — A Telegram request that never left is safe to repeat
Invariants: I8, I13, I14. Builds on ADR 0141, ADR 0143.
Context
The Bot API senders of stitchkit/telegram call fetch. fetch folds establishing the
connection and waiting for the answer into one failure, and from that failure nobody can say
whether Telegram saw the message. So the senders treat every network failure without a status as
unknown: a broadcast records the recipient uncertain and halts, the operator channel drops the
message. That is the right answer for a request that may have arrived — a repeat could duplicate
it — and the wrong one for a request that never left. A consuming project measured a route on
which, about one attempt in fifteen, no connection was established at all for eight seconds; each
such attempt lost an alert that had never been sent. It wrote its own transport to tell the two
apart, and every other sender of notifications needs the same distinction.
Decision
- The outcome of a send has three values, separated at the transport.
createTelegramBotTransportresolves the host, races its addresses (Happy Eyeballs, RFC 8305) and writes the request only on an established connection. Until the first byte is written, every failure — lookup, connection, an unreadable body — isTelegramNotDispatchedErrorwith itsstage: the request certainly did not leave. After that, a failure is an ordinary error: the request may have arrived, and its outcome is unknown. A received answer is Telegram’s own word, as before. - Classification reads the transport’s word first.
classifyTelegramSendFailurereturns the new reasonnot-dispatched—retryable, notrecipientUnreachable,evidence: 'transport'— for that error anywhere in a cause chain. A broadcast retries it astransient; the operator channel sends it again. Everything else keeps its classification, and a failure without status or answer staysunknown, never retried. - The transport is opt-in, through the existing
fetchseam. Every sender takesfetch: TelegramFetch,(url, init) => Promise<Response>; the globalfetchstays the default, because it honours proxies and the transport does not.callTelegramBotApipassesTelegramNotDispatchedErrorthrough unchanged (its message carries no URL, so no token). - Every wait is bounded. The address lookup and the connection rounds stop at
connectBudgetMs, the answer must arrive withinresponseTimeoutMsand fitmaxResponseBytes; a caller’s signal rejects with its reason at any stage. The read of a request body the caller streams is bounded by that signal alone: how long a body may take is the caller’s to say.
Rejected alternatives
- Leave the transport with each consumer and document the boundary. The distinction is a property of every sender, not of one project; the second copy would be written by the next project that loses an alert.
- Infer “not sent” from
fetcherror codes (ECONNREFUSED, DNS errors). The runtimes do not expose them consistently, and a connect timeout and a lost answer arrive as the sameTypeError. - Make the transport the default. It would silently stop honouring a proxy a deployment relies on.
Consequences
TelegramSendFailureReasongainsnot-dispatchedandTelegramSendFailure.evidencegainstransport: a breaking change for an exhaustiveswitchorRecordover them in this evolving entrypoint, migrated indocs/guide/upgrading.md.tests/telegram-transport.test.tsdrives a real local socket: a refused connection is not dispatched and a broadcast retries it; a connection dropped after the request was written staysunknownandambiguous; a silent first address loses the race; answers past the size or time limit are unknown outcomes.