Abort() Considered Harmful

Consider a function like the following within the context of a RESTful API handling a request such as POST /resource-a.

def make_resource_a(resource_a_request):

    # assume this client does no error handling of its own
    response = http_client.post(
        url="https://some-other-service.domain.com",
        body=json.dumps(resource_a_request)
    )

    resource_a = serialize_resource_a(response)

    return resource_a

For better or worse, synchronous RPC calls like this over HTTP are a fairly common thing to encounter, and anyone who’s worked with anything similar will know that there are quite a few things that can go wrong in this function.

A more robust version of this function might look something like this:

def make_resource_a(resource_a_request):

    client_facing_error_msg = "could not create resource-a at this time"
    
    try:
        response = http_client.post( 
            url="https://some-other-service.domain.com",
            body=json.dumps(resource_a_request)
        )
    # general exception; might be a timeout, conn refused/dropped, etc
    except NetworkError:
        abort(500, msg=client_facing_error_msg)

    if response.status_code != 201:
        abort(500, msg=client_facing_error_msg)

    try:
        resource_a = serialize_resource_a(response)
    except SerializationError
        abort(500, msg=client_facing_error_msg)

    return resource_a

This is better, in that we handle common issues and return something better than a generic 500 to the client. abort() will arrest execution of the function, hand control back to the framework’s controller, and have it return an HTTP response to the client that contains the relevant error message.

However, this pattern is still less than ideal. Imagine now that we’ve got another resource in the API called resource-b, with a creation function that looks like this:

def make_resource_b(resource_b_request):

    # resource-b requires a resource-a under the hood, so let's make one
    resource_a = make_resource_a(resource_b_request.resource_a_request)

    # use resource-a to make resource-b
    resource_b = _make_resource_b(resource_b_request, resource_a)

    return resource_b

Now we see the issue with the abort() approach. Instead of being a generic logic function, make_resource_a assumes that it is being called within the context of resource-a’s request path, and couples client-facing error handling with the business logic. This means that make_resource_b can’t consume make_resource_a without granting make_resource_a the ability to return context-dependent error messages to the client.

The way to decouple these two concerns is simple; use Exceptions, or if they’re not available, a consistent interface for bubbling error conditions upwards. The goal is to allow the caller to decide how to present an error to the client, as the business logic may be needed in multiple contexts. Below is how this would look with exceptions:

def make_resource_a(resource_a_request):
    
    try:
        response = http_client.post(
            url="https://some-other-service.domain.com",
            body=json.dumps(resource_a_request)
        )
    except NetworkError:
        raise ResourceACreationError

    if response.status_code != 201:
        raise ResourceACreationError

    try:
        resource_a = serialize_resource_a(response)
    except SerializationError:
        raise ResourceACreationError

    return resource_a

Callers can then use it as follows:

try:
    make_resource_a(resource_a_request)
except ResourceACreationError:
    abort(500, msg=relevant_client_facing_error_message)

The key takeaway is that business logic and error presentation should be decoupled. Otherwise, your functions become context-dependent and non-reusable.