`ui.on_exception` never sees exceptions from an async `@ui.refreshable` body
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
# 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.
For an async def body, RefreshableTarget.run does not raise — it returns an awaitable (nicegui/functions/refreshable.py:42-43 on 3.15.0):
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:
async def await_with_context(awaitable, context):
"""Await an awaitable within a context manager."""
with context:
return await awaitableThe 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:
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.
$ pytest mre.py -q
FAILED mre.py::test_async_via_refresh - AssertionError: ui.on_exception saw 0...
1 failed, 1 passed in 0.58sThe 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: boomAlso 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.
Source: zauberzeug/nicegui