`ui.on_exception` never sees exceptions raised while a `@ui.refreshable` rebuilds
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
#
# The two tests 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_direct_call(user: User, caplog: pytest.LogCaptureFixture):
seen: list[Exception] = []
@ui.page('/')
def page():
ui.on_exception(seen.append)
@ui.refreshable
def part(explode: bool = False):
if explode:
raise RuntimeError('boom')
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_via_refresh(user: User, caplog: pytest.LogCaptureFixture):
seen: list[Exception] = []
@ui.page('/')
def page():
ui.on_exception(seen.append)
@ui.refreshable
def part(explode: bool = False):
if explode:
raise RuntimeError('boom')
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:
Calling a @ui.refreshable function directly raises into the caller's context, so ui.on_exception sees it. Calling .refresh() on the same function with the same body does not.
Unlike the async-handler cases, this affects synchronous refreshable bodies too, because the deferral happens one level higher: refresh() returns an AwaitableResponse whose __init__ schedules the entire rebuild as a background task.
The exception still reaches app.on_exception and the log — only the client-scoped handler is skipped.
app.handle_exception only forwards to the client-scoped handlers when a slot stack is present — nicegui/app/app.py:178 on 3.15.0:
if context.slot_stack and context.client is not None:
context.client.handle_exception(exception)refreshable.refresh() returns an AwaitableResponse, and nicegui/awaitable_response.py:21 schedules the work immediately:
background_tasks.create(self._fire(), name='fire')_fire() calls fire_and_forget() → _execute_refresh() → target.run(self.func). All of that therefore runs inside a background task, outside the client context, so when the refreshable body raises, the exception surfaces in background_tasks._handle_exceptions with an empty slot stack.
That is why the sync/async distinction does not apply here: the rebuild is deferred regardless of whether the function is a coroutine.
Actual output (nicegui 3.15.0 from PyPI, clean venv)test_direct_call PASSED [ 50%]
test_via_refresh FAILED [100%]
========================= 1 failed, 1 passed in 0.53s ==========================The failure is AssertionError: ui.on_exception saw 0, expected 1. Registering both handler kinds on the same page shows where it does land:
app.on_exception saw : ['refreshable rebuild']
ui.on_exception saw : []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/awaitable_response.py", line 27, in _fire
self.fire_and_forget()
File ".../nicegui/functions/refreshable.py", line 100, in fire_and_forget
if awaitables := self._execute_refresh(args, kwargs, instance=instance):
File ".../nicegui/functions/refreshable.py", line 119, in _execute_refresh
result = target.run(self.func)
File ".../nicegui/functions/refreshable.py", line 38, in run
result = func(*self.args, **self.kwargs)NiceGUI Version
3.15.0
Python Version
3.12.11
Browser
Other
Operating System
macOS
Additional Context
Browser-independent — the repro uses nicegui.testing.User, so no browser is involved.
Split out from #6233 (async Event subscribers and async validation functions): those two share one mechanism and this one has another, so they can be worked in parallel.
Source: zauberzeug/nicegui