Skip to content

Commit 351a81f

Browse files
authored
Merge pull request #2903 from plotly/feat/on-error
Add callback on_error handler
2 parents 501b715 + db28caf commit 351a81f

File tree

9 files changed

+197
-24
lines changed

9 files changed

+197
-24
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: 57 additions & 16 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

@@ -226,7 +233,7 @@ def insert_callback(
226233
long=None,
227234
manager=None,
228235
running=None,
229-
dynamic_creator=False,
236+
dynamic_creator: Optional[bool] = False,
230237
no_output=False,
231238
):
232239
if prevent_initial_call is None:
@@ -272,8 +279,16 @@ def insert_callback(
272279
return callback_id
273280

274281

275-
# pylint: disable=R0912, R0915
276-
def register_callback( # pylint: disable=R0914
282+
def _set_side_update(ctx, response) -> bool:
283+
side_update = dict(ctx.updated_props)
284+
if len(side_update) > 0:
285+
response["sideUpdate"] = side_update
286+
return True
287+
return False
288+
289+
290+
# pylint: disable=too-many-branches,too-many-statements
291+
def register_callback(
277292
callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs
278293
):
279294
(
@@ -297,6 +312,7 @@ def register_callback( # pylint: disable=R0914
297312
long = _kwargs.get("long")
298313
manager = _kwargs.get("manager")
299314
running = _kwargs.get("running")
315+
on_error = _kwargs.get("on_error")
300316
if running is not None:
301317
if not isinstance(running[0], (list, tuple)):
302318
running = [running]
@@ -342,6 +358,8 @@ def add_context(*args, **kwargs):
342358
"callback_context", AttributeDict({"updated_props": {}})
343359
)
344360
callback_manager = long and long.get("manager", app_callback_manager)
361+
error_handler = on_error or kwargs.pop("app_on_error", None)
362+
345363
if has_output:
346364
_validate.validate_output_spec(insert_output, output_spec, Output)
347365

@@ -351,7 +369,7 @@ def add_context(*args, **kwargs):
351369
args, inputs_state_indices
352370
)
353371

354-
response = {"multi": True}
372+
response: dict = {"multi": True}
355373
has_update = False
356374

357375
if long is not None:
@@ -440,10 +458,24 @@ def add_context(*args, **kwargs):
440458
isinstance(output_value, dict)
441459
and "long_callback_error" in output_value
442460
):
443-
error = output_value.get("long_callback_error")
444-
raise LongCallbackError(
461+
error = output_value.get("long_callback_error", {})
462+
exc = LongCallbackError(
445463
f"An error occurred inside a long callback: {error['msg']}\n{error['tb']}"
446464
)
465+
if error_handler:
466+
output_value = error_handler(exc)
467+
468+
if output_value is None:
469+
output_value = NoUpdate()
470+
# set_props from the error handler uses the original ctx
471+
# instead of manager.get_updated_props since it runs in the
472+
# request process.
473+
has_update = (
474+
_set_side_update(callback_ctx, response)
475+
or output_value is not None
476+
)
477+
else:
478+
raise exc
447479

448480
if job_running and output_value is not callback_manager.UNDEFINED:
449481
# cached results.
@@ -462,10 +494,22 @@ def add_context(*args, **kwargs):
462494
if output_value is callback_manager.UNDEFINED:
463495
return to_json(response)
464496
else:
465-
output_value = _invoke_callback(func, *func_args, **func_kwargs)
466-
467-
if NoUpdate.is_no_update(output_value):
468-
raise PreventUpdate
497+
try:
498+
output_value = _invoke_callback(func, *func_args, **func_kwargs)
499+
except PreventUpdate as err:
500+
raise err
501+
except Exception as err: # pylint: disable=broad-exception-caught
502+
if error_handler:
503+
output_value = error_handler(err)
504+
505+
# If the error returns nothing, automatically puts NoUpdate for response.
506+
if output_value is None:
507+
if not multi:
508+
output_value = NoUpdate()
509+
else:
510+
output_value = [NoUpdate for _ in output_spec]
511+
else:
512+
raise err
469513

470514
component_ids = collections.defaultdict(dict)
471515

@@ -487,12 +531,12 @@ def add_context(*args, **kwargs):
487531
)
488532

489533
for val, spec in zip(flat_output_values, output_spec):
490-
if isinstance(val, NoUpdate):
534+
if NoUpdate.is_no_update(val):
491535
continue
492536
for vali, speci in (
493537
zip(val, spec) if isinstance(spec, list) else [[val, spec]]
494538
):
495-
if not isinstance(vali, NoUpdate):
539+
if not NoUpdate.is_no_update(vali):
496540
has_update = True
497541
id_str = stringify_id(speci["id"])
498542
prop = clean_property_name(speci["property"])
@@ -506,10 +550,7 @@ def add_context(*args, **kwargs):
506550
flat_output_values = []
507551

508552
if not long:
509-
side_update = dict(callback_ctx.updated_props)
510-
if len(side_update) > 0:
511-
has_update = True
512-
response["sideUpdate"] = side_update
553+
has_update = _set_side_update(callback_ctx, response) or has_update
513554

514555
if not has_update:
515556
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/celery_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def run():
161161
c.ignore_register_page = False
162162
c.updated_props = ProxySetProps(_set_props)
163163
context_value.set(c)
164+
errored = False
164165
try:
165166
if isinstance(user_callback_args, dict):
166167
user_callback_output = fn(*maybe_progress, **user_callback_args)
@@ -170,13 +171,15 @@ def run():
170171
user_callback_output = fn(*maybe_progress, user_callback_args)
171172
except PreventUpdate:
172173
# Put NoUpdate dict directly to avoid circular imports.
174+
errored = True
173175
cache.set(
174176
result_key,
175177
json.dumps(
176178
{"_dash_no_update": "_dash_no_update"}, cls=PlotlyJSONEncoder
177179
),
178180
)
179181
except Exception as err: # pylint: disable=broad-except
182+
errored = True
180183
cache.set(
181184
result_key,
182185
json.dumps(
@@ -188,7 +191,8 @@ def run():
188191
},
189192
),
190193
)
191-
else:
194+
195+
if not errored:
192196
cache.set(
193197
result_key, json.dumps(user_callback_output, cls=PlotlyJSONEncoder)
194198
)

dash/long_callback/managers/diskcache_manager.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ def call_job_fn(self, key, job_fn, args, context):
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),
125126
)
126127
proc.start()
127128
return proc.pid
@@ -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,8 +197,10 @@ 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
203+
errored = True
200204
cache.set(
201205
result_key,
202206
{
@@ -206,7 +210,8 @@ def run():
206210
}
207211
},
208212
)
209-
else:
213+
214+
if not errored:
210215
cache.set(result_key, user_callback_output)
211216

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

0 commit comments

Comments
 (0)