Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*

Expand Down
7 changes: 4 additions & 3 deletions dash/background_callback/managers/celery_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
)
Expand Down
5 changes: 3 additions & 2 deletions dash/background_callback/managers/diskcache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions tests/unit/test_async_background_callback_job_fn.py
Original file line number Diff line number Diff line change
@@ -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):

Check warning on line 87 in tests/unit/test_async_background_callback_job_fn.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use asynchronous features in this function or remove the `async` keyword.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ7RAkUYQiWuvnGqMKGK&open=AZ7RAkUYQiWuvnGqMKGK&pullRequest=3822
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):

Check warning on line 118 in tests/unit/test_async_background_callback_job_fn.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use asynchronous features in this function or remove the `async` keyword.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ7RAkUYQiWuvnGqMKGL&open=AZ7RAkUYQiWuvnGqMKGL&pullRequest=3822
return f"processed {value}"

result = _run_celery_job(fn)
assert result == "processed input"


if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading