Error Handling for Callbacks and Layouts

Good day, All,

I wanted to pass along something that I’ve learned recently.

It is how we can utilize custom error handling of Dash callbacks and layouts once a server is deployed, and or you cant watch the console all the time. Also when running a flask server instead of running the app, even with debug=True, you lose the error messages.

Anyways, to utilize this, we are going to add decorators to all the callbacks and layout functions.

TLDR:

import dash
from dash._callback import GLOBAL_CALLBACK_MAP
from dash._utils import to_json
import traceback
import utils.appFunction as apF
from dash import ctx
from dash.exceptions import PreventUpdate

def layout_decorator(error_message):
    def decorator(func):
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except:
                alertError(f"{(current_user or '')} - {error_message}", traceback.format_exc())
                error = 'there was an issue, IT has been notified'
                return html.Div([error,
                                 apF.notification('error', error)])
        return wrapper
    return decorator

def callback_decorator(error_message, output):
    def decorator(func):
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                if str(e) == '':
                    raise PreventUpdate
                alertError(f"{(current_user or '')} - {error_message}",
                           f"""{output}
{traceback.format_exc()}""")

            resp = {"app_notification": {
                            "children":
                                json.loads(to_json(apF.notification('error',
                                                                    'there was an issue, IT has been notified')))
            },
            "loading": {'style': {'display': 'none'}}}
            try:
                if isinstance(output, list):
                    for o in output:
                        if 'disabled' in str(o):
                            resp[str(o).split('.')[0]] = {'disabled': False}
                        if 'notification' in str(o).lower():
                            resp[str(o).split('.')[0]] = {"children":
                                json.loads(to_json(apF.notification('error',
                                                                    'there was an issue, IT has been notified',
                                                                    ctx)))}
                            del resp['app_notification']
            except:
                if 'disabled' in str(output):
                    resp[str(output).split('.')[0]] = {'disabled': False}
                if 'notification' in str(output).lower():
                    resp[str(output).split('.')[0]] = {"children":
                                                      json.loads(to_json(apF.notification('error',
                                                                                          'there was an issue, IT has been notified',
                                                                                          ctx)))}
                    del resp['app_notification']
                pass
            return json.dumps({
                    "multi": True,
                    "response": resp
                })
        return wrapper
    return decorator

for pg in dash.page_registry.values():
        if callable(pg['layout']):
            pg['layout'] = layout_decorator(pg['path'] or pg['path_template'])(pg['layout'])

for k, v in GLOBAL_CALLBACK_MAP.items():
    if v.get('callback'):
        v['callback'] = callback_decorator('error in callback', v['output'])(v['callback'])

Now lets break is down.

def layout_decorator(error_message):
    def decorator(func):
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except:
                alertError(f"{(current_user or '')} - {error_message}", traceback.format_exc())
                error = 'there was an issue, IT has been notified'
                return html.Div([error,
                                 apF.notification('error', error)])
        return wrapper
    return decorator

This assumes you are using functions for your layouts. It tries to perform the function, and upon error it will alertError, in my setup this is configured to send me an email upon an error. The return statement is configured in this example to send the app a notification.

Now, lets take a look at the callback handler and how it differs from the layout handler.

def callback_decorator(error_message, output):
    def decorator(func):
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                if str(e) == '':
                    raise PreventUpdate
                alertError(f"{(current_user or '')} - {error_message}",
                           f"""{output}
{traceback.format_exc()}""")

            resp = {"app_notification": {
                            "children":
                                json.loads(to_json(apF.notification('error',
                                                                    'there was an issue, IT has been notified')))
            },
            "loading": {'style': {'display': 'none'}}}
            try:
                if isinstance(output, list):
                    for o in output:
                        if 'disabled' in str(o):
                            resp[str(o).split('.')[0]] = {'disabled': False}
                        if 'notification' in str(o).lower():
                            resp[str(o).split('.')[0]] = {"children":
                                json.loads(to_json(apF.notification('error',
                                                                    'there was an issue, IT has been notified',
                                                                    ctx)))}
                            del resp['app_notification']
            except:
                if 'disabled' in str(output):
                    resp[str(output).split('.')[0]] = {'disabled': False}
                if 'notification' in str(output).lower():
                    resp[str(output).split('.')[0]] = {"children":
                                                      json.loads(to_json(apF.notification('error',
                                                                                          'there was an issue, IT has been notified',
                                                                                          ctx)))}
                    del resp['app_notification']
                pass
            return json.dumps({
                    "multi": True,
                    "response": resp
                })
        return wrapper
    return decorator

Notice, this is now passing the output from the callback, this is to help flip through the outputs for resetting things like if you are disabling buttons. This also checks to see if there are any notifications already displaying, I have a standard of my notification container to have notification somewhere in the name. This also is configured to update the style of my loading screen in the event that I have it showing.

As an important note:

except Exception as e:
    if str(e) == '':
        raise PreventUpdate

^ the raise PreventUpdate will flag this handler, thus checking to make sure there is an actual error string is import. If blank I just pass it along the chain.

Now the reason I utilize traceback vs the generic error string, I like to know the line and what exactly happened. Makes it a lot easier to troubleshoot imo.

There are a couple of internal functions from dash that we are pulling for this.

GLOBAL_CALLBACK_MAP is obviously the map of all the callbacks in the app.
to_json is helpful when converting dash components to their json response for the frontend to handle, I then parse this back to a dictionary for use in the children. :magic_wand:

Now, I will say this will bug you if you have silently failing code, or the errors are passing too quickly in your console. So, be prepared.


There might be other ways to implement this which would be smoother, but this is working for me. :slight_smile:

As a note, if you handle the found errors in the callbacks itself, then you wont trigger this handler.

I also prefer to handle the sweeping error handler because I do not want them app to crash, and this will notify you of everything.


Here is an example of the response:
image

5 Likes

Here is an MRE:

from dash import *
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from dash._callback import GLOBAL_CALLBACK_MAP
from dash._utils import to_json
import traceback
from dash import ctx
from dash.exceptions import PreventUpdate
import uuid
import json

app = Dash(__name__, use_pages=True, pages_folder='')

def notification(type, message, ctx=None):
    if type == "success":
        if ctx:
            if not isinstance(ctx.triggered_id, str):
                return dmc.Notification(
                    id=f'{ctx.triggered_id["type"] + "-" + str(ctx.triggered[0]["value"])}',
                    message=message,
                    action="update",
                    icon=DashIconify(icon="akar-icons:circle-check"),
                    color="green",
                )
            else:
                return dmc.Notification(
                    id=f'{ctx.triggered_id + "-" + str(ctx.triggered[0]["value"])}',
                    message=message,
                    action="update",
                    icon=DashIconify(icon="akar-icons:circle-check"),
                    color="green",
                )
        else:
            return dmc.Notification(
                id=str(uuid.uuid4()),
                message=message,
                action="show",
                icon=DashIconify(icon="akar-icons:circle-check"),
                color="green",
            )
    if type == "error":
        if ctx:
            if not isinstance(ctx.triggered_id, str):
                return dmc.Notification(
                    id=f'{ctx.triggered_id["type"] + "-" + str(ctx.triggered[0]["value"])}',
                    message=message,
                    action="update",
                    icon=DashIconify(icon="akar-icons:circle-check"),
                    color="red",
                )
            else:
                return dmc.Notification(
                    id=f'{ctx.triggered_id + "-" + str(ctx.triggered[0]["value"])}',
                    message=message,
                    action="update",
                    icon=DashIconify(icon="akar-icons:circle-check"),
                    color="red",
                )
        else:
            return dmc.Notification(
                id=str(uuid.uuid4()),
                message=message,
                action="show",
                icon=DashIconify(icon="akar-icons:circle-check"),
                color="red",
            )
    if type == "processing":
        if not isinstance(ctx.triggered_id, str):
            return dmc.Notification(
                id=f'{ctx.triggered_id["type"]+"-"+str(ctx.triggered[0]["value"])}',
                message=message,
                action="show",
                loading=True,
                color="orange",
                autoClose=False,
                disallowClose=True,
            )
        else:
            return dmc.Notification(
                id=f'{ctx.triggered_id + "-" + str(ctx.triggered[0]["value"])}',
                message=message,
                action="show",
                loading=True,
                color="orange",
                autoClose=False,
                disallowClose=True,
            )

def alertError(subject, message):
    print(subject)
    print(message)

def missingImport():
    return rawr

register_page('test', path='/test', layout=missingImport)

def layout():
    return 'this is a valid page'

register_page('home', path='/', layout=layout)

def layout2():
    return html.Div([html.Button('test callback', id='rawr'),
                     dmc.Checkbox(id='testChecked'),
                     html.Div(id='children')])

@callback(
    Output('children', 'children'),
    Input('rawr', 'n_clicks'),
    State('testChecked', 'checked'),
    prevent_initial_call=True
)
def partialFailingCall(n, c):
    if c:
        return rawr
    return 'I ran properly'

register_page('test-2', path='/testing', layout=layout2)

app.layout = dmc.NotificationsProvider(html.Div([
    html.Div(id='app_notification'),
    html.Div([dcc.Link(pg['name'], pg['path'], style={'display': 'block'}) for pg in page_registry.values()],
             style={'paddingRight': '50px', 'borderRight': '2px solid black', 'height': '100%',
                    'marginRight': '10px'}),
    page_container
], style={'display': 'flex', 'height': '100vh'}))

def layout_decorator(error_message):
    def decorator(func):
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except:
                error = 'there was an issue, IT has been notified'
                alertError(f"{error_message}", traceback.format_exc())
                return html.Div([error,
                                 notification('error', error)])
        return wrapper
    return decorator

def callback_decorator(error_message, output):
    def decorator(func):
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                if str(e) == '':
                    raise PreventUpdate
            alertError(f"{error_message}", f"{output}\n {traceback.format_exc()}")
            resp = {"app_notification": {
                            "children":
                                json.loads(to_json(notification('error',
                                                                    'there was an issue, IT has been notified')))
            },
            "loading": {'style': {'display': 'none'}}}
            try:
                if isinstance(output, list):
                    for o in output:
                        if 'disabled' in str(o):
                            resp[str(o).split('.')[0]] = {'disabled': False}
                        if 'notification' in str(o).lower():
                            resp[str(o).split('.')[0]] = {"children":
                                json.loads(to_json(notification('error',
                                                                    'there was an issue, IT has been notified',
                                                                    ctx)))}
                            del resp['app_notification']
            except:
                if 'disabled' in str(output):
                    resp[str(output).split('.')[0]] = {'disabled': False}
                if 'notification' in str(output).lower():
                    resp[str(output).split('.')[0]] = {"children":
                                                      json.loads(to_json(notification('error',
                                                                                          'there was an issue, IT has been notified',
                                                                                          ctx)))}
                    del resp['app_notification']
                pass
            return json.dumps({
                    "multi": True,
                    "response": resp
                })
        return wrapper
    return decorator


for pg in page_registry.values():
    if callable(pg['layout']):
        pg['layout'] = layout_decorator(pg['path'] or pg['path_template'])(pg['layout'])


for k, v in GLOBAL_CALLBACK_MAP.items():
    if v.get('callback'):
        v['callback'] = callback_decorator('error in callback', v['output'])(v['callback'])

for k, v in app.callback_map.items():
    if v.get('callback'):
        v['callback'] = callback_decorator('error in callback', v['output'])(v['callback'])


app.run(debug=True)

As a note, the GLOBAL_CALLBACK_MAP must be used if you are passing @callback and app.callback_map needs to be used if you are passing @app.callback.

Including both mechanisms will have all of the callbacks covered. :magic_wand: