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
34 changes: 34 additions & 0 deletions dash/_compression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
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 zlib
from typing import Any


def decompress_payload(data: bytes) -> 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,
as produced by fflate's ``gzipSync`` on the client side.

Args:
data: The raw compressed request body bytes.

Returns:
The decompressed and parsed callback request dictionary.

Raises:
ValueError: If the data cannot be decompressed or parsed.
"""
try:
# fflate's gzipSync produces gzip format; use 16 + MAX_WBITS to signal gzip
decompressed = zlib.decompress(data, zlib.MAX_WBITS | 16)
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
12 changes: 12 additions & 0 deletions dash/dash-renderer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dash/dash-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"cytoscape-fcose": "^2.2.0",
"dependency-graph": "^0.11.0",
"fast-isnumeric": "^1.1.4",
"fflate": "^0.8.0",
"node-polyfill-webpack-plugin": "^2.0.1",
"prop-types": "15.8.1",
"ramda": "^0.30.1",
Expand Down
33 changes: 30 additions & 3 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
assocPath
} from 'ramda';

import {gzipSync} from 'fflate';

import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants';
import {MAX_AUTH_RETRIES} from './constants';
import {
Expand Down Expand Up @@ -468,7 +470,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 Down Expand Up @@ -524,12 +528,33 @@ 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 {
fetchBody = gzipSync(new TextEncoder().encode(newBody));
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 +1094,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
Loading