#6234·nicegui

`ui.on_exception` never sees exceptions raised while a `@ui.refreshable` rebuilds

Author: evnchnCreated Aug 3, 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
#
# 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.

Mechanism

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:

python
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:

python
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.