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.