A buyer pays 100 Stars, Telegram takes them, and the bot replies to the payment message with "Sorry, I don't know this command". The on_paid handler that should hand over the goods never runs, and there is no row for the payment anywhere. That is what my library, aiogram-stars, did the day before its 0.1.0 release, in any bot that included a catch-all router before it.

That bug never made it into a release. Before I published the first version, I went through it again with one question: where can a payment get lost here? I found nine bugs. Each one is fixed in its own commit, and each commit has a test that fails on the code before it. This post covers four of them in detail: asyncio.wait_for not holding the 10-second pre-checkout deadline, the catch-all router, dp.run_polling() shutting down in the middle of on_paid, and flood control in the refund queue. The other five are. a short list at the end, followed by what is still not solved.

The code is in sinnercode228/aiogram-stars. Links to the library's lines point to commit d601bc3, the 0.1.0 release, except the two that show the code before a fix. I extracted the library from the payment code of Zernolist, a demo coffee shop that runs as a Telegram Mini App. All the runs below are mine, on aiogram 3.31.0 and Python 3.13; the wait_for experiment also ran on 3.10 to 3.12.

How a Stars payment works

A payment arrives as two updates. The first is pre_checkout_query: Telegram asks the bot whether to go ahead and waits 10 seconds for answerPreCheckoutQuery. Without an answer in time, the payment fails for the buyer. The second is a message with successful_payment: the Stars are gone from the buyer's balance, and the bot has to deliver. The only way to give them back is refundStarPayment.

With aiogram-stars you write two functions: check decides whether to take a payment, and on_paid hands over the goods. This is the middle of the quick start from the README:

async def check(checkout: Checkout) -> None: if stock["coffee"] == 0: raise RejectPayment("Sold out, sorry.") # declines the pre-checkout query  async def on_paid(payment: Payment, message: Message) -> None: if stock["coffee"] == 0: # the last bag went while this buyer was paying  raise RejectPayment("Sold out. Your Stars are on their way back.") # queues a refund  stock["coffee"] -= 1 await message.answer(f"Order {payment.order_id} is paid, thank you!") stars = StarsPayments( MemoryStorage(), secret=os.environ["STARS_SECRET"], check=check, on_paid=on_paid ) dp = Dispatcher() stars.setup(dp) 

The library does the rest. The invoice payload carries an HMAC over the order id and the amount, so a payload made elsewhere or paid with another amount is declined before check runs. Pre-checkout is answered before the deadline. on_paid runs once per telegram_payment_charge_id: before the call, a row with status processing and a lease is inserted (INSERT ... ON CONFLICT (charge_id) DO NOTHING), and only the handler that inserted it goes on. A refund is written to a table first, and a background task sends it with retries.

That is the design. Here is where it did not hold.

1. Ten seconds to answer, and the answer left after eleven

check usually queries a database: is the item in stock, has this order been paid already. So that a slow database cannot eat all ten seconds, the library gives check checkout_timeout seconds, 8 by default, and keeps the remaining two for the answerPreCheckoutQuery request itself. The first version did this with asyncio.wait_for (_payments.py#L326-L344 at 4bc1436):

 try: await asyncio.wait_for(self._check(checkout, data), self._checkout_timeout) except RejectPayment as exc: log.info("check declined order %s: %s", order_id, exc.reason) return False, exc.reason except asyncio.TimeoutError: log.error( "check for order %s took longer than %s s; declined pre-checkout query %s", order_id, self._checkout_timeout, query.id, ) return False, self._checkout_error # ... (other exceptions cut)  return True, None 

It looks right: too slow means a decline with checkout_error. But on timeout, wait_for cancels the task, waits for it to finish, and only then raises TimeoutError. Cancellation in asyncio is a request. CancelledError is raised where check is waiting, and then its finally and except blocks run to the end, awaits included. If check holds a database connection and rolls back in finally on that same hung connection, wait_for waits for the rollback too.

You can see it without aiogram:

import asyncio import time async def slow_to_stop() -> None: try: await asyncio.sleep(30) # a stock query that hangs  finally: await asyncio.sleep(0.5) # a rollback on the same hung connection  async def ignores_cancel() -> None: try: await asyncio.sleep(30) except asyncio.CancelledError: await asyncio.sleep(0.1) # and returns as if all was well  async def main() -> None: for check in (slow_to_stop, ignores_cancel): started = time.monotonic() try: await asyncio.wait_for(check(), timeout=0.05) outcome = "returned: the payment would be approved" except asyncio.TimeoutError: outcome = "TimeoutError" print(f"{check.__name__}: {outcome} after {time.monotonic() - started:.2f} s") asyncio.run(main()) 

Python 3.10, 3.11, 3.12 and 3.13 all print the same:

slow_to_stop: TimeoutError after 0.55 s ignores_cancel: returned: the payment would be approved after 0.15 s 

The timeout is 0.05 s, and TimeoutError comes after 0.55 s. The second line is worse: when check swallows the cancellation and returns normally, wait_for returns its result and raises nothing. The Python 3.13 docstring says so: "If the task suppresses the cancellation and returns a value instead, that value is returned." Swallowing it is easy, since CancelledError derives from BaseException and a bare except: or except BaseException catches it. The old code then reached return True, None and approved the payment, although check had been interrupted halfway.

I also ran the old code with the default settings against a fake Bot API session: check hangs, and the rollback in finally takes 3 seconds. The log says "took longer than 8.0 s", but the answer went out 11.01 s after handling began. That is past the 10 seconds Telegram allows, before counting the time the update took to reach the bot.

The fixed code (_payments.py#L391-L409) runs check as a task and waits for it with asyncio.wait. On timeout, wait cancels nothing and waits for nothing; it returns:

 # A task rather than wait_for, which waits for the cancelled check to stop: a check that  # is slow to clean up, or that ignores its cancellation, must not delay the answer.  task = asyncio.ensure_future(check(checkout, data)) try: await asyncio.wait({task}, timeout=self._checkout_timeout) except asyncio.CancelledError: task.cancel() raise if not task.done(): task.cancel() log.error( "check for order %s took longer than %s s; declined pre-checkout query %s", order_id, self._checkout_timeout, query_id, ) return _Verdict( ok=False, error=self._checkout_error, order_id=order_id, late_check=task ) 

The cancelled task travels in the verdict as late_check, and the handler waits for it only after the answer is sent (#L363-L370):

 verdict = await self._decide(query, data) try: await bot.answer_pre_checkout_query( pre_checkout_query_id=query.id, ok=verdict.ok, error_message=verdict.error ) finally: if verdict.late_check is not None: await _outlast(verdict.late_check, verdict.order_id) 

_outlast waits for the task and logs a warning if it did not stop. The task is not abandoned: the handler waits for it to the end, and the error it finished with goes to the log, which a separate test checks. Cancelling the handler cancels check as well; that is the except asyncio.CancelledError branch.

Running check as a task has a second effect: the verdict is now read from the task explicitly. A task that cancelled itself declines. RejectPayment declines with a reason the buyer sees. Any other exception declines with checkout_error. A return value other than None declines too. Before commit 668bf9d, a check that returned False approved the payment, because nothing looked at the result.

The test (tests/test_pre_checkout.py#L165-L191):

async def test_check_that_is_slow_to_stop_is_answered_at_the_deadline( bot: Bot, session: FakeSession, caplog: pytest.LogCaptureFixture ) -> None: cleaned_up = asyncio.Event() async def check(checkout: Checkout) -> None: try: await asyncio.sleep(30) finally: # As slow as the work itself, like a rollback on a database connection that hangs.  await asyncio.sleep(0.5) cleaned_up.set() dp = dispatcher(make_stars(check=check, checkout_timeout=0.05, checkout_error="Too slow")) started = time.monotonic() with caplog.at_level(logging.WARNING, logger="aiogram_stars"): handling = asyncio.create_task( dp.feed_update(bot, pre_checkout_update(bot, SIGNER.sign("order-1", 10), 10)) ) assert await answered(session) - started < 0.4 assert answer(session) == (False, "Too slow") assert not cleaned_up.is_set() await handling assert cleaned_up.is_set(), "the handler lets the cancelled check finish after answering" assert "check for order order-1 took longer than 0.05 s" in caplog.text 

On the code before the fix (4bc1436) it fails on the first assertion, with the answer sent 0.56 s in against a 0.05 s timeout:

E assert (473.466552522 - 472.908190609) < 0.4 

The test next to it, where check swallows the cancellation, fails on the old code with AssertionError: assert (True, None) == (False, 'Too slow'): the payment was approved. And in the run with default settings, the fixed code answers after 8.01 s.

2. A catch-all router took the payment

aiogram looks for a handler in the router tree: the router's own handlers first, then its sub-routers in order, and the first handler that matches takes the update (router.py#L199-L202 in aiogram 3.31.0):

 for router in self.sub_routers: response = await router.propagate_event(update_type=update_type, event=event, **kwargs) if response is not UNHANDLED: break 

include_router sets the router's parent_router (#L278), and that setter appends it to the end of the list (#L252-L253). In the first version stars.setup(dp) did exactly router.include_router(self._router). A common pattern in bots is a router that answers unknown commands, an @router.message() with no filters. A message with successful_payment is a message too, so when such a router is included before setup, it takes the payment.

I built that bot on the code before the fix (668bf9d): a fallback router with one handler that answers "Sorry, I don't know this command", then dp.include_router(fallback), stars.setup(dp), a pre-checkout query and a payment. The result:

sub_routers: ['fallback', 'aiogram-stars'] pre-checkout ok=True; on_paid calls: 0; stored: None; bot replied: ["Sorry, I don't know this command"] 

Pre-checkout went through: fallback has no pre_checkout_query handler, so the library answered it. Telegram took the Stars and sent successful_payment, and fallback took that. on_paid never ran, the storage is empty, and the buyer was told the bot does not know the command. If fallback also has an unfiltered @router.pre_checkout_query(), as in the test below, nobody answers pre-checkout at all, and Telegram fails the payment after 10 seconds.

The fix is two lines in setup after include_router (_payments.py#L202-L204):

 router.include_router(self._router) router.sub_routers.remove(self._router) router.sub_routers.insert(0, self._router) 

include_router still refuses to attach the router twice (aiogram raises RuntimeError), and then the library's router moves to the front of the list. The other routers keep their order. Handlers registered on dp itself still run first (#L183-L197), on purpose: that is where a bot can take payments for invoices in an old format, sent before it switched to the library. A separate test checks that @dp.message(F.successful_payment.invoice_payload.startswith("legacy:")) gets such a payment and the library does not record it. The flip side remains: a catch-all handler registered directly on dp still takes payments before the library does.

The test (tests/test_lifecycle.py#L235-L264):

async def test_catch_all_handlers_in_routers_included_earlier_do_not_swallow_payments( bot: Bot, session: FakeSession ) -> None: paid: list[Payment] = [] async def on_paid(payment: Payment) -> None: paid.append(payment) stars = make_stars(on_paid=on_paid) fallback = Router(name="fallback") @fallback.message() async def unknown_command(message: Message) -> None: return None @fallback.pre_checkout_query() async def any_query(query: PreCheckoutQuery) -> None: return None dp = Dispatcher() dp.include_router(fallback) stars.setup(dp) await dp.feed_update(bot, pre_checkout_update(bot, PAYLOAD, 100)) await dp.feed_update(bot, payment_update(bot, PAYLOAD, 100)) [answer] = session.calls(AnswerPreCheckoutQuery) assert answer.ok is True assert [payment.charge_id for payment in paid] == ["charge-1"] assert dp.sub_routers[-1] is fallback, "the other routers keep their order" 

On 668bf9d it fails at the unpacking, because pre-checkout was never answered:

E ValueError: not enough values to unpack (expected 1, got 0) 

3. Shutdown cut on_paid off, and Telegram never sent the update again

on_paid hands over the goods: it writes to a database, sends a message, maybe calls another service. If the bot restarts meanwhile (a deploy, systemctl restart, docker stop), the old code left the payment in processing for good.

Three places in aiogram 3.31.0 explain why.

First, with polling, each update is handled in a task of its own by default (handle_as_tasks=True), and the read loop does not wait for it (dispatcher.py#L398-L410):

 handle_update = self._process_update(bot=bot, update=update, **kwargs) if handle_as_tasks: # ... (the tasks_concurrency_limit branch cut)  else: handle_update_task = asyncio.create_task(handle_update) self._handle_update_tasks.add(handle_update_task) handle_update_task.add_done_callback(self._handle_update_tasks.discard) 

The tasks go into _handle_update_tasks, and that set is not used anywhere else, shutdown included.

Second, an update is confirmed by the next getUpdates with a higher offset (#L248-L254):

 for update in updates: yield update # The getUpdates method returns the earliest 100 unconfirmed updates.  # To confirm an update, use the offset parameter when calling getUpdates  # All updates with update_id less than or equal to offset will be marked  # as confirmed on the server and will no longer be returned.  get_updates.offset = update.update_id + 1 

Since the handler went off into a task, the next getUpdates goes out at once, while on_paid is still running. In my run below, by the time on_paid started for update 500, the bot had already sent getUpdates with offset=501. As far as Telegram is concerned, the update is handled, and it will not be sent again.

Third, run_polling is asyncio.run(self.start_polling(...)) (#L677), or, with uvloop installed on Python 3.11+, an asyncio.Runner, which does the same on exit. On stop, start_polling cancels the polling tasks, runs the shutdown hooks and closes the bot session (#L614-L631), without waiting for the handler tasks. Then, before returning, asyncio.run cancels every task that is still alive (runners.py#L198-L206), and on_paid gets CancelledError. CancelledError is not an Exception, so the except Exception branch in _handle_payment does not catch it, the outcome is never stored, and the row stays processing. If on_paid was about to raise RejectPayment because the item had sold out, the refund never gets queued either.

The run: dp.run_polling(bot) on a fake session, SqlAlchemyStorage on an SQLite file, one payment update in the queue. on_paid sleeps for a second to stand in for handing over the goods, and as soon as it starts, the process sends itself SIGTERM. The code before the fix (607a2c7):

 0.04s on_paid started; getUpdates sent with offsets [None, 501] 0.04s SIGTERM Received SIGTERM signal 0.04s on_paid cancelled 0.04s run_polling returned [('charge-1', 'processing')] 

I checked separately that asyncio.run does the cancelling and aiogram does not: when I call await dp.start_polling(bot) from my own coroutine and wait two seconds after it, on_paid finishes 1.04 s after the start and the payment is stored as accepted.

The lease (payment_lease) does not help here. It lets another handler take the payment over when an update for the same charge_id comes again, and this one will not come.

The fix has two parts. The payment handler records its task (_payments.py#L446-L455):

 handler = asyncio.current_task() if handler is None: # pragma: no cover - aiogram runs handlers in tasks  await self._handle_payment(message, paid, data) return key = object() # one per call: a handler may feed another update from its own task  self._handling[key] = (handler, paid.telegram_payment_charge_id) try: await self._handle_payment(message, paid, data) finally: del self._handling[key] 

And stop(), which the shutdown hook calls, first waits for those tasks, for at most payment_lease (#L216-L240):

 async def stop(self) -> None: # ... (docstring cut)  await self._wait_for_payments() await self._outbox.stop() async def _wait_for_payments(self) -> None: current = asyncio.current_task() handlers Source:https://dev.to/sinnercode228/the-stars-were-charged-and-onpaid-never-ran-where-telegram-stars-payments-get-lost-in-aiogram-5m2

Related posts