8
\$\begingroup\$

Python3's async/await syntax is great, but it does create a divide between libraries which are async-based and those which are not. For example, boto3 (AWS API library) currently doesn't work with async. There is a separate project, aiobotocore, attempts to recreate some of this functionality in an async context.

I have been thinking for a while about how to create HTTP API clients which can be run async and sync contexts. I have come up with a strategy that involves creating a separation between the logic (preparation of requests, interpretation of responses), and the sending. The logic is implemented as a generator function, which yields out an object which represents the request, and receives back an object representing the response. This generator is "run" by a runner function, which does the actual sending, and may be sync or async.

You can imagine that if, for example, boto3 had been written this way, it would be easy to re-use the bulk of the code to make an async version, rather than having to do an async rewrite.

I have included a toy example below. I would welcome comments.

from typing import Iterable, NamedTuple, Dict, Any, Optional

import requests
from aiohttp import ClientSession

URL_TEMPLATE = "https://api.icndb.com/jokes/{id}/"


class Request(NamedTuple):
    method: str
    url: str
    json: Optional[Dict[str, Any]] = None


class Response(NamedTuple):
    status: int
    json: Optional[Dict[str, Any]] = None


def get_joke(id: int) -> Iterable[Request]:

    response = yield Request("GET", URL_TEMPLATE.format(id=id))

    try:

        if response.status != 200:
            raise JokeApiError("API request failed")

        data = response.json
        if data.get("type") == 'NoSuchQuoteException':
            raise NoSuchJoke(data.get("value", ""))
        if data.get("type") != "success":
            raise JokeApiError("API request failed")

        raise Return(data["value"]["joke"])

    except (JokeApiError, Return, StopIteration):
        raise
    except Exception as e:
        raise JokeApiError() from e


def call_api_sync(it):
    try:
        for req in it:
            response = requests.request(req.method, req.url, json=req.json)
            it.send(
                Response(status=response.status_code, json=response.json())
            )
    except Return as e:
        return e.value


async def call_api_async(it):
    async with ClientSession() as session:
        try:
            for req in it:
                async with session.request(
                        method=req.method,
                        url=req.url,
                        json=req.json) as res:
                    json = await res.json()
                    response = Response(status=res.status, json=json)
                    it.send(response)
        except Return as e:
            return e.value


class JokeApiError(Exception):
    pass


class NoSuchJoke(JokeApiError):
    pass


class Return(Exception):

    def __init__(self, value):
        self.value = value
\$\endgroup\$
  • \$\begingroup\$ I'd suggest adding aif name... section as well \$\endgroup\$ – hjpotter92 Jun 28 '18 at 17:23
  • \$\begingroup\$ I'm not sure I understand. Could you elaborate? \$\endgroup\$ – samfrances Jun 28 '18 at 18:13
3
\$\begingroup\$

The code is admirably clear. Nice annotations. With these classes, Chuck Norris wouldn't need a debugger, he could just stare down the bug until the code confesses.

class Request(NamedTuple):
    method: str

Maybe list method 2nd, and allow defaulting to 'GET'?

    response = yield Request('GET', URL_TEMPLATE.format(id=id))

    try:

        if response.status != 200:

Ok, nit-picky observation: there's more than one success code, so one might bury the test in an is_success() helper function. (I use an API that routinely returns 204 success.) But this is for pedagogic purposes, and the 200 test is perfect, don't change it.

Broader observation: showing that "stuff which can go wrong after GET" happens within the try is pretty interesting. There is a small amount of "ceremony" to access the result. If this were a production library, rather than pedagogic, I'd be wondering if we could maybe save clients from looking for 200 status at all, and just raise appropriate errors if client is asking for bad data.

This would probably need an url_prefix_to_api_name registry map, so we could identify a given url as being a Joke url, and could look for a class with the conventional name JokeApiError. (Clearly the registry would never contain Norris, because: No statement can catch the ChuckNorrisException.)

The next few lines have boilerplate checks that maybe could be defaulted, and / or moved into some JokeApi method a user could choose to supply.

The raise in this line:

    raise Return(data['value']['joke'])

is, ummm, slightly surprising. I'm sure you have your technical reasons for it. But maybe it could be a return, or a yield, or simply a method call with side effects? Or a .close()-style cleanup handler of a with resource manager? Or simply buried in library code that developers typically won't view? The raise is just a little jarring, given that this is the success case.

I encourage you to keep pushing on this. Two ways to win:

  1. Write something a little bigger than just joke access, maybe do some CRUD.
  2. Convince one other developer to use your stuff.

Both will push your code in the direction of increased maturity, increased readiness for a 1.0 release.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.