Context
There have been a series of discussions around introducing various new methods of handling exception as seen in other languages: PEP 463 – Exception-catching expressions | peps.python.org, Introducing a Safe Navigation Operator in Python - #163 by elis.byberi
All of these stalled due to the community not able to reach consensus around accepting the trade-off these proposals are presenting of allowing the developer to discard the exception itself. It was felt that although it may be convenient, it may harm the developer in long term as they no longer has any context why they got that value and it might not be as easy to spot this is the case compared to a big fat try block.
At their core the authors appeared to wants the ability to succinctly know if an API they called failed or not. If it failed they want to be able to quickly and easily handle it without needing to think about how to avoid ending up with a nest of exception handlers which can make it hard to follow the control flow similar to when there is a large nest of if statements:
def my_endpoint(data: str):
try:
schema = Schema.validate(data)
try:
model = Model.get(schema.model_id)
except Model.DidNotFind:
model = Model()
return model
except ValidationError:
return "Schema is invalid"
Even if this isn’t enough to convince you, the way APIs in python return their results can be wildly different which adds more things to learn for the developer.
Some APIs may treat unrecoverable logic errors (out of memory etc) and recoverable application errors (Database could not be connected to) as Exceptions.
Others will treat application errors as something they can return in their own custom result object (One example being the Response object for the requests library)
And finally some libraries which are based on a C library may return None and have some kind of API for fetching the error.
Some of these approaches make it hard for the developer to realise they have missed how to handle errors, exceptions for example may have an unlimited amount of potential errors to handle since it handles the unexpected cases. APIs which handle application errors as a result type make it more clear what the expected errors should be which can be more easily expressed through python’s type system but since every API has their own way of representing this the syntax can vary depending on what it is that can fail:
A HTTP response has fields such as “status_code” that must be inspected, whilst with a Future the user is expected to check “exception” (Unless of course this future may contain a result object such as a HTTP response)
Prior Art
In languages such as Rust and Swift, they recognised the value in introducing a Result type in their stdlib to provide a convention that all APIs can adopt. The result type is essentially a Union Type or Enum that can be either “Success” or “Error” with methods for handling each (Similar to Future in python).
They have proven to be a very flexible tool in the developer’s arsenal of error handling tools and are a battle tested concept. Even languages which have some kind of async programming paradigm have some kind of Result type in the form of Futures or Promises, think of these as being those but for all cases than just async programming.
The pseudo code of these structures boil down to something like this
class Success:
value: any
class Error:
value: Exception
class Result(enum.Enum):
Success = Success
Error = Error
When implementing an API which returns a result the API is very similar to futures:
def a_request_that_could_fail() -> Result[str, NotFoundException | InsecureConnection]:
return Result.Success("Hoe much wood could a woodchuck chuck if a woodchuck could chuck wood")
In these languages Results are full class citizens of pattern matching, for example:
match a_request_that_could_fail():
case Success:
print("dddd")
case Error:
print('ssss')
This also unlocks the kinfdof succinct error handling, without losing the error details that the python community is after:
result = a_request_that_could_fail()()
name = result.value if result is Success else print("Could not load name: {result.value}")
Implementation
Since Future is essentially a result type for async programming, we could instead introduce a more generalised class for returning results into the stdlib based on the pseudo code above.
This class could then be adopted by APIs who want to adopt the new convention, classes such as futures would essentially become a async focused “Result” subclass.
As part of implementing the PEP it might be possible to implement as a small POC library which developers could adopt before adding to the stdlib (Similar to enum)
Backwards Compatibility
- Previous APIs would have no changes, this would be an optional new way of returning errors they can choose to adopt or not.
- The result type could be easily back-ported similarly to how enums were through a package, but with some features limited if they relied on specific support from the language
- Since Result is a container type, support from the type system to declare what success value types and error types might be useful (Similar to lists and dicts)
Out of scope
- This proposal isn’t trying to go into detail about how existing python APIs would adopt this, since this would be something to discuss after landing this PEP
- This proposal isn’t trying to discuss how to make these APIs more convenient by providing operators (such as “try?”, “.?”, “??”, “?”). These only make sense to discuss once this is implemented and we’ve seen real-world use of this new class in python with the existing APIs. Only at that point may we get a feel for which of these operators we truly need.