uplink icon indicating copy to clipboard operation
uplink copied to clipboard

Unexpected behavior of async `response_handler` callback

Open returnnullptr opened this issue 3 years ago • 9 comments

Describe the bug

Changes made in #248 break the async response_handler callback behavior.

To Reproduce

Define asynchronous response handler:

@uplink.response_handler
async def custom_response_handler(response):
    return "result"

Define consumer method decorated this way:

class ExampleConsumer(uplink.Consumer):
    @custom_response_handler
    @uplink.get("/")
    def consumer_method(self):
        pass

Call the method:

async def main(base_url: str):
    async with aiohttp.ClientSession() as session:
        example_consumer = ExampleConsumer(
            base_url=base_url,
            client=uplink.AiohttpClient(session),
        )

        # expected: "result", actual: <coroutine object custom_response_handler at 0x7f7c7ccbc8c0>
        # In the `0.9.5`, asynchronous callbacks worked as expected
        result = await example_consumer.consumer_method()

        # double await works, but this is unexpected
        result = await (await example_consumer.consumer_method())

Since the 0.9.6, awaited method call is a coroutine.

Expected behavior

A consumer method decorated this way returns a result using a single await.

Additional context

The aiohttp client is used.

returnnullptr avatar Feb 15 '22 10:02 returnnullptr

Hey, @returnnullptr - thanks for reporting! I think you caught something deeper. #258 should fix this.

prkumar avatar Feb 20 '22 09:02 prkumar

This also seems to have broken my app. I find that objects I await return a co-routine object and have to be awaited again....

HarvsG avatar Mar 08 '22 23:03 HarvsG

Hey, @HarvsG - Got it. The fix will be part of v0.9.7, which I'll be releasing this week. ETA is by March 11th

prkumar avatar Mar 08 '22 23:03 prkumar

Great, my app actually targets uplink 0.9.4 but is is used within Homeassisstant which seems to be forcing it to use a newer version with the bug.

Edit: In the meantime I have fixed the issue by ssh-ing into my machine, then running pip install uplink==0.9.5 in the relevant container.

HarvsG avatar Mar 08 '22 23:03 HarvsG

v0.9.7 is live now and includes the fix for this issue

prkumar avatar Mar 12 '22 21:03 prkumar

hmm, still getting the same error with 0.9.7

HarvsG avatar Mar 13 '22 13:03 HarvsG

@HarvsG - Interesting.. I might have missed something, or you might be running into a similar issue with a different cause. Could you check out this test case and let me know if it captures your usage? (i.e., invoking a method wrapped by response handler with an async http client)

prkumar avatar Mar 13 '22 18:03 prkumar

This might be a different issue altogether, in which case I can file a separate ticket, but we've found a similar issue with the usage of the aiohttp client and uplink >= 0.9.6.

In our system, we enqueue many (tens of thousands) requests, and execute them with a concurrency of, say, 100. For example:

 client = AiohttpClient(aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=100),
                                              timeout=aiohttp.ClientTimeout(total=600)))

In short, we've noticed that when using an aiohttp client in conjunction with the @retry decorator, after the first retry event, performance slows to a trickle (multiple minutes between requests), and eventually seemingly stops.

We've added logging and found that these retried requests don't spend that time waiting for a connection or anything; once the on_request_start event is fired for the retried request, they immediately get a connection and execute the request, it just takes them forever to get requeued and for the on_request_start event to fire.

When using the aiohttp client with uplink 0.9.5, the retry logic works as expected and is fully performant even in the case of occasional retries due to server/network errors.

I'm not sure if this is any relation to the response_handler callback issue mentioned here, but the timing and relation to aiohttp made us think this could be related.

jamur2 avatar Sep 27 '22 20:09 jamur2

Using converters with request handlers seems to be another problem. I used the simple test case and rewritten something like this to reproduce the issue:

import asyncio

import pydantic
import uplink
import uplink.converters.pydantic_  # noqa
from uplink.clients.aiohttp_ import AiohttpClient

class MyModel(pydantic.BaseModel):
    something: str

@uplink.response_handler
async def simple_async_handler(response):
    return {"something": "somestr"}

class Calendar(uplink.Consumer):
    @simple_async_handler
    @uplink.get("todos/{todo_id}")
    def get_todo(self, todo_id) -> MyModel:
        pass

async def main():
    calendar = Calendar(base_url="https://example.com/", client=AiohttpClient())
    # Run
    response = await calendar.get_todo(todo_id=1)

asyncio.run(main())

Running this results in:

pydantic_core._pydantic_core.ValidationError: 1 validation error for mymodel
  Input should be a valid dictionary or instance of mymodel [type=model_type, input_value=<coroutine object simple_...r at 0x000001D09BE2B700>, input_type=coroutine]

I tracked it down to here:

Line 102 of uplink.hooks.TransactionHookChain.handle_response

    def handle_response(self, consumer, response):
        for hook in self._response_handlers:
            response = hook.handle_response(consumer, response)
        return response

hook.handle_response is simple_async_handler in this case, which itself is a coroutine, yet it isn't awaited. Therefore response becomes a coroutine object.

Maybe this can be fixed by introducing an async version of the Converter class, that enables us to await inside self.convert method

speksen-kb01 avatar Jan 30 '24 10:01 speksen-kb01