Skip to content

Fix: route updates via sub-observers instead of dp.update() - #45

Merged
rezhajulio merged 2 commits into
rezhajulio:mainfrom
TayoBus:fix/dispatcher-wiring
Oct 8, 2026
Merged

rezhajulio merged 2 commits into
rezhajulio:mainfrom
TayoBus:fix/dispatcher-wiring

Conversation

@TayoBus

@TayoBus TayoBus commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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 handled for every incoming update). Production was rolled back to 4465dbe to restore service.

Root cause (two bugs in main.py wiring)

  1. @dp.update() user handlers never run. Dispatcher.__init__ registers its own internal _listen_update on dp.update first, and TelegramEventObserver.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.31 Dispatcher: handler call count = 0.
  2. state kwarg collision. Even if the handler had run, the shared-state kwarg was named state — FSMContextMiddleware (always registered on dp.update) injects its own FSMContext as state on update handlers, shadowing our AppState. Verified: the handler received an FSMContext instead of our state object.

Fix

  • Register the single update entrypoint on the four concrete sub-observers (dp.message(), dp.edited_message(), dp.callback_query(), dp.chat_member()) — these have no internal handlers, so the entrypoint is always reached. The full Update is recovered via the event_update kwarg that _listen_update sets (verified working).
  • Rename the shared-state kwarg to app_state everywhere in the wiring (start_polling, _route_update, on_startup, on_shutdown).
  • Extract 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:

  • Entry per update: dispatch: update_id=… kind=… chat=… user_id=… cmd=… (+ wiring: received update_id=… at the aiogram entrypoint)
  • Per matched handler: label, plugin, group; plus StopPropagation / error-handler routing decisions
  • dispatch: update_id=… unhandled by all groups when nothing matches; per-spec filter misses at DEBUG
  • handle_bot_error lines now carry the update_id

The 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

  • New tests/test_main_wiring.py (3 tests): feeds synthetic message/callback-query updates through a real Dispatcher wired exactly like production and asserts dispatch_update is reached with the real AppState (not FSMContext); asserts the entrypoint lives on the sub-observers, not dp.update.
  • New TestDispatchLogging (5 tests) in tests/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.

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.
@rezhajulio
rezhajulio merged commit 31c6130 into rezhajulio:main Oct 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants