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* 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"])