Skip to content

Commit 02de64d

Browse files
committed
Add callback on_error handler
1 parent bbd013c commit 02de64d

File tree

10 files changed

+188
-37
lines changed

10 files changed

+188
-37
lines changed

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ max-bool-expr=5
414414
max-branches=15
415415

416416
# Maximum number of locals for function / method body
417-
max-locals=20
417+
max-locals=25
418418

419419
# Maximum number of parents for a class (see R0901).
420420
max-parents=7

dash/_callback.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections
22
import hashlib
33
from functools import wraps
4+
from typing import Callable, Optional, Any
45

56
import flask
67

@@ -67,6 +68,7 @@ def callback(
6768
cancel=None,
6869
manager=None,
6970
cache_args_to_ignore=None,
71+
on_error: Optional[Callable[[Exception], Any]] = None,
7072
**_kwargs,
7173
):
7274
"""
@@ -137,6 +139,10 @@ def callback(
137139
this should be a list of argument indices as integers.
138140
:param interval:
139141
Time to wait between the long callback update requests.
142+
:param on_error:
143+
Function to call when the callback raises an exception. Receives the
144+
exception object as first argument. The callback_context can be used
145+
to access the original callback inputs, states and output.
140146
"""
141147

142148
long_spec = None
@@ -186,6 +192,7 @@ def callback(
186192
long=long_spec,
187193
manager=manager,
188194
running=running,
195+
on_error=on_error,
189196
)
190197

191198

@@ -272,8 +279,8 @@ def insert_callback(
272279
return callback_id
273280

274281

275-
# pylint: disable=R0912, R0915
276-
def register_callback( # pylint: disable=R0914
282+
# pylint: disable=too-many-branches,too-many-statements
283+
def register_callback(
277284
callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs
278285
):
279286
(
@@ -297,6 +304,7 @@ def register_callback( # pylint: disable=R0914
297304
long = _kwargs.get("long")
298305
manager = _kwargs.get("manager")
299306
running = _kwargs.get("running")
307+
on_error = _kwargs.get("on_error")
300308
if running is not None:
301309
if not isinstance(running[0], (list, tuple)):
302310
running = [running]
@@ -342,6 +350,8 @@ def add_context(*args, **kwargs):
342350
"callback_context", AttributeDict({"updated_props": {}})
343351
)
344352
callback_manager = long and long.get("manager", app_callback_manager)
353+
app_on_error = kwargs.pop("app_on_error", None)
354+
345355
if has_output:
346356
_validate.validate_output_spec(insert_output, output_spec, Output)
347357

@@ -403,6 +413,7 @@ def add_context(*args, **kwargs):
403413
triggered_inputs=callback_ctx.triggered_inputs,
404414
ignore_register_page=True,
405415
),
416+
on_error=on_error or app_on_error,
406417
)
407418

408419
data = {
@@ -462,7 +473,15 @@ def add_context(*args, **kwargs):
462473
if output_value is callback_manager.UNDEFINED:
463474
return to_json(response)
464475
else:
465-
output_value = _invoke_callback(func, *func_args, **func_kwargs)
476+
try:
477+
output_value = _invoke_callback(func, *func_args, **func_kwargs)
478+
except Exception as err: # pylint: disable=broad-exception-caught
479+
if on_error:
480+
output_value = on_error(err)
481+
elif app_on_error:
482+
output_value = app_on_error(err)
483+
else:
484+
raise err
466485

467486
if NoUpdate.is_no_update(output_value):
468487
raise PreventUpdate

dash/dash.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import base64
1717
import traceback
1818
from urllib.parse import urlparse
19-
from typing import Dict, Optional, Union
19+
from typing import Any, Callable, Dict, Optional, Union
2020

2121
import flask
2222

@@ -369,6 +369,10 @@ class Dash:
369369
370370
:param description: Sets a default description for meta tags on Dash pages (use_pages=True).
371371
372+
:param on_error: Global callback error handler to call when
373+
an exception is raised. Receives the exception object as first argument.
374+
The callback_context can be used to access the original callback inputs,
375+
states and output.
372376
"""
373377

374378
_plotlyjs_url: str
@@ -409,6 +413,7 @@ def __init__( # pylint: disable=too-many-statements
409413
hooks: Union[RendererHooks, None] = None,
410414
routing_callback_inputs: Optional[Dict[str, Union[Input, State]]] = None,
411415
description=None,
416+
on_error: Optional[Callable[[Exception], Any]] = None,
412417
**obsolete,
413418
):
414419
_validate.check_obsolete(obsolete)
@@ -520,6 +525,7 @@ def __init__( # pylint: disable=too-many-statements
520525
self._layout = None
521526
self._layout_is_function = False
522527
self.validation_layout = None
528+
self._on_error = on_error
523529
self._extra_components = []
524530

525531
self._setup_dev_tools()
@@ -1377,6 +1383,7 @@ def dispatch(self):
13771383
outputs_list=outputs_list,
13781384
long_callback_manager=self._background_manager,
13791385
callback_context=g,
1386+
app_on_error=self._on_error,
13801387
)
13811388
)
13821389
)

dash/long_callback/managers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def job_running(self, job):
3939
def make_job_fn(self, fn, progress, key=None):
4040
raise NotImplementedError
4141

42-
def call_job_fn(self, key, job_fn, args, context):
42+
def call_job_fn(self, key, job_fn, args, context, on_error=None):
4343
raise NotImplementedError
4444

4545
def get_progress(self, key):

dash/long_callback/managers/celery_manager.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def get_task(self, job):
8989
def clear_cache_entry(self, key):
9090
self.handle.backend.delete(key)
9191

92-
def call_job_fn(self, key, job_fn, args, context):
92+
def call_job_fn(self, key, job_fn, args, context, on_error=None):
9393
task = job_fn.delay(key, self._make_progress_key(key), args, context)
9494
return task.task_id
9595

@@ -139,7 +139,9 @@ def _make_job_fn(fn, celery_app, progress, key):
139139
cache = celery_app.backend
140140

141141
@celery_app.task(name=f"long_callback_{key}")
142-
def job_fn(result_key, progress_key, user_callback_args, context=None):
142+
def job_fn(
143+
result_key, progress_key, user_callback_args, context=None, on_error=None
144+
):
143145
def _set_progress(progress_value):
144146
if not isinstance(progress_value, (list, tuple)):
145147
progress_value = [progress_value]
@@ -161,6 +163,7 @@ def run():
161163
c.ignore_register_page = False
162164
c.updated_props = ProxySetProps(_set_props)
163165
context_value.set(c)
166+
errored = False
164167
try:
165168
if isinstance(user_callback_args, dict):
166169
user_callback_output = fn(*maybe_progress, **user_callback_args)
@@ -170,25 +173,31 @@ def run():
170173
user_callback_output = fn(*maybe_progress, user_callback_args)
171174
except PreventUpdate:
172175
# Put NoUpdate dict directly to avoid circular imports.
176+
errored = True
173177
cache.set(
174178
result_key,
175179
json.dumps(
176180
{"_dash_no_update": "_dash_no_update"}, cls=PlotlyJSONEncoder
177181
),
178182
)
179183
except Exception as err: # pylint: disable=broad-except
180-
cache.set(
181-
result_key,
182-
json.dumps(
183-
{
184-
"long_callback_error": {
185-
"msg": str(err),
186-
"tb": traceback.format_exc(),
187-
}
188-
},
189-
),
190-
)
191-
else:
184+
if on_error:
185+
user_callback_output = on_error(err)
186+
else:
187+
errored = True
188+
cache.set(
189+
result_key,
190+
json.dumps(
191+
{
192+
"long_callback_error": {
193+
"msg": str(err),
194+
"tb": traceback.format_exc(),
195+
}
196+
},
197+
),
198+
)
199+
200+
if not errored:
192201
cache.set(
193202
result_key, json.dumps(user_callback_output, cls=PlotlyJSONEncoder)
194203
)

dash/long_callback/managers/diskcache_manager.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,14 @@ def clear_cache_entry(self, key):
115115
self.handle.delete(key)
116116

117117
# noinspection PyUnresolvedReferences
118-
def call_job_fn(self, key, job_fn, args, context):
118+
def call_job_fn(self, key, job_fn, args, context, on_error=None):
119119
# pylint: disable-next=import-outside-toplevel,no-name-in-module,import-error
120120
from multiprocess import Process
121121

122122
# pylint: disable-next=not-callable
123123
proc = Process(
124-
target=job_fn, args=(key, self._make_progress_key(key), args, context)
124+
target=job_fn,
125+
args=(key, self._make_progress_key(key), args, context, on_error),
125126
)
126127
proc.start()
127128
return proc.pid
@@ -168,7 +169,7 @@ def get_updated_props(self, key):
168169

169170

170171
def _make_job_fn(fn, cache, progress):
171-
def job_fn(result_key, progress_key, user_callback_args, context):
172+
def job_fn(result_key, progress_key, user_callback_args, context, on_error):
172173
def _set_progress(progress_value):
173174
if not isinstance(progress_value, (list, tuple)):
174175
progress_value = [progress_value]
@@ -187,6 +188,7 @@ def run():
187188
c.ignore_register_page = False
188189
c.updated_props = ProxySetProps(_set_props)
189190
context_value.set(c)
191+
errored = False
190192
try:
191193
if isinstance(user_callback_args, dict):
192194
user_callback_output = fn(*maybe_progress, **user_callback_args)
@@ -195,18 +197,24 @@ def run():
195197
else:
196198
user_callback_output = fn(*maybe_progress, user_callback_args)
197199
except PreventUpdate:
200+
errored = True
198201
cache.set(result_key, {"_dash_no_update": "_dash_no_update"})
199202
except Exception as err: # pylint: disable=broad-except
200-
cache.set(
201-
result_key,
202-
{
203-
"long_callback_error": {
204-
"msg": str(err),
205-
"tb": traceback.format_exc(),
206-
}
207-
},
208-
)
209-
else:
203+
if on_error:
204+
user_callback_output = on_error(err)
205+
else:
206+
errored = True
207+
cache.set(
208+
result_key,
209+
{
210+
"long_callback_error": {
211+
"msg": str(err),
212+
"tb": traceback.format_exc(),
213+
}
214+
},
215+
)
216+
217+
if not errored:
210218
cache.set(result_key, user_callback_output)
211219

212220
ctx.run(run)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from dash import Dash, html, Input, Output
2+
3+
4+
def test_cber001_error_handler(dash_duo):
5+
def global_callback_error_handler(err):
6+
return f"global: {err}"
7+
8+
app = Dash(on_error=global_callback_error_handler)
9+
10+
app.layout = [
11+
html.Button("start", id="start-local"),
12+
html.Button("start-global", id="start-global"),
13+
html.Div(id="output"),
14+
html.Div(id="output-global"),
15+
]
16+
17+
def on_callback_error(err):
18+
return f"callback: {err}"
19+
20+
@app.callback(
21+
Output("output", "children"),
22+
Input("start-local", "n_clicks"),
23+
on_error=on_callback_error,
24+
prevent_initial_call=True,
25+
)
26+
def on_start(_):
27+
raise Exception("local error")
28+
29+
@app.callback(
30+
Output("output-global", "children"),
31+
Input("start-global", "n_clicks"),
32+
prevent_initial_call=True,
33+
)
34+
def on_start_global(_):
35+
raise Exception("global error")
36+
37+
dash_duo.start_server(app)
38+
dash_duo.find_element("#start-local").click()
39+
40+
dash_duo.wait_for_text_to_equal("#output", "callback: local error")
41+
42+
dash_duo.find_element("#start-global").click()
43+
dash_duo.wait_for_text_to_equal("#output-global", "global: global error")
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from dash import Dash, Input, Output, html
2+
from tests.integration.long_callback.utils import get_long_callback_manager
3+
4+
long_callback_manager = get_long_callback_manager()
5+
handle = long_callback_manager.handle
6+
7+
8+
def global_error_handler(err):
9+
10+
return f"global: {err}"
11+
12+
13+
app = Dash(
14+
__name__, long_callback_manager=long_callback_manager, on_error=global_error_handler
15+
)
16+
17+
app.layout = [
18+
html.Button("callback on_error", id="start-cb-onerror"),
19+
html.Div(id="cb-output"),
20+
html.Button("global on_error", id="start-global-onerror"),
21+
html.Div(id="global-output"),
22+
]
23+
24+
25+
def callback_on_error(err):
26+
return f"callback: {err}"
27+
28+
29+
@app.callback(
30+
Output("cb-output", "children"),
31+
Input("start-cb-onerror", "n_clicks"),
32+
prevent_initial_call=True,
33+
background=True,
34+
on_error=callback_on_error,
35+
)
36+
def on_click(_):
37+
raise Exception("callback error")
38+
39+
40+
@app.callback(
41+
Output("global-output", "children"),
42+
Input("start-global-onerror", "n_clicks"),
43+
prevent_initial_call=True,
44+
background=True,
45+
)
46+
def on_click_global(_):
47+
raise Exception("global error")
48+
49+
50+
if __name__ == "__main__":
51+
app.run(debug=True)

0 commit comments

Comments
 (0)