From ff49cff7707ee0d5e9e15a492da77e54a02b6e59 Mon Sep 17 00:00:00 2001 From: Ian Murray Date: Tue, 16 Jun 2026 15:08:06 +0000 Subject: [PATCH 1/2] Fix UnboundLocalError in async background callbacks In the async_run() path of the Celery and Diskcache background callback managers, user_callback_output was referenced after the try/except block without being initialised. When an async callback raised PreventUpdate or another exception, control skipped the assignment and the subsequent asyncio.iscoroutine(user_callback_output) check raised UnboundLocalError, masking the original error. Initialise user_callback_output to None before the try block and only evaluate the coroutine check when no error occurred. Add unit tests that exercise the generated job functions directly for the success, exception and PreventUpdate paths. --- .../managers/celery_manager.py | 7 +- .../managers/diskcache_manager.py | 5 +- .../test_async_background_callback_job_fn.py | 126 ++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_async_background_callback_job_fn.py diff --git a/dash/background_callback/managers/celery_manager.py b/dash/background_callback/managers/celery_manager.py index d68c65168d..3b416d488b 100644 --- a/dash/background_callback/managers/celery_manager.py +++ b/dash/background_callback/managers/celery_manager.py @@ -209,6 +209,7 @@ async def async_run(): c.updated_props = ProxySetProps(_set_props) context_value.set(c) errored = False + user_callback_output = None # to help type checking try: if isinstance(user_callback_args, dict): user_callback_output = await fn( @@ -243,10 +244,10 @@ async def async_run(): ), ) - if asyncio.iscoroutine(user_callback_output): - user_callback_output = await user_callback_output - if not errored: + if asyncio.iscoroutine(user_callback_output): + user_callback_output = await user_callback_output + cache.set( result_key, json.dumps(user_callback_output, cls=PlotlyJSONEncoder) ) diff --git a/dash/background_callback/managers/diskcache_manager.py b/dash/background_callback/managers/diskcache_manager.py index db7cd112bc..dbd5ac1662 100644 --- a/dash/background_callback/managers/diskcache_manager.py +++ b/dash/background_callback/managers/diskcache_manager.py @@ -263,6 +263,7 @@ async def async_run(): c.updated_props = ProxySetProps(_set_props) context_value.set(c) errored = False + user_callback_output = None # to help type checking try: if isinstance(user_callback_args, dict): user_callback_output = await fn( @@ -289,9 +290,9 @@ async def async_run(): }, ) - if asyncio.iscoroutine(user_callback_output): - user_callback_output = await user_callback_output if not errored: + if asyncio.iscoroutine(user_callback_output): + user_callback_output = await user_callback_output try: cache.set(result_key, user_callback_output) except Exception as err: # pylint: disable=broad-except diff --git a/tests/unit/test_async_background_callback_job_fn.py b/tests/unit/test_async_background_callback_job_fn.py new file mode 100644 index 0000000000..de93c9c5ed --- /dev/null +++ b/tests/unit/test_async_background_callback_job_fn.py @@ -0,0 +1,126 @@ +"""Unit tests for the async background callback job functions. + +These tests reproduce the ``UnboundLocalError`` that occurred when an async +background callback raised ``PreventUpdate`` or another exception before +``user_callback_output`` was assigned. They exercise the generated job +function directly, so they require neither a browser nor a running Celery or +Diskcache backend. +""" +import json + +import pytest + +from dash.exceptions import PreventUpdate +from dash.background_callback.managers.celery_manager import ( + _make_job_fn as make_celery_job_fn, +) +from dash.background_callback.managers.diskcache_manager import ( + _make_job_fn as make_diskcache_job_fn, +) + + +class FakeCache: + """Minimal in-memory stand-in for a Diskcache/Celery result backend.""" + + def __init__(self): + self.store = {} + + def set(self, key, value): + self.store[key] = value + + def get(self, key, default=None): + return self.store.get(key, default) + + +class FakeCeleryApp: + """Minimal Celery application exposing a backend and a task decorator.""" + + def __init__(self): + self.backend = FakeCache() + + def task(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + +def _run_diskcache_job(fn): + cache = FakeCache() + job_fn = make_diskcache_job_fn(fn, cache, progress=False) + job_fn("result-key", "progress-key", ["input"], {}) + return cache.store.get("result-key") + + +def _run_celery_job(fn): + celery_app = FakeCeleryApp() + job_fn = make_celery_job_fn(fn, celery_app, progress=False, key="test") + job_fn("result-key", "progress-key", ["input"], {}) + stored = celery_app.backend.store.get("result-key") + return json.loads(stored) if stored is not None else None + + +def test_diskcache_async_job_fn_exception_is_reported(): + """A raised exception is cached as a background_callback_error, not masked.""" + + async def fn(_value): + raise ValueError("boom") + + result = _run_diskcache_job(fn) + assert "background_callback_error" in result + assert result["background_callback_error"]["msg"] == "boom" + + +def test_diskcache_async_job_fn_prevent_update_is_reported(): + """Raising PreventUpdate caches a no-update result without raising.""" + + async def fn(_value): + raise PreventUpdate + + result = _run_diskcache_job(fn) + assert result == {"_dash_no_update": "_dash_no_update"} + + +def test_diskcache_async_job_fn_success_is_reported(): + """A successful async callback caches its return value.""" + + async def fn(value): + return f"processed {value}" + + result = _run_diskcache_job(fn) + assert result == "processed input" + + +def test_celery_async_job_fn_exception_is_reported(): + """A raised exception is cached as a background_callback_error, not masked.""" + + async def fn(_value): + raise ValueError("boom") + + result = _run_celery_job(fn) + assert "background_callback_error" in result + assert result["background_callback_error"]["msg"] == "boom" + + +def test_celery_async_job_fn_prevent_update_is_reported(): + """Raising PreventUpdate caches a no-update result without raising.""" + + async def fn(_value): + raise PreventUpdate + + result = _run_celery_job(fn) + assert result == {"_dash_no_update": "_dash_no_update"} + + +def test_celery_async_job_fn_success_is_reported(): + """A successful async callback caches its return value.""" + + async def fn(value): + return f"processed {value}" + + result = _run_celery_job(fn) + assert result == "processed input" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From dbe2b3526cf047aea086ee0888a78e61670bc7fc Mon Sep 17 00:00:00 2001 From: Ian Murray Date: Tue, 16 Jun 2026 15:18:17 +0000 Subject: [PATCH 2/2] Add CHANGELOG entry for async background callback fix (#3822) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e0404e123..d341397cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Fixed - [#3805](https://github.com/plotly/dash/pull/3805) Fix FastAPI POST routes deadlock caused by middleware consuming request body. Fixes [#3801](https://github.com/plotly/dash/issues/3801). +- [#3822](https://github.com/plotly/dash/pull/3822) Fix `UnboundLocalError` for `user_callback_output` in async background callbacks (Celery and Diskcache managers) when the callback raises `PreventUpdate` or another exception before the variable is assigned. ## [4.2.0] - 2026-06-01 - *The Freedom Update*