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
21 changes: 21 additions & 0 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def callback(
persistent: Optional[bool] = False,
mcp_enabled: Optional[bool] = None,
mcp_expose_docstring: Optional[bool] = None,
compress_payload: bool = False,
compress_threshold: int = 5_000,
**_kwargs,
) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]:
"""
Expand Down Expand Up @@ -188,6 +190,15 @@ def callback(
If True, this callback will not show the "Updating..." title while
running. Useful for persistent WebSocket callbacks that stay active
for long periods without requiring a loading indicator.
:param compress_payload:
If True, the callback request payload will be compressed using gzip
compression before being sent to the server. This can significantly
reduce network transmission size for large payloads.
Defaults to False.
:param compress_threshold:
The size threshold in bytes above which the payload will be compressed
when `compress_payload` is True. Set to 0 to always compress regardless
of size. Defaults to 5,000 bytes (5 kB).
"""

background_spec: Any = None
Expand Down Expand Up @@ -249,6 +260,8 @@ def callback(
persistent=persistent,
mcp_enabled=mcp_enabled,
mcp_expose_docstring=mcp_expose_docstring,
compress_payload=compress_payload,
compress_threshold=compress_threshold,
)

return cast(
Expand Down Expand Up @@ -304,6 +317,8 @@ def insert_callback(
persistent=False,
mcp_enabled=None,
mcp_expose_docstring=None,
compress_payload: bool = False,
compress_threshold: int = 5_000,
) -> str:
if prevent_initial_call is None:
prevent_initial_call = config_prevent_initial_callbacks
Expand Down Expand Up @@ -331,6 +346,8 @@ def insert_callback(
"hidden": hidden,
"websocket": websocket,
"persistent": persistent,
"compress_payload": compress_payload,
"compress_threshold": compress_threshold,
}
if running:
callback_spec["running"] = running
Expand All @@ -349,6 +366,8 @@ def insert_callback(
"websocket": websocket,
"mcp_enabled": mcp_enabled,
"mcp_expose_docstring": mcp_expose_docstring,
"compress_payload": compress_payload,
"compress_threshold": compress_threshold,
}
callback_list.append(callback_spec)

Expand Down Expand Up @@ -773,6 +792,8 @@ def register_callback(
persistent=_kwargs.get("persistent", False),
mcp_enabled=_kwargs.get("mcp_enabled", None),
mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"),
compress_payload=_kwargs.get("compress_payload", False),
compress_threshold=_kwargs.get("compress_threshold", 5_000),
)

# pylint: disable=too-many-locals
Expand Down
48 changes: 48 additions & 0 deletions dash/_compression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""
Utilities for decompressing callback payloads.

Payload compression reduces network traffic for large callback requests,
particularly useful for callbacks with large data payloads.
"""

import json
import os
import zlib
from typing import Any

# Maximum decompressed payload size is 64 MB by default
MAX_PAYLOAD_SIZE = int(os.getenv("DASH_MAX_PAYLOAD_SIZE_MB", "64")) * 1024 * 1024


def decompress_payload(data: bytes, max_size: int = MAX_PAYLOAD_SIZE) -> Any:
"""
Decompress a gzip-compressed callback request body.

The data is expected to be the raw bytes of a gzip-compressed UTF-8 JSON payload.

Args:
data: The raw compressed request body bytes.
max_size: The maximum allowed size of the decompressed payload in bytes.

Returns:
The decompressed and parsed callback request dictionary.

Raises:
ValueError: If the data cannot be decompressed or parsed.
"""
try:
decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
decompressed = decompressor.decompress(data, max_size + 1)

if len(decompressed) > max_size:
raise ValueError("Decompressed callback payload is too large.")

if not decompressor.eof:
raise ValueError("Incomplete gzip callback payload.")

if decompressor.unused_data:
raise ValueError("Unexpected data after gzip callback payload.")

return json.loads(decompressed.decode("utf-8"))
except Exception as e:
raise ValueError("Failed to decompress callback payload.") from e
15 changes: 10 additions & 5 deletions dash/backends/_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from dash.fingerprint import check_fingerprint
from dash import _validate, get_app
from dash.exceptions import PreventUpdate
from dash._compression import decompress_payload
from .base_server import (
BaseDashServer,
RequestAdapter,
Expand Down Expand Up @@ -328,7 +329,8 @@ def setup_catchall(self, dash_app: Dash):
and passed through the middleware, which is necessary for features like authentication
and timing to work correctly on all routes. FastAPI will match this catch-all route
for any path that isn't matched by a more specific route, allowing the middleware to
process the request and then return the appropriate response (e.g., 404 if no Dash route matches)."""
process the request and then return the appropriate response (e.g., 404 if no Dash route matches).
"""

def _setup_catchall(self):
try:
Expand Down Expand Up @@ -549,7 +551,10 @@ def add_redirect_rule(self, app, fullname, path):
def serve_callback(self, dash_app: Dash):
async def _dispatch(request: Request): # pylint: disable=unused-argument
# pylint: disable=protected-access
body = self.request_adapter().get_json()
if "gzip" in request.headers.get("content-encoding", ""):
body = decompress_payload(await self.request_adapter()._request.body())
else:
body = self.request_adapter().get_json()
cb_ctx = dash_app._initialize_context(
body
) # pylint: disable=protected-access
Expand Down Expand Up @@ -748,9 +753,9 @@ async def websocket_handler(websocket: WebSocket):
)
# Track pending callbacks: concurrent.futures.Future (sync/threadpool)
# or asyncio.Task (async/event-loop).
pending_callbacks: Dict[
str, concurrent.futures.Future | asyncio.Future
] = {}
pending_callbacks: Dict[str, concurrent.futures.Future | asyncio.Future] = (
{}
)

# Start sender task to drain outbound queue (sends pre-serialized text)
# pylint: disable=protected-access
Expand Down
12 changes: 9 additions & 3 deletions dash/backends/_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
from dash import _validate
from dash.exceptions import PreventUpdate, InvalidResourceError
from dash._callback import _invoke_callback, _async_invoke_callback
from dash._compression import decompress_payload
from dash._utils import parse_version
from .base_server import BaseDashServer, RequestAdapter, ResponseAdapter


if TYPE_CHECKING: # pragma: no cover - typing only
from dash import Dash

Expand Down Expand Up @@ -252,7 +252,10 @@ def add_redirect_rule(self, app, fullname, path):
# pylint: disable=unused-argument
def serve_callback(self, dash_app: Dash):
def _dispatch():
body = request.get_json()
if "gzip" in request.headers.get("Content-Encoding", ""):
body = decompress_payload(request.data)
else:
body = request.get_json()
# pylint: disable=protected-access
cb_ctx = dash_app._initialize_context(body)
func = dash_app._prepare_callback(cb_ctx, body)
Expand All @@ -271,7 +274,10 @@ def _dispatch():
return cb_ctx.dash_response.set_response(data=response_data)

async def _dispatch_async():
body = request.get_json()
if "gzip" in request.headers.get("Content-Encoding", ""):
body = decompress_payload(request.data)
else:
body = request.get_json()
# pylint: disable=protected-access
cb_ctx = dash_app._initialize_context(body)
func = dash_app._prepare_callback(cb_ctx, body)
Expand Down
12 changes: 8 additions & 4 deletions dash/backends/_quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from dash.fingerprint import check_fingerprint
from dash._utils import parse_version
from dash import _validate
from dash._compression import decompress_payload
from .base_server import (
BaseDashServer,
RequestAdapter,
Expand Down Expand Up @@ -385,7 +386,10 @@ def add_redirect_rule(self, app, fullname, path):
def serve_callback(self, dash_app: Dash): # type: ignore[override] # Quart always async
async def _dispatch():
adapter = QuartRequestAdapter()
body = await adapter.get_json()
if "gzip" in adapter.request.headers.get("Content-Encoding", ""):
body = decompress_payload(await adapter.request.get_data())
else:
body = await adapter.get_json()
# pylint: disable=protected-access
cb_ctx = dash_app._initialize_context(body)
# pylint: disable=protected-access
Expand Down Expand Up @@ -581,9 +585,9 @@ async def websocket_handler(): # pylint: disable=too-many-branches
)
# Track pending callbacks: concurrent.futures.Future (sync/threadpool)
# or asyncio.Task (async/event-loop).
pending_callbacks: Dict[
str, concurrent.futures.Future | asyncio.Future
] = {}
pending_callbacks: Dict[str, concurrent.futures.Future | asyncio.Future] = (
{}
)

# Start sender task to drain outbound queue (sends pre-serialized text)
# pylint: disable=protected-access
Expand Down
36 changes: 32 additions & 4 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,9 @@ function handleServerside(
background: BackgroundCallbackInfo | undefined,
additionalArgs: [string, string, boolean?][] | undefined,
getState: any,
running: any
running: any,
compressPayload?: boolean,
compressThreshold?: number
): Promise<CallbackResponse> {
if (hooks.request_pre) {
hooks.request_pre(payload);
Expand All @@ -487,7 +489,7 @@ function handleServerside(
runningOff = running.runningOff;
}

const fetchCallback = () => {
const fetchCallback = async () => {
const headers = getCSRFHeader(config) as any;
let url = `${urlBase(config)}_dash-update-component`;
let newBody = body;
Expand Down Expand Up @@ -524,12 +526,36 @@ function handleServerside(
moreArgs = moreArgs.filter(([_, __, single]) => !single);
}

let fetchBody: BodyInit = newBody;

// Compress payload if enabled and size threshold is met
if (
compressPayload &&
compressThreshold !== undefined &&
newBody.length > compressThreshold
) {
try {
const stream = new Blob([newBody])
.stream()
.pipeThrough(new CompressionStream('gzip'));
fetchBody = await new Response(stream).blob();
headers['Content-Encoding'] = 'gzip';
} catch (error) {
// Fall through to send uncompressed
// eslint-disable-next-line no-console
console.warn(
'Sending uncompressed payload, because compressing failed:',
error
);
}
}

return fetch(
url,
mergeDeepRight(config.fetch, {
method: 'POST',
headers,
body: newBody
body: fetchBody
})
);
};
Expand Down Expand Up @@ -1069,7 +1095,9 @@ export function executeCallback(
? additionalArgs
: undefined,
getState,
cb.callback.running
cb.callback.running,
cb.callback.compress_payload,
cb.callback.compress_threshold
);
}

Expand Down
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/types/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ICallbackDefinition {
no_output?: boolean;
websocket?: boolean;
persistent?: boolean;
compress_payload?: boolean;
compress_threshold?: number;
}

export interface ICallbackProperty {
Expand Down
82 changes: 82 additions & 0 deletions tests/integration/callbacks/test_compressed_callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
Integration tests for callback payload compression.
"""

import pytest
from dash import Dash, html, dcc, Input, Output, State


@pytest.mark.parametrize(
"backend,dash_duo_fixture",
[("flask", "dash_duo"), ("quart", "dash_duo_mp"), ("fastapi", "dash_duo")],
)
@pytest.mark.parametrize("payload_size", [1, 500_000])
@pytest.mark.parametrize("compress_threshold", [0, 500_000])
def test_cbcomp01_compress_request_payload(
request, dash_duo_fixture, backend, payload_size, compress_threshold
):
"""Test that the client sends a compressed body when appropriate."""
if backend == "quart":
pytest.importorskip(
"quart", reason="Quart extra dependencies are not installed"
)
pytest.importorskip("hypercorn", reason="hypercorn is not installed")
elif backend == "fastapi":
pytest.importorskip(
"fastapi", reason="fastapi extra dependencies are not installed"
)

app = Dash(__name__, backend=backend)

@app.backend.before_request
def capture_compression():
# intercept the request to /_dash-update-component and record whether the payload was compressed
req = app.backend.request_adapter()
if req.path == "/_dash-update-component":
if "gzip" in req.headers.get("Content-Encoding", ""):
req.context.compressed_payload_size = int(
req.headers.get("content-length", 0)
)
else:
req.context.compressed_payload_size = None

@app.callback(
Output("data_size", "children"),
Output("data_compressed", "children"),
Output("data_compressed_size", "children"),
Input("btn", "n_clicks"),
State("store", "data"),
compress_payload=True,
compress_threshold=compress_threshold,
prevent_initial_call=True,
)
def on_click(n, data):
# log the size of the data and whether it was compressed
compressed_payload_size = (
app.backend.request_adapter().context.compressed_payload_size
)
return (
len(data),
repr(compressed_payload_size is not None),
compressed_payload_size,
)

app.layout = html.Div(
[
html.Button("Click", id="btn"),
html.Div(id="data_size"),
html.Div(id="data_compressed"),
html.Div(id="data_compressed_size"),
dcc.Store(id="store", data="x" * payload_size),
]
)

dash_duo = request.getfixturevalue(dash_duo_fixture)
dash_duo.start_server(app)
dash_duo.find_element("#btn").click()
# assert that the data size matches the expected payload size
dash_duo.wait_for_text_to_equal("#data_size", f"{payload_size}")
# assert that the data was compressed if the payload size is greater than or equal to the compression threshold
dash_duo.wait_for_text_to_equal(
"#data_compressed", repr(payload_size >= compress_threshold)
)
Loading
Loading