#6262·nicegui

`ui.on_exception` never sees exceptions from an async `@ui.refreshable` body

Author: evnchnCreated Aug 7, 2026Updated Aug 23, 2026
Labelsbug

First Check

  • I added a very descriptive title here.
  • This is not a security issue.
  • This is not a Q&A. I am sure something is wrong with NiceGUI or its documentation.
  • I used the GitHub search to find a similar issue and came up empty.

Example Code

python
# Save as mre.py next to a pytest.ini containing:
#     [pytest]
#     asyncio_mode = auto
#     main_file =
# then:  pip install nicegui==3.15.0 "pytest>=9,<10" pytest-asyncio && pytest mre.py
#
# Same async @ui.refreshable body in both tests. They differ by ONE line:
# the first passes, the second fails.
import asyncio

import pytest
from nicegui import ui
from nicegui.testing import User

pytest_plugins = ['nicegui.testing.user_plugin']


async def test_async_direct_call(user: User, caplog: pytest.LogCaptureFixture):
    seen: list[Exception] = []

    @ui.page('/')
    async def page():
        ui.on_exception(seen.append)

        @ui.refreshable
        async def part(explode: bool = False):
            await asyncio.sleep(0)
            if explode:
                raise RuntimeError('boom')

        await part()
        ui.button('fire', on_click=lambda: part(True))          # direct call

    await user.open('/')
    user.find('fire').click()
    await asyncio.sleep(0.1)
    caplog.records.clear()
    assert len(seen) == 1, f'ui.on_exception saw {len(seen)}, expected 1'


async def test_async_via_refresh(user: User, caplog: pytest.LogCaptureFixture):
    seen: list[Exception] = []

    @ui.page('/')
    async def page():
        ui.on_exception(seen.append)

        @ui.refreshable
        async def part(explode: bool = False):
            await asyncio.sleep(0)
            if explode:
                raise RuntimeError('boom')

        await part()
        ui.button('fire', on_click=lambda: part.refresh(True))  # via refresh()

    await user.open('/')
    user.find('fire').click()
    await asyncio.sleep(0.1)
    caplog.records.clear()
    assert len(seen) == 1, f'ui.on_exception saw {len(seen)}, expected 1'

Description

Filed by Claude Code on @evnchn's behalf.

TL;DR:

Awaiting a @ui.refreshable with an async def body raises into the caller's context, so ui.on_exception sees it. Calling .refresh() on the same function with the same body does not.

This is the async counterpart of #6234. That issue's MRE uses a synchronous body, and PR #6258 (which fixes it) only catches synchronous raises out of RefreshableTarget.run — so this case is untouched by that fix and needs a separate decision about which layer it belongs in.

As with #6234, the exception still reaches app.on_exception and the log — only the client-scoped handler is skipped.

Mechanism

For an async def body, RefreshableTarget.run does not raise — it returns an awaitable (nicegui/functions/refreshable.py:42-43 on 3.15.0):

python
        if helpers.should_await(result):
            return cast(_T, helpers.await_with_context(result, self.container))

so _execute_refresh collects it into awaitables and hands it to background_tasks.create_or_defer(asyncio.gather(*awaitables), ...). The body's exception therefore surfaces much later, inside that gather.

By then await_with_context (nicegui/helpers/functions.py:57-60) has already unwound its with:

python
async def await_with_context(awaitable, context):
    """Await an awaitable within a context manager."""
    with context:
        return await awaitable

The exception propagates out of the with, so the slot stack is empty again when background_tasks._handle_exceptions calls app.handle_exception, and the client-scoped guard (nicegui/app/app.py:178) is skipped:

python
if context.slot_stack and context.client is not None:
    context.client.handle_exception(exception)

Same end state as #6234 — a background task with no client context — but reached one layer further down, which is why a fix at the _execute_refresh try/except cannot see it.

Actual output (nicegui 3.15.0 from PyPI, clean venv)
$ pytest mre.py -q
FAILED mre.py::test_async_via_refresh - AssertionError: ui.on_exception saw 0...
1 failed, 1 passed in 0.58s

The traceback that reaches the log confirms the path:

Traceback (most recent call last):
  File ".../nicegui/background_tasks.py", line 152, in _handle_exceptions
    task.result()
  File ".../nicegui/background_tasks.py", line 131, in wrapper
    return await awaitable
  File ".../nicegui/helpers/functions.py", line 60, in await_with_context
    return await awaitable
  File ".../mre.py", line 52, in part
    raise RuntimeError('boom')
RuntimeError: boom

Also run against the head of PR #6258 (843849b0): same result, 1 failed, 1 passed — that PR does not reach this case.

NiceGUI Version

3.15.0

Python Version

3.14.2

Browser

Other

Operating System

macOS

Additional Context

Browser-independent — the repro uses nicegui.testing.User, so no browser is involved.

Split out from #6234 the same way #6234 was split from #6233, so the three can be worked independently. If you would rather track this inside #6234 as a second case, that works too — say the word and I will close this.