Introduce Result class to allow a modern representation of failure cases

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.
2 Likes

For prior art, see:

Though there are some drawbacks, such as not supporting exhaustive match ing due to a lack of @sealed.

Also related to ADTs:

2 Likes

I don’t think these are good examples. OOM isn’t something I would call a logic error – in fact, that error is so special, it’s one of the few which inherits directly from BaseException. And the response object from requests does not substitute for exceptions from that library. Making an HTTP request and getting a 4xx or 5xx response means you got a well formed response, and is distinct from the error cases (NetworkError).

Because you come back to the request/response example later in your post, it matters that the current requests library semantics are actually pretty good.


If folks want to look at prior at, I’d also consider result:


I find a lot of the current argumentation in this proposal weak. I stand by what I said in the thread about null coalescing operators that a result type combined with dedicated operators (IMO, selectively, just one or two operators) could be interesting. But right now I think this does not engage carefully enough with the subject matter, with clear enough driving use cases.

It’s not clear what APIs would adopt a result type, what the resulting usage would look like, and what benefits their users would derive.
Some of the text seems to argue that exceptions are hard to handle, which is not convincing, given that we’ve been doing it for decades in multiple programming languages.

It’s important for any proposal which leans on “Rust does it this way” to directly engage with the fact that Rust doesn’t have exceptions.

Keep in mind that non-additively changing existing parts of the stdlib is difficult and typically such proposals are rejected in the name of backwards compatibility. So, are there well known libraries which would start using a result type if one were added? What would the authors of existing packages which provide result types like to see in the core language? Why are, or aren’t, those packages used in the community?

1 Like

Also see:

The common result retrieval API for those is obj.result() and obj.exception() (the concurrent.futures variant is a blocking call, so it adds a timeout parameter, but that isn’t relevant here).

I don’t think designing a Result protocol that plays nicely with both the existing Future objects and with pattern matching to easily separate successes and failures will be entirely straightforward, but I do think it should be feasible.

This proposal is still only at the Ideas thread stage - think brainstorming moreso than proposing a concrete change. There’s a seed of a potentially interesting idea here, so we want to see if it can grow in directions that make it more compelling.

3 Likes

Yes, good callout – I think my tone was overall too negative. I like the idea of a result type. And I think ?. and ?[] accessors might play well with it (TBH, still a little unclear on how).

Right now, I’m not clear on who would use it though. I need those examples, I think, to understand what the idea “really is”.

1 Like

So here is one such use case, imagine you want to process an entire list. You want to keep track of items that you weren’t able to process successfully. For implementation reasons you cannot run it concurrently (thread safety, resources etc) but you want to try to process as much as you can and only see the errors at the end (This is basically what unit testing libraries do)

Here are your options with python today:

Option 1 - Throw an exception

def download(file):
    # For the purposes of this example this is a function that could fail

list_of_downloads = ["file1.txt", "file2.txt"]

try:
    data = [list_of_downloads(file) for file in list_of_downloads]
except DownloadError as error:
   print(error)

In this case if you are lucky the exception may give you details on which download failed from that list but some exceptions may be thrown that lack the necessary information needed to figure out which item it was and this program will also quit at the first issue t has

Option 2 - Wrap loop comprehension an exception

def download(file):
    # For the purposes of this example this is a function that could fail

list_of_downloads = ["file1.txt", "file2.txt"]
data = []

for item in list_of_downloads:
  try:
      data.append(list_of_downloads(file))
  except DownloadError as error:
     print('Error for:", file)
     print(error)

In this case you can now at least associate it with which file caused the error but it’s now more verbose, it won’t quit at first error but it may put it in the middle of the logs which is not what you want

Option 3 - Manually track errors:

def download(file):
    # For the purposes of this example this is a function that could fail

list_of_downloads = ["file1.txt", "file2.txt"]
data = []
errors = []

for item in list_of_downloads:
  try:
      data.append(list_of_downloads(file))
  except DownloadError as error:
     errors.append((file, error)

for file, error in errors:
    print('Error for:", file)
    print(error)

This is even more verbose but at least it does what we want.

Option 4 - Use Futures for their result like functionality

import concurrent.futures

def download(file):
    # For the purposes of this example this is a function that could fail

list_of_downloads = ["file1.txt", "file2.txt"]

with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
    data = {(file, executor.submit(download_file, file)) for file in list_of_downloads}

for file, result in data:
   if result.exception:
      print('Error for:", file)
      print(result.exception)

We now have result like functionality, but we’ve had to pull in an entire async library and boilerplate just to get it

Ideal Solution - If we had a Results protocol

def download(file):
    # For the purposes of this example this is a function that could fail

list_of_downloads = ["file1.txt", "file2.txt"]
data = {(file, download_file(file)) for file in list_of_downloads}

for file, result in data:
   if result.exception:
      print('Error for:", file)
      print(result.exception)

Exact functionality we want without complicated tracking of errors or needing an async library

While I like actual result types in theory, I can’t see myself using it in a language like python in a higher level API. Returning a result type is not a guarantee of not raising an exception, nor could such a guarantee ever be made. Exception handling is here to stay, so this just means more to do.

We have exception groups for handling multiple exceptions, and in concurrent applications, we have futures that specifically capture exceptions separately at the lower level (but in which higher level tools now turn this into exception groups)

My argument is that python already directly has result types just not in name, for example these are APIs and libraries that return an object that can contain data indicating success or an error:

  • Future from concurrent.futures are a Result Type
  • Response form the requests are a Result Type

And we have libraries and APIs that act as a result type but use Exceptions to indicate an error:

  • ORM queries from django is a Result Type "Book.objects.get(id=5)’
  • Subscripts “dict[‘key’]”

All of these provide there own way of indicating an error, what happens when the user suddenly also needs to add support for a GraphQL library. They also need to handle the exceptions it throws which right now can be difficult in non-async code (See my previous comment) or it’s yet another custom result type they need to handle.

At it’s core this proposal is about defining a convention, If Futures are just Results for aync programming or AsyncResults if you will. Then it stands to reason that a Result is essentially a SyncFuture.

Once we have both we now not only have a convention for unexpected errors through Exceptions but we now have a convention for returning errors we expect our application to handle through Futures and Results. Meaning no matter which library a user is using they don’t need to care if it throws exceptions, uses futures, returns HttpResponses or GraphQLresponses. They only care that it returns a result.

I understand that this proposal is more interesting with dedicate operators, but this doesn’t mean there isn’t value in introducing a convention behind returning errors for sync code where it makes error handling easier. So they can live prior to the introduction of such operators, and in fact they already do given the bunch of result like classes that already exist in python + it’s libraries.

Secondly based on the discussions around operators etc, we would need something like this to resolve the concerns people hard regarding null operators swallowing errors but even just discussing the operators ended up with the conversation being bogged down.

Given that I think there is value just in establishing a convention around error handling (just like conventions such as WSGI have for handling HTTP requests) on it’s own which can later be extended to operators if we decided to go in that direction ,it doesn’t in my view make sense to end up being side tracked on things that could be deferred until later on. There is no credible reason that a Result type needs such operators.

Option 6 - Use exception groups:

# * Two general-use helpers somewhere in a library:

@contextlib.contextmanager
def grouping_exceptions(msg="collected exceptions"):
    exceptions = []
    try:
        yield exceptions
    except BaseException as exc:
        exceptions.append(exc)
    raise BaseExceptionGroup(msg, exceptions)

@contextlib.contextmanager
def collecting_exc(exceptions):
    try:
        yield
    except BaseException as exc:
        exceptions.append(exc)
        
# * Actual code:

def download(file):
    # For the purposes of this example this is a function that could fail

list_of_downloads = ["file1.txt", "file2.txt"]
data = []

with grouping_exceptions("occurred while downloading") as exceptions:
    for item in list_of_downloads:
        with collecting_exc(exceptions):
            data.append(list_of_downloads(file))

The point is it’s about separating unexpected errors from ones you can handle. Swift and Rust still both have exceptions but the idea is that code lives at the highest part of your code designed to handle it so you don’t end up with exception blocks littered everywhere i.e If it’s a memory error, lets unload some things and try again

There are many cases where exception blocks harm readability which is why many library developers effectively return result types. For example the requests library will return to you a “HttpResponse”, so even if don’t think you would use a result type, there is probably loads of such result types in your application

But the problem is there is no consistent interface, if you swap out one function call for another. You will also need to change your error handling even if for example it’s just another HTTP request library which will largely face the same exact problems (network errors, http status codes)

One of the great things about futures is the async APIs can throw an exception, return a successful value or even just return an Exception. Your code doesn’t care it only needs to handle a future, there is no reason why the sync APIs can’t also benefit from this given so many libraries are already effectively rolling their own SyncFutures.

Yeah that’s pretty hard to understand IMHO and also doesn’t solve the problem statement

We already have this, you handle the exceptions you know how to.

try:
   thing_that_can_fail()
except KnownException as e:
    handle_known(e)

I wouldn’t agree with this. You can write legible code with either pattern of handling errors.

This won’t go away by using result types. In either the result type or exception handling version, you need to adapt your error handling for the specific errors that the library you swapped for raises/returns.

That’s… really not accurate. For a handful of lower level utilities that don’t do real error handling and only act as a “last resort log”, or that never interact with the return value (things like cache layers that forward everything to the consumer), it can be, but analogs of those work with functions raising exceptions too. If you’re actually correctly handling errors, you care what the errors are in determining what to do with them.

Yes but like I said many libraries have made the choice to treat certain errors as a response, so yes whilst you can use Exceptions to do this. Many library developers have decided it’s not optimal especially as you might not need some features of exceptions such as a stack trace if the error is just supposed to represent an error 500 response from the server, So the python ecosystem already has these result types just not in name

Sure, but the thing I am suggesting is that Result types already exist in python today but they not called result types and are often bespoke to that library. This means if you want to throw an exception because you think thats the best and most optimal way to deal with an error you now need to have specific code everywhere you use one of the Result-type like APIs which can convert it into an Exception. If at least there was a consistent interface then you could at least do “raise Exception(result.value) if result is Error” and get an exception if needed like we can with futures without library specific glue code.

Conversely with out Result types, everyone would have to throw Exceptions which obviously the ecosystem has already decided is not ideal otherwise they would not have developed libraries such as “requests” where a failed API request is returned as a Response object with the failed status code rather than an Exception

I think the issue here is that you can’t go back in time. If a Result type had always been around, the issues @mikeshardmind brings up might not be a problem. But a proposal for a new thing now has to directly deal with the transition (or lack thereof), not just what might have been.

The current libraries aren’t going to change en masse, so we will end up with a mix of the two. Is there a path to re-unifying on Result types, or is it just another way to do it?

1 Like

It’s a combination of lack of time travel, and that without the things other languages do syntactically around Result types, there’s usually not a compelling reason to use one. Everything that can be done with one can be done with exceptions, but the reverse is not true. Exceptions are zero-cost in the happy path.

I also don’t think it even needs standard library inclusion. If it becomes a common enough pattern to mirror the futures API, then simply documenting your return type as a protocol tells people “don’t worry about what the underlying type is, here’s the method we document on it for accessing value or exception” If that ends up consistent across libraries, you have a consistent interface without needing a consistent runtime type. If it doesn’t, that might indicate that different libraries are using it as more than just a result type as you see it.

1 Like

Well we can look at how other languages such as Javascript adopted Promises and Futures.

  1. Essentially it’s a way of reunifying through defining a protocol, For example if library returns a Response object today. It could add the ability to conform to the Result protocol so you can check easily if its an error or not. Similarly to how they adopted promises, for a moment you had apis that did the old callback pattern and then promises
  2. Languages then also added language level features to help with transition, for example in some languages you have operators such as “try?” which allow you to capture exceptions and convert them into a result type even if the library didn’t supprot it

By introducing them we can cater for both groups, like exceptions ? then this result type can act like one. Like having your errors be returned like a value like with futures, it can do that too

Right now it’s kind of a style and taste choice for each developer and library. There isn’t any reason why it has to be that way ?

This then therefore unlocks features for python like null operators, that is if we want the null operators to return an error with details on why the value is None. And not just be None

I don’t think totally true, Promise and Futures show that you can return an Exception in a way that forces people to think about how they handle errors, without throwing them and still keeping the information around context of the error

But still alllow people who prefer exceptions as the way to handle everywhting to do so.

I think a relevant question here is: why aren’t they all using the same way of doing this? What are the differences or incompatibilities that lead to many libraries inventing a version?

Because if those existing users can’t be brought on the same page, it’s unlikely that there’s a solution that fits in the stdlib.

I don’t know the exact reason but I suspect it’s down to preference, for me personally I’ve also seen Exceptions as exceptional reasons that my application cannot continue as it may not be safe to do so (KeyAccess etc). I know I’m not alone.

It can also be the case that they think it’s more concise to do this:

result = request.get()
users = result.json if result.status == 200 else []

Than:

try:
result = request.get()
users = result.json
except Exception:
users = []

I think one of the cruxes of the proposal is it should be possible to go interchangeable between both like we can kind of do with Futures and their ability to return an Exception as a value. But in following a convention that everyone understands. Of course there might be a transition period but I believe we had that with Enums in python too ?

Finally the problem with the “why can’t we all just use exceptions” argument is you could also just extend this to other concepts. Why did we need turnery statements when you can do that with if blocks ?

Why did we need the match statement when we can also do that with the if statement.

I wasn’t asking that as a rhetorical question, I think the answer is a necessary part of the background for this to be a viable proposal.

It’s less about why people wrote their own version[1] and more about whether the various solutions are actually compatible with each other, or if they each behave differently due to the use case. If they do, then a standard solution needs to accommodate all of those differences somehow.


  1. people love to roll their own solution to things ↩︎