Repository navigation
Fix: route updates via sub-observers instead of dp.update() - #45
Merged
Merged
Conversation
The aiogram migration registered the single update entrypoint with @dp.update(). That handler never ran: Dispatcher.__init__ registers its own internal _listen_update on dp.update first, and the observer stops at the first matching handler, so every update fell through to _listen_update, found no router handlers, and was logged 'not handled'. The bot was deaf from the deploy moment. Register the entrypoint on the four concrete sub-observers instead (message, edited_message, callback_query, chat_member), which have no internal handlers, and recover the full Update via the event_update kwarg that _listen_update sets. Also rename the shared-state kwarg from state to app_state: FSMContextMiddleware (always registered on dp.update) injects its own FSMContext as 'state' on update handlers, which would have shadowed our AppState even if the handler had run. build_dispatcher() centralizes the wiring so tests can pin it; tests/test_main_wiring.py feeds synthetic updates through a real Dispatcher and asserts dispatch_update is reached with our AppState.
Every update now emits an update_id-tagged trail through the dispatch loop: entry (update_id, kind, chat, user, command), per matched handler (label, plugin, group), StopPropagation / error-handler decisions, and 'unhandled by all groups' when nothing matches. Per-spec filter misses stay at DEBUG. handle_bot_error and the wiring entrypoint (_route_update) also tag their lines with update_id. This makes the 2026-10-08 deaf-bot failure mode visible in Logfire: updates arriving with no dispatch lines (or aiogram 'not handled' with zero 'handled') is now directly queryable, and the monitor script (logfire_check.py) gained a liveness check on exactly that signal.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After the aiogram migration (PR #43) deployed, the bot was completely deaf — it polled fine, the scheduler ran, but no update was ever handled (Logfire showed
Update id=... is not handledfor every incoming update). Production was rolled back to 4465dbe to restore service.Root cause (two bugs in
main.pywiring)@dp.update()user handlers never run.Dispatcher.__init__registers its own internal_listen_updateondp.updatefirst, andTelegramEventObserver.trigger()stops at the first matching handler. Our entrypoint (registered second) was never called; every update fell through to_listen_update, found no router handlers, and returned UNHANDLED. Verified with a local repro against the real aiogram 3.31Dispatcher: handler call count = 0.statekwarg collision. Even if the handler had run, the shared-state kwarg was namedstate—FSMContextMiddleware(always registered ondp.update) injects its ownFSMContextasstateon update handlers, shadowing ourAppState. Verified: the handler received anFSMContextinstead of our state object.Fix
dp.message(),dp.edited_message(),dp.callback_query(),dp.chat_member()) — these have no internal handlers, so the entrypoint is always reached. The fullUpdateis recovered via theevent_updatekwarg that_listen_updatesets (verified working).app_stateeverywhere in the wiring (start_polling,_route_update,on_startup,on_shutdown).build_dispatcher()so the wiring is testable; document both gotchas in the docstring and AGENTS.md.Observability (2nd commit)
Every dispatch path is now trace-logged with the update_id, so the failure mode is directly visible in Logfire instead of silent:
dispatch: update_id=… kind=… chat=… user_id=… cmd=…(+wiring: received update_id=…at the aiogram entrypoint)dispatch: update_id=… unhandled by all groupswhen nothing matches; per-spec filter misses at DEBUGhandle_bot_errorlines now carry the update_idThe deploy-watch monitor script also gained a liveness check: when the aiogram build is the currently running one, updates arriving with zero handled (aiogram
not handled> 0,handled== 0) now trips ATTENTION instead of reporting healthy.Verification
tests/test_main_wiring.py(3 tests): feeds synthetic message/callback-query updates through a realDispatcherwired exactly like production and assertsdispatch_updateis reached with the realAppState(notFSMContext); asserts the entrypoint lives on the sub-observers, notdp.update.TestDispatchLogging(5 tests) intests/test_dispatch.py: asserts the entry/match/stop/unhandled/ignored log lines.uv run pytest: 1272 passed;ruff check .clean;mypy src/bot/ tests/clean (86 files).Deploy note
Merging this alone does not restore the aiogram build in production — it needs a redeploy after merge. The currently deployed 4465dbe (PTB) build is unaffected by this PR.