#6233·nicegui

`ui.on_exception` still misses exceptions from async `Event` subscribers and async `validation` functions (follow-up to #5945)

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
#
# Each pair below differs by ONE line: the first passes, the second fails.
import asyncio

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

pytest_plugins = ['nicegui.testing.user_plugin']


# ---- Case A: a nicegui.Event subscriber ----

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

    @ui.page('/')
    def page():
        event: Event[[]] = Event()

        def subscriber():                       # sync
            raise RuntimeError('boom')

        event.subscribe(subscriber)
        ui.on_exception(seen.append)
        ui.button('fire', on_click=lambda: event.emit())

    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_A2_async_subscriber(user: User, caplog: pytest.LogCaptureFixture):
    seen: list[Exception] = []

    @ui.page('/')
    def page():
        event: Event[[]] = Event()

        async def subscriber():                 # async -- the only difference
            raise RuntimeError('boom')

        event.subscribe(subscriber)
        ui.on_exception(seen.append)
        ui.button('fire', on_click=lambda: event.emit())

    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'


# ---- Case B: a validation function ----

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

    def check(value):                           # sync
        raise RuntimeError('boom')

    @ui.page('/')
    def page():
        ui.on_exception(seen.append)
        field = ui.input('field', validation=check)
        ui.button('validate', on_click=lambda: field.validate(return_result=False))

    await user.open('/')
    user.find('validate').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_B2_async_validation(user: User, caplog: pytest.LogCaptureFixture):
    seen: list[Exception] = []

    async def check(value):                     # async -- the only difference
        raise RuntimeError('boom')

    @ui.page('/')
    def page():
        ui.on_exception(seen.append)
        field = ui.input('field', validation=check)
        ui.button('validate', on_click=lambda: field.validate(return_result=False))

    await user.open('/')
    user.find('validate').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:

PR #5946 fixed ui.on_exception for async event handlers (reported in #5945) by catching the exception inside the slot context. The same failure mode still appears on two more deferred paths, so a handler registered with ui.on_exception silently never fires:

  1. an async subscriber to a nicegui.Event — the sync subscriber right beside it is caught
  2. an async validation= function — again, the sync one is caught

In every case the exception still reaches app.on_exception and the log, so nothing is lost silently at the app level — but the client-scoped handler that the user registered on that page does not run.

The ask: confirm these should be caught, and whether you'd prefer one shared helper (promoting events._await_and_handle_in_context to helpers) over two local fixes.

A third instance with a different mechanism — @ui.refreshable rebuilds — is filed separately as #6234 so the two can be worked in parallel.

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)

An exception that escapes a coroutine scheduled with background_tasks.create* surfaces in the task's done-callback background_tasks._handle_exceptions, which runs with an empty slot stack. So it reaches app.on_exception but never ui.on_exception.

That is exactly what #5946 addressed, by awaiting inside a helper that catches while still in the context (nicegui/events.py:481 and :486).

Case A — nicegui/event.py:137 still has the pre-#5946 shape:

python
def _invoke_and_forget(callback: Callback[P], *args: P.args, **kwargs: P.kwargs) -> Any:
    try:
        result = callback.run(*args, **kwargs)
        if helpers.should_await(result):
            background_tasks.create_or_defer(callback.await_result(result), name=...)
    except Exception as e:
        core.app.handle_exception(e)     # only reachable from the sync branch

Callback.await_result (event.py:35) re-enters the slot context for the await but has no try/except, so the exception leaves the context before anything handles it.

Case B — nicegui/elements/mixins/validation_element.py:70-74: no try/except and no slot context at all.

python
result = self._validation(self.value)
if helpers.should_await(result):
    async def await_error():
        self.error = await result
    background_tasks.create(await_error(), name=f'validate {self.id}')
Actual output (nicegui 3.15.0 from PyPI, clean venv)
test_A1_sync_subscriber PASSED               [ 25%]
test_A2_async_subscriber FAILED              [ 50%]
test_B1_sync_validation PASSED               [ 75%]
test_B2_async_validation FAILED              [100%]
========================= 2 failed, 2 passed in 0.86s ==========================

Each failure is AssertionError: ui.on_exception saw 0, expected 1. Case A's captured traceback ends in the done-callback, which is the mechanism above:

ERROR    nicegui:app.py:181 boom
Traceback (most recent call last):
  File ".../nicegui/background_tasks.py", line 152, in _handle_exceptions
    task.result()
  File ".../nicegui/event.py", line 38, in await_result
    return await result
  File ".../mre.py", line 46, in subscriber
    raise RuntimeError('boom')
RuntimeError: boom
Both cases side by side: app-level fires, client-level does not

Registering both an app.on_exception and a ui.on_exception handler on the same page and firing both cases in one run (nicegui 3.15.0):

app.on_exception saw : ['async Event subscriber', 'async validation']
ui.on_exception saw  : []

So nothing is being swallowed at the app level — the defect is precisely that the client-scoped handler is skipped. (This is also why the log still shows the traceback, which may be why it has gone unnoticed.)

Candidate fix for Case A, and what it was checked against

Applying the #5946 shape to Callback.await_result makes test_A2 pass, and the repository's existing tests/test_event.py still passes 11/11:

python
async def await_result_and_handle_in_context(self, result: Awaitable) -> Any:
    """Await the result within the callback's slot context, handling exceptions in-context."""
    with (self.slot and self.slot()) or nullcontext():
        try:
            return await result
        except Exception as e:
            core.app.handle_exception(e)

This is offered as evidence that the diagnosis is right, not as a proposed patch — Case B was not fixed, and you may prefer one shared helper. helpers.await_with_context has the same "re-enter the context but don't catch in it" behaviour and is called at four sites on 3.15.0 (client.py:426, client.py:453, functions/navigate.py:75, functions/refreshable.py:43), so a handle_exceptions=True variant of it may be the smaller total change.

Swept and NOT affected

Checked with the same harness on 3.15.0, no defect found:

site result
events.handle_event (the #5946 fix site) correct — the control case in the MRE passes
ui.sub_pages async page builder (sub_pages.py, functions/navigate.py) correct — both sync and async builders reach ui.on_exception
app.on_connect / Client.safe_invoke (client.py:419) neither sync nor async connect-handler exceptions reach ui.on_exception. Reported here only for completeness — this looks like intended app-scoped-lifecycle vs in-page semantics rather than a bug, so it is deliberately not part of the report.

One claim was withdrawn during preparation: an apparent "sync validation is not caught either" result turned out to be an artifact of the test harness — user.find(...).type(...) assigns element.value directly, bypassing handle_event, which a real browser would not do. Case B above therefore drives validation through field.validate() from a real click handler instead.

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. The MRE deliberately mirrors the regression test added in #5946 (tests/test_event.py::test_ui_on_exception) so the comparison is apples to apples.

Related but distinct: #6230 also concerns app.handle_exception, but is about the guard itself raising, not about the client-scoped handlers being skipped.