DEV Community

Michael Bukachi
Michael Bukachi

Posted on

Making Safe API Calls With Python

Making REST API calls in python is quite common and easy especially due to existince of libraries such as requests.

Take the following example:

def get_user(user_id):
    res = requests.get(f'https://jsonplaceholder.typicode.com/users/{user_id}')
    if res.ok:
        return res.json()
    else:
        print(f'{res.status_code}: {res.text}')
        return {}
Enter fullscreen mode Exit fullscreen mode

The code is pretty straightforward. We are defining a function to fetch a user's details from an API (A very handy web app for testing out web apps).

Simple. Right?

However, what happens when there is no connectivity? Exceptions are raised.
This can easily be wrapped in a try...except clause but it makes our code messy, especially when we have multiple endpoints to call.

Enter returns.

Grand entrance

Returns is a library written with safety in mind. It's quite powerful and has a lot to offer. Now let's quickly fix our unsafe code.

class GetUser(object):

    @pipeline
    def __call__(self, user_id: int) -> Result[dict, Exception]:
        res = self._make_request(user_id).unwrap()
        return self._parse_json(res)

    @safe
    def _make_request(self, user_id: int) -> requests.Response:
        res = requests.get(f'https://jsonplaceholder.typicode.com/users/{user_id}')
        res.raise_for_status()
        return res

    @safe
    def _parse_json(self, res: requests.Response) -> dict:
        user = res.json()
        return user


get_user = GetUser()

Enter fullscreen mode Exit fullscreen mode

We have defined a class which can be invoked as a function once instantiated. We need to take note of the @safe decorator, a very handy decorator which wraps any exception raised into a Failure object.
The Failureclass is a subclass of the Result type which is basically a simple container.
Now, our code changes from:

try:
    user = get_user(1)
except Exception:
    print('An error occurred')
Enter fullscreen mode Exit fullscreen mode

to:

user = get_user(1) # returns Success(dict) or Failure(Exception)
user = user.value_or({})
Enter fullscreen mode Exit fullscreen mode

Isn't that just elegant?

I know what you are thinking. It's too much code!
That's the same thing I thought at first. But after playing around with the library and a bit of customization, I started to notice that my code looked cleaner. I could quickly scan through a file and get the gist of what is going without have to follow references. This is just the surface. Returns has way more to offer. Check it out.

Here's a decorator I came up with to automatically unwrap Result inner values or provide a default value instead:

def unwrap(success=None, failure=None):
    def outer_wrapper(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            if is_successful(result):
                if success is not None:
                    return success
            else:
                if failure is not None:
                    return failure
            return result._inner_value

        return wrapper

    return outer_wrapper

Enter fullscreen mode Exit fullscreen mode

Usage:

    @unwrap(failure={}) # If a failure occurs an empty dict is returnd
    @pipeline
    def __call__(self, user_id: int) -> Result[dict, Exception]:
        ...

user = get_user(1) # returns dict or empty dict      
Enter fullscreen mode Exit fullscreen mode

Cheers!

Top comments (0)