Error handling in Scala
After a few years of using Scala to develop backend services at Harry’s, we developed a robust approach to error handling, leveraging its powerful type system. It takes advantage of the flexibility of Scala types and tries to avoid defensive programming such as aggressive exception catching and re-throwing.
It can be summarized as:
Use non-exception types for expected errors and exceptions for unexpected errors. Let exceptions bubble all the way up to the edge of the system where they are caught and sent to an error aggregator service, such as Bugsnag or Sentry.
This article uses a simplified version of the Harry’s order service as a way to illustrate this approach.
This app is a small web service with a single endpoint allowing clients to create purchases, for the sake of simplicity the only element of the payload we are handling is a string, acting as a payment instrument. In a real world scenario things would be more complicated, the payload would need to include information about the products ordered and the payment data would need to be handled really carefully with regards to PCI compliance. Stripe’s new APIs, Payment Methods and Payment Intents, which are SCA compliant, are great tools in that regard.
The code can be found on Github
The two main requirements for this small project are the following:
- In order to allow API clients to display useful errors to users, non happy path scenarios, such as insufficient funds or invalid credit card zip code are explicitly handled and result in specific API error responses including detailed information.
- All other unexpected events, such as random network failures or failures from a third party API, result in a generic error. Having such handler as close as possible to the place where an API response is generated helps guarantee that any unhandled errors result in a generic 500 errors. No stacktrace or any other internal information should leak through the API.
The web API defines a single route, responding to a POST request:
The happy path is reasonably straightforward, the JSON payload is parsed and the deserialized instance of the
Orderclass is then passed to a service class, which will in turn delegate the API call to the payment gateway to a dedicated client class. If the payment goes through, we then return a successful response to the client. Once again we’re simplifying things quite significantly here, in a real world app, we would very likely want to do more, such as writing something to a database in order to have a record of the order.
chargeCard method simulates how an exception-based client would behave, throwing different exceptions depending on which error occurred.
case Failure(exception) =>clause handles any exceptions thrown within the
Future returned by the
Back to the main topic of this post, the situation starts to become more complicated once we start thinking about all the other outcomes beside the happy path. The following is a list of all the errors documented in the API:
The code we’re using is not actually making a request to Stripe, but it mimics the behavior of the official clients. Most of the official clients offer a generic and fairly non opinionated approach, using exceptions. The following is the official Java example, but most other languages look very similar, Go being the exception (pun intended):
This approach is not inherently flawed, but it didn’t feel satisfying to us, for the following reasons:
- In Java, exceptions can be either checked or unchecked, but Scala treats all exceptions as unchecked, as illustrated in this gist. This means that it is impossible to rely on strong compile time checks to ensure that exceptions are handled.
- There is no hierarchy of errors, it is really hard to infer which exceptions should be caught and which one should not.
Following this architecture, it is non trivial to handle some errors differently than others. In the case of a
CardException, we would like to extract the decline code from the error, and include it in the JSON response, so that a customer can see why the order failed. Being able to provide a detailed error, such as an invalid zip code, instead of a generic error can make a big difference both for the user, improving their experience, but also for the business. A user seeing a detailed error is more likely to know how to fix it and try again.
We now need to catch any potentially thrown exceptions to handle errors as previously explained. The following example shows how this can be achieved at the controller level.
This approach works fairly well here. When Stripe returns a card error, we include the decline code in the 412 Payment Required JSON response. All other errors result in a generic 500 Server Error response.
The controller code is now depending on lower level types, such as
CardException , which is not ideal.
Furthermore, while we may have achieved the required goal in this particular situation, since the
chargeCard method does not explicitly differentiate which exceptions are expected errors and which ones are unexpected, there is no way to rely on the compiler to make sure that all expected errors are handled in any other places where this method is called.
Looking at these errors closer we can separate them in in two distinct categories:
Expected errors, errors that cannot be prevented and therefore must be explicitly handled: card_error. While it might be interesting to have metrics to know how often these errors happen, observing them in a production environment, at a normal rate, is not a bug and does not warrant further investigation.
Unexpected errors, some of them being unpreventable, api_connection_error and api_error, and the ones caused by a bug or configuration error, aka errors that could be prevented: authentication_error, idempotency_error, invalid_request_error and rate_limit_error. In other words, observing the preventable ones in a production environment should be treated as a bug, probably urgent, and should be investigated.
For the unexpected errors that fall in the unpreventable category, there is not a lot of options in terms of actions that can be taken to address them. The few times we saw errors with the api_error code, we ended up reaching out to the Stripe support team to let them know about the issue.
The last error in the list, validation_error, is a little bit different since it’s never returned by the API but triggered from the client libraries and we will therefore ignore it.
This is what the code looks like with this approach:
The API client now returns a more complex type, which represents all the different outcomes that a caller must handle. By virtue of explicitly being part of the return type, a caller will have to explicitly handle all the cases.
PurchaseService class defines its own type for the
Left type returned by
createPurchase. It may look unnecessary here since it is identical to the one defined in the payment client, but it is included to illustrate that in a real world example, the
Left type might become more complex. We could imagine a parent trait
PurchaseFailure with subtypes such as
case class InventoryUnavaible(skus: Set[Sku]),
case class InvalidShippingAddress(zipcode: String) etc …
This is a situation where the Scala type system really shines. We defined our return type as
Either[ChargeFailure, Charge], meaning that the compiler can check that all values have been accounted for when enumerating all the possible values. In practical terms, it means that callers of this method have to handle both sides,
Right when matching against the value of the method. Not doing so will emit a compile warning, which would be a compilation error with the
Another benefit of this version, compared to the previous one, is that the semantics of the Stripe class do not leak through the rest of the app, the way it did with exceptions, where Stripe specific exceptions were handled in the controller.
Using exceptions for unexpected errors lets us write uncluttered code that doesn’t have to explicitly handle these truly exceptional cases. If the Stripe API returns an error due to an authentication error or a request being rate limited, there isn’t much that the user can do to. This is why we handle these cases by letting the generic handler return the “Something went wrong error”. Additionally, the error will be sent to an error aggregation service, Sentry in our case, to be addressed ASAP.
Either based approach is conceptually very similar to Railway Oriented Programming, which we will cover in details in another blog post.
The real payment service at Harry’s does not use the Stripe Java client. We ended up writing our own client, which uses the pattern described in this article.
- We use exceptions when the situation is truly exceptional, in its English sense, and do not expect callers to catch the exception, except the outer most layer of the program that makes sure a valid JSON response is returned through the API.
- Expected errors are handled through custom types, either with sealed traits,
Optiontypes. This article did not cover
Optionbut there are a lot of articles on the topic, this one being one of my favorites. We also did not get to cover how
sealed traittypes can be used to rely on exhaustive pattern matching checks at compile times, which can also be very useful when defining methods with complex return types.
- When dealing with exception-throwing third parties, we catch these with a simple try/catch or a Try and convert the error to a custom type.
- Either types are great when operations can fail and can be used to implement a version of the “railway oriented programming” pattern.
-Xfatal-warningscompiler flags makes sure that all warnings are treated as errors at the compilation step. This makes sure that pattern matching checks are exhaustive and fail if a case is not handled.
- Writing your own API client can be worthwhile, despite the added development cost, as you end up having full control over its architecture and behavior. In this case, even though the Scala & Java interoperability allowed us to use an existing library, rolling out our own allowed us to take advantage of Scala specific features, providing better ergonomics for our needs.
Disclaimer: I am not currently employed by Harry’s, but worked on developing this approach to error handling while working there.