Handling Exceptions in Frappe Framework

Problem Statement

Frappe Framework raises errors through the frappe.throw wrapper and allows the developer to specify many properties regarding the error. This includes the exception to raise, and the message to display to the end user.

This is great, until the developer handles an exceptions raised by frappe.throw. The error message is still displayed to the end user.
The developer can omit these messages by setting frappe.flags.mute_messages = True This can be dangerous because if the developer doesn’t reset the flag after they’ve handled the exception, then no unhandled exceptions will be displayed to the user.

So, what is the appropriate way to handle exceptions in frappe?

Examples

Case 1:

@frappe.whitelist()
def test_1():
    """
    This case we will attempt to load a non-existing document without handling any exceptions.

    Observation and Expected Results:
        1. The exception frappe.DoesNotExistError is raised.
        2. The api call returns a status code of 404.
        3. A dialog is displayed to the user regarding the error that occurred.
    """
    frappe.get_doc("DocType", "Foo")

Case 2:

@frappe.whitelist()
def test_1a():
    """
    Case Description:
        This case we will attempt to load a non-existing document while handing a single expected exception
        frappe.DoesNotExistError

    Observed Results:
        1. The exception frappe.DoesNotExistError is raised.
        2. We catch the exception. (For demonstration, we aren't actually doing anything with the caught exception)
        3. The api call return a status code of 200
        4. A dialog id displayed to the user regarding the error that was caught.

    Expected Results:
        Don't display an error dialog in regards to an exception that has been caught.
    """
    try:
        test_1()
    except frappe.DoesNotExistError:
        pass  # Do something useful :p

Case 3:

@frappe.whitelist()
def test_1b():
    """
    Case Description:
        Repeat the same case as test_1a except we will enable the flag mute_messages to avoid an error dialog of the
        already caught exception.

    Observed and Expected results:
        1. The exception is handled
        2. api responds with status 200
        3. the end user knows the action they performed is successful.
    """
    frappe.flags.mute_messages = True
    test_1a()

Case 4:

@frappe.whitelist()
def test_1c():
    """
    Case Description:
        Execute test_1b knowing that all known exceptions will be handled.

        Then attempt to add an already existing document without handling any possible exceptions.

        Observed Results:
            1. The exception frappe.DuplicateEntryError is raised
            2. api responds with status code 409
            3. The traceback to the error is posted to the browser console.
            4. The end user has no idea their request didn't work. (If they don't have the browser console open)

        Expected Results:
            1. The exception frappe.DoesNotExistError is raised and handled. (Don't report to user since it's been handled)
            2. The exception frappe.DuplicateEntryError is raised
            3. The api responds with a client error.
            4. The user is notified by the error dialog of their mistake.
    """
    test_1b()
    frappe.get_doc({
        "doctype": "Gender",
        "gender": "Male"
    }).insert()

Possible Solutions ?

frappe.flags.mute_messages
By setting the frappe.flags.mute_messages flag before an expected exception is to occur, then resetting it after the exception has been handled kind of works.
Only if frappe.DoesNotExistError is raised during the try block this method will work, if any other exception is raised it will not be displayed to the user.

@frappe.whitelist()
def test_2():
    """
    Case Description:
        Catch frappe.DoesNotExistError while muting messages. Then reset mute_messages after handling the error.

    Observed and Expected Results:
        1. The frappe.DoesNotExistError does not get presented to the user since it's been handled.
        2. The frappe.DuplicateEntryError is displayed to the user.
    """
    try:
        frappe.flags.mute_messages = True
        test_1()
    except frappe.DoesNotExistError:
        pass
    finally:
        frappe.flags.mute_messages = False

    frappe.get_doc({
        "doctype": "Gender",
        "gender": "Male"
    }).insert()

Avoid exceptions?
Developing to avoid exceptions is the best way in my option to avoid this.

@frappe.whitelist()
def test_3():
    if frappe.db.exists("DocType", "Foo"):
        test_1()
        
    if not frappe.db.exists("Gender", "Male"):
        frappe.get_doc({
            "doctype": "Gender",
            "gender": "Male"
        }).insert()

To conclude
Should developers handle exceptions via try - except statements, or avoid exceptions using logical conditions?

12 Likes

Fantastic post. I’ve encountered and struggled with this myself. Most-recently, about a week ago.

Like you mentioned, to avoid the messages when frappe.get_doc() fails, I’ve been preceding with a call using frappe.db.exists(). This works…but I’m not happy with it. Because it potentially means two SQL calls, when really I only needed one.

  • frappe.db.exists() queries SQL to see if the record is there.
  • frappe.get_doc() queries SQL again, this time fetching the columns desired.

From a performance perspective, this bothers me (yes, it’s possible cache is used…but no guarantees)

For my own App code, I deliberately avoid writing frappe.throw(), for this reason and a few others.

Having read your post, I think I might try editing frappe.get_doc(), and add a new quiet=True argument. With the quiet argument, it would either leverage frappe.flags.mute_messages. Or alternately, call raise frappe.DoesNotExistError instead of frappe.throw.() :thinking:

4 Likes

Other Cases Could be:

Calling
frappe.clear_last_message() in the except block
or
frappe.clear_messages() to clear all messages
or
remove the entry manually from the list frappe.local.message_log

5 Likes