Abort() Considered Harmful
Consider a function like the following within the context of a RESTful API handling a request such as
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.