DEV Community

SinnerCode
SinnerCode

Posted on

The Stars were charged and on_paid never ran: where Telegram Stars payments get lost in aiogram

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
            )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

_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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

E   ValueError: not enough values to unpack (expected 1, got 0)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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 = {task: charge for task, charge in self._handling.values() if task is not current}
        if not handlers:
            return
        log.info("waiting for %d payment(s) being handled to finish", len(handlers))
        _, pending = await asyncio.wait(handlers, timeout=self._payment_lease.total_seconds())
        for task in pending:
            log.error(
                "payment %s is still being handled at shutdown; if the process exits now, "
                "on_paid is cut off and the payment stays %s",
                handlers[task],
                PaymentStatus.PROCESSING.value,
            )
Enter fullscreen mode Exit fullscreen mode

aiogram runs the shutdown hooks before it closes the bot session, so on_paid can still send messages while shutdown waits for it. The task is not current check is for stop() called from inside on_paid, which would otherwise wait for itself; there is a test for that too.

The same run after the fix:

 0.04s on_paid started; getUpdates sent with offsets [None, 501]
 0.04s SIGTERM
Received SIGTERM signal
 1.04s on_paid finished
 1.05s run_polling returned
[('charge-1', 'accepted')]
Enter fullscreen mode Exit fullscreen mode

The test (tests/test_lifecycle.py#L291-L311) starts start_polling with one update queued, waits for on_paid to begin, stops polling and checks the status:

async def test_shutdown_waits_for_a_payment_being_handled(bot: Bot, session: FakeSession) -> None:
    # dp.run_polling() ends in asyncio.run(), which cancels the handlers still running, and
    # Telegram does not send the update again: on_paid would be cut off for good.
    started = asyncio.Event()

    async def on_paid(payment: Payment) -> None:
        started.set()
        await asyncio.sleep(0.2)

    stars = make_stars(on_paid=on_paid, refunds=QUIET)
    dp = Dispatcher()
    stars.setup(dp)
    polling = await start_polling_with_a_payment(dp, bot, session)
    await asyncio.wait_for(started.wait(), 2)

    await dp.stop_polling()
    await polling

    payment = await stars.get_payment("charge-1")
    assert payment is not None
    assert payment.status is PaymentStatus.ACCEPTED
Enter fullscreen mode Exit fullscreen mode

On 607a2c7:

E   AssertionError: assert <PaymentStatus.PROCESSING: 'processing'> is <PaymentStatus.ACCEPTED: 'accepted'>
Enter fullscreen mode Exit fullscreen mode

The wait is capped at payment_lease, 10 minutes by default. A supervisor that will not wait that long and follows up with SIGKILL brings the problem back; more on that under "What is still open".

4. Flood control burnt the attempts of every refund at once

A refund in aiogram-stars is a row in stars_refunds. Right after a refund is queued in the same process, and otherwise every poll_interval (10 seconds), a background task goes through the refunds that are due, one at a time: it claims the row with a lease, counts the attempt, and calls refundStarPayment. By default there are 8 attempts; the wait after the first failure is 30 seconds and doubles up to an hour, so the last attempt comes about 64 minutes after the first.

On a 429, Telegram sends retry_after, and aiogram turns that reply into TelegramRetryAfter. The first version (_outbox.py at c7a1c60) applied it only to the refund that hit it, and moved on to the next one in the same sweep:

        except TelegramRetryAfter as exc:
            error = describe(exc)
            wait = timedelta(seconds=exc.retry_after)
        # ... (cut)
        else:
            status = RefundStatus.PENDING
            next_attempt_at = now + max(self._policy.delay(refund.attempts), wait)
Enter fullscreen mode Exit fullscreen mode

Flood control applies to the bot, though, not to one payment. While it lasts, the next refundStarPayment in the same sweep only hits it again, and the attempt has already been counted when the row was claimed.

A run on the code before the fix: five refunds queued, the fake Bot API answers every call with 429 and retry_after=120, one sweep:

refundStarPayment calls: 5, attempts: [1, 1, 1, 1, 1]
Enter fullscreen mode Exit fullscreen mode

Five requests to Telegram during flood control, and every refund lost one of its eight attempts on a call that could not succeed. With the fix:

refundStarPayment calls: 1, attempts: [1, 0, 0, 0, 0]
Enter fullscreen mode Exit fullscreen mode

The fix is one _paused_until field for the whole queue (_outbox.py#L60-L61, #L109-L112, #L159-L164):

        # Flood control applies to the bot, so a TelegramRetryAfter pauses every refund.
        self._paused_until: datetime | None = None

    # ... (cut)

        while not (background and self._stop_requested()):
            now = self._clock()
            if self._paused_until is not None and now < self._paused_until:
                break

    # ... (cut)

    async def _attempt(self, bot: Bot, refund: Refund, token: str) -> Refund | None:
        error, wait = await self._send(bot, refund)
        now = self._clock()
        if wait:
            self._paused_until = now + wait
            log.warning("flood control: no refunds are sent before %s", self._paused_until)
Enter fullscreen mode Exit fullscreen mode

The check comes before the claim, so during the pause no refund is claimed and no attempt is spent. The refundStarPayment call itself moved into _send, which returns the error and the wait.

The test (tests/test_refunds.py#L182-L211):

async def test_flood_control_pauses_the_other_refunds_too(
    bot: Bot, session: FakeSession, storage: Storage, clock: Clock
) -> None:
    # Flood control applies to the bot: sending the next refund at once only hits it again.
    stars = outbox(storage, clock, Desk())
    await stars.refund("a", user_id=1)
    clock.advance(seconds=1)
    await stars.refund("b", user_id=2)
    session.fail(
        RefundStarPayment, "Too Many Requests: retry after 120", status=429, retry_after=120
    )

    [hit] = await stars.send_due_refunds(bot)

    assert (hit.charge_id, hit.status) == ("a", RefundStatus.PENDING)
    assert len(session.calls(RefundStarPayment)) == 1
    waiting = await stars.get_refund("b")
    assert waiting is not None
    assert waiting.attempts == 0, "the other refund was not tried during the flood wait"

    clock.advance(seconds=119)
    assert await stars.send_due_refunds(bot) == []
    assert len(session.calls(RefundStarPayment)) == 1

    clock.advance(seconds=1)
    sent = await stars.send_due_refunds(bot)
    # ... (both refunds are done)
Enter fullscreen mode Exit fullscreen mode

Here the fake Bot API answers 429 to the first call only. The old code (c7a1c60) sent the second refund in the same sweep, send_due_refunds returned two refunds instead of one, and the test fails at the unpacking with ValueError: too many values to unpack (expected 1). It runs against the in-memory storage and SQLite, and against PostgreSQL when STARS_TEST_POSTGRES_URL is set.

The pause lives in process memory. When several processes work through refunds in one database, each of them learns about flood control on its own first 429.

Five more, briefly

  • 4bc1436. The payload tag was compared with hmac.compare_digest on strings, which raises TypeError: comparing strings with non-ASCII characters is not supported when a string has non-ASCII characters. A payload whose tag was 22 letters "ё" (the length of a real tag) crashed the handler: pre-checkout went unanswered, and a payment with such a payload was neither recorded nor refunded. Tags are now compared as bytes.
  • 668bf9d. The return value of check was ignored, so return False approved the payment. Only None approves now; any other value declines and logs an error telling the author to raise RejectPayment.
  • 177a858. PaymentStatus and RefundStatus derive from str, Enum, and f"{status}" gave accepted on Python 3.10 but PaymentStatus.ACCEPTED on 3.11 and later. __str__ now returns the value.
  • 956a0cc. A blank checkout_error was accepted, while the Bot API needs error_message whenever ok is false ("Required if ok is False" in the method's description). Every decline that shows that text (a payload that does not verify, a slow or failing check) would have broken the rule. It is now a ValueError when StarsPayments is created. RejectPayment already refused a blank reason in its constructor.
  • b1668c5. The [sqlite] and [postgres] extras installed the driver but not SQLAlchemy, so aiogram_stars.sqlalchemy failed to import after pip install "aiogram-stars[postgres]".

What is still open

The big one: a payment can get stuck in processing. Telegram almost never sends a successful_payment update twice. Polling confirms it with the next getUpdates, as in story 3, and a webhook with handle_in_background=True (the default in SimpleRequestHandler) gets its 200 at once. So if the process is killed inside on_paid (SIGKILL, out of memory, a supervisor that does not wait payment_lease), the payment stays processing. The same happens if the database fails while the outcome is stored. If it fails while the payment is first recorded, there is no row at all, and the only traces are the log line and getStarTransactions, which the library does not call.

In 0.1.0 nothing listed stuck payments for you: the README suggested finding them with a SQL query and settling each one by hand. Version 0.2.0 adds that part. stuck_payments() lists error payments and processing ones whose payment_lease has run out. accept_payment(charge_id) marks one paid once you have handed over the goods yourself, and reject_payment(charge_id, reason=...) marks it rejected and queues its refund in the same storage transaction, as RejectPayment from on_paid does. For refunds that ran out of attempts, failed_refunds() lists them and retry_refund(charge_id) queues one again. Settling refuses a payment whose lease is still held, since on_paid may still be running, and of two admins who settle one payment at once, one wins and the other gets a ValueError. The Recovering stuck payments section of the README has a job that sends both lists to an admin chat, and the example bot has /stuck, /accept, /reject and /retry.

It is still manual work. The library never calls stuck_payments() on its own, so something has to run it, and a person has to find out what happened to the order. A failed refund that should not be sent any more stays in failed_refunds(), and a pending refund cannot be withdrawn: there is no method for either yet.

Four more limits:

  • on_refunded and on_refund_failed run once. If the process dies between storing the result and the callback, the notification is lost, although the status in the table is right.
  • The pre-checkout timeout covers check only. The time the update took to reach the bot and the answerPreCheckoutQuery request come out of the same 10 seconds.
  • The flood control pause applies to one process, not to the database.
  • The tests do not talk to Telegram. They run against a fake Bot API session whose replies go through aiogram's own response parsing, so errors come out as the exception classes aiogram raises in production.

Trying it

pip install aiogram-stars installs the library with MemoryStorage; pip install "aiogram-stars[sqlite]" or "aiogram-stars[postgres]" adds SQLAlchemy and a driver for SqlAlchemyStorage. It needs Python 3.10+ and aiogram 3.15+. The README's quick start runs in the test suite, and examples/shop_bot.py is a bot with /buy, /refund and /paysupport, plus /stuck, /accept, /reject and /retry for an admin. Code and issues: github.com/sinnercode228/aiogram-stars.


Written by sinnercode228, a freelance full-stack developer. Telegram: @sinnercode

Top comments (0)