Asyncio Sleep Does not seem to Satisfy Rate Limits for certain status codes

Viewed 87

I am working with an API that limits to 4 requests/s. I am using asyncio and aiohttp to make asynchronous http requests. I am using Windows.

When working with the API I receive 3 status codes most commonly, 200, 400 & 429.

The 4/s issue works entirely fine when seeing many 400s but soon as I receive a 200 and try to resolve the json using response.json I begin to receive 429s for too many requests. I am trying to understand why something like this would occur.

EDIT: After doing some logging, I am seeing that the 429s appear to creep up after I have a response that takes longer than a second to complete (in the case of a 200, a large JSON response might take a bit of time to resolve). It appears that after a > 1s request elapsed time request occurs, followed by a fast one (which takes a few ms) the requests sort of "jump" ahead too quickly and overwhelm the API with more than 4 requests resolving in a second.

I am utilizing a semaphore of size 3 (trying 4 hits 429 way more often). The workflow is generally:

1. Create event loop
2. Gather tasks
3. Create http session and begin async requests with our Semaphore.
4. _fetch() is handling the specific asynchronous requests.

I am trying to understand why that when I receive 200s (which requires some JSON serialization and likely adds some latency). If I am always awaiting a sleep call of 1.5 seconds per call, why am I still able to hit rate limits? Is this fault of the API I am hitting or is there something intrinsically wrong with my async-await calls.

Below is my code:


import asyncio
import aiohttp
import time

class Request:
    def __init__(self, url: str, method: str="get",  payload: str=None):
        self.url: str = url
        self.method: str = method
        self.payload: str or dict = payload or dict()

class Response:
    def __init__(self, url: str, status: int, payload: dict=None, error: bool=False, text: str=None):
        self.url: str = url
        self.status: int = status
        self.payload: dict = payload or dict()
        self.error: bool = error
        self.text: str = text or ''

def make_requests(headers: dict, requests: list[Request]) -> asyncio.AbstractEventLoop:
    """
    requests is a list with data necessary to make requests
    """
    loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
    responses: asyncio.AbstractEventLoop = loop.run_until_complete(_run(headers, requests))
    return responses

async def _run(headers: dict, requests: list[Request]) -> "list[Response]":

    # Create a semaphore to limit how many concurrent thread processes we can run (MAXIMUM) at a time.
    semaphore: asyncio.Semaphore = asyncio.Semaphore(3)
    time.sleep(10) # wait 10 seconds before beginning our async requests
    async with aiohttp.ClientSession(headers=headers) as session:
        tasks: list[asyncio.Task] = [asyncio.create_task(_iterate(semaphore, session, request)) for request in requests]
        responses: list[Response] = await asyncio.gather(*tasks)
    return responses

async def _iterate(semaphore: asyncio.Semaphore, session: aiohttp.ClientSession, request: Request) -> Response:
    async with semaphore:
        return await _fetch(session, request)


async def _fetch(session: aiohttp.ClientSession, request: Request) -> Response:
    try:
        async with session.request(request.method, request.url, params=request.payload) as response:
            print(f"NOW: {time.time()}")
            print(f"Response Status: {response.status}.")
            content: dict = await response.json()
            response.raise_for_status()
            await asyncio.sleep(1.5)
            return Response(request.url, response.status, payload=content, error=False)
    except aiohttp.ClientResponseError:
        if response.status == 429:
            await asyncio.sleep(12) # Back off before proceeding with more requests
            return await _fetch(session, request)
        else:
            await asyncio.sleep(1.5)
            return Response(request.url, response.status, error=True)
1 Answers

The 4/s issue works entirely fine when seeing many 400s, but soon as I receive a 200 and try to resolve the JSON using response.json, I begin to receive 429s for too many requests. I am trying to understand why something like this would occur.

The response status does not depend on how often you call the .json method on responses. The cause can be the security of the server API is running on. At the debugging time, I had to optimize the make_requests to make it more readable.

import asyncio
import aiohttp


class Request:
    def __init__(self, url: str, method: str = "get", payload: str = None):
        self.url: str = url
        self.method: str = method
        self.payload: str or dict = payload or dict()


class Response:
    def __init__(self, url: str, status: int, payload: dict = None, error: bool = False, text: str = None):
        self.url: str = url
        self.status: int = status
        self.payload: dict = payload or dict()
        self.error: bool = error
        self.text: str = text or ''


async def make_requests(headers: dict, requests: "list[Request]"):
    """
    This function makes concurrent requests with a semaphore.

    :param headers: Main HTTP headers to use in the session.
    :param requests: A list of Request objects.
    :return: List of responses converted to Response objects.
    """

    async def make_request(request: Request) -> Response:
        """
        This closure makes limited requests at the time.

        :param request: An instance of Request that describes HTTP request.
        :return: A processed response.
        """

        async with semaphore:
            try:
                response = await session.request(request.method, request.url, params=request.payload)
                content = await response.json()
                response.raise_for_status()
                return Response(request.url, response.status, payload=content, error=False)
            except (aiohttp.ClientResponseError, aiohttp.ContentTypeError, aiohttp.ClientError):
                if response.status == 429:
                    return await make_request(request)
                return Response(request.url, response.status, error=True)

    semaphore = asyncio.Semaphore(3)
    curr_loop = asyncio.get_running_loop()

    async with aiohttp.ClientSession(headers=headers) as session:
        return await asyncio.gather(*[curr_loop.create_task(make_request(request)) for request in requests])


if __name__ == "__main__":
    HEADERS = {
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0"
    }

    REQUESTS = [
        Request("https://www.google.com/search?q=query1"),
        Request("https://www.google.com/search?q=query2"),
        Request("https://www.google.com/search?q=query3"),
        Request("https://www.google.com/search?q=query4"),
        Request("https://www.google.com/search?q=query5"),
    ]

    loop = asyncio.get_event_loop()
    responses = loop.run_until_complete(make_requests(HEADERS, REQUESTS))
    print(responses)  # [<__main__.Response object at 0x7f4f73e5da30>, <__main__.Response object at 0x7f4f73e734f0>, <__main__.Response object at 0x7f4f73e73790>, <__main__.Response object at 0x7f4f73e5d9d0>, <__main__.Response object at 0x7f4f73e73490>]
    loop.close()

If you get 400s after some count of requests, you need to check what headers are sent by the browser that is missed in your request.

I am trying to understand why when I receive 200s (which requires some JSON serialization and it likely adds some latency). If I am always awaiting a sleep call of 1.5 seconds per call, why am I still able to hit rate limits? Is this fault of the API I am hitting, or is there something intrinsically wrong with my async-await calls?

I'm not sure what you meant by saying "to be able to hit rate limits", but asyncio.sleep should work properly. The script makes the first limited count of concurrent requests (in this case, semaphore allows three concurrent tasks) almost at the same time. After a request is received, it waits for 1.5 sec concurrently and returns the result of the task. The key is concurrency. If you wait with asyncio.sleep for 1.5 sec in 3 different tasks, it will wait 1.5 sec but not 4.5. If you wanted to set delays between requests, you could wait before or after calling the create_task.

Related