Skip to content

Support async cancellations - #719

Closed
karpetrosyan wants to merge 17 commits into
encode:masterfrom
karpetrosyan:support-async-cancellations
Closed

Support async cancellations#719
karpetrosyan wants to merge 17 commits into
encode:masterfrom
karpetrosyan:support-async-cancellations

Conversation

@karpetrosyan

@karpetrosyankarpetrosyan commented Jun 12, 2023

Copy link
Copy Markdown
Contributor

Problem

Cancellations in the middle of a request/response cycle are not supported by the httpcore.

Something like this should brake the connection pool

importanyioimporthttpcoreasyncdefmain():
pool=httpcore.AsyncConnectionPool()
withanyio.move_on_after(1):
awaitpool.request(
"GET", "http://example.com"
) # long running requestprint(pool.connections) # One active connection that will remain active indefinitely# [<AsyncHTTPConnection ['http://example.com:80', HTTP/1.1, ACTIVE, Request Count: 1]>]anyio.run(main)

TODO

  • Write a failing test
  • Make a test pass

@karpetrosyankarpetrosyan mentioned this pull request Jun 12, 2023
@karpetrosyan
karpetrosyanforce-pushed the support-async-cancellations branch from 001de05 to dceaaf6CompareJune 12, 2023 12:40
@karpetrosyankarpetrosyan added the enhancement New feature or request label Jun 12, 2023
Comment threadtests/test_cancecllations.py Outdated
await pool.request(
"GET", "http://example.com"
)
assert not pool.connections

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is quite correct.
The behaviour we'd want to see here is that there is a connection in the pool, but it is IDLE.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it and can't remember when, but I remember thinking that this is much safer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, there's some complexities here... 🤔

Either way, this test case is a great starting point.

Comment threadtests/test_cancecllations.py Outdated
Comment threadtests/test_cancecllations.py Outdated
Comment threadtests/test_cancecllations.py Outdated
Comment threadtests/test_cancecllations.py Outdated
@karpetrosyan

Copy link
Copy Markdown
ContributorAuthor

First, we must decide how we will support "CancelScope" while keeping sync and async interfaces similar. I'm not sure whether it's better to do it in another PR or in this one.

@lovelydinosaurlovelydinosaur mentioned this pull request Jun 13, 2023
@lovelydinosaur

Copy link
Copy Markdown
Contributor

First, we must decide how we will support "CancelScope" while keeping sync and async interfaces similar.

Okay. Before we get to implementation, could you start off by showing me where we need shielding rather than how we're going to implement it?

In order to have confidence that we're really getting it right I'd be interested in looking at that from the ground up...

Suppose we're just working directly with an HTTPConnection instance directly, rather than with a connection pool. Where would we need cancellation shielding?

@karpetrosyan

Copy link
Copy Markdown
ContributorAuthor

asyncdef_response_closed(self) ->None:
asyncwithself._state_lock:
if (
self._h11_state.our_stateish11.DONE
andself._h11_state.their_stateish11.DONE
):
self._state=HTTPConnectionState.IDLE
self._h11_state.start_next_cycle()
ifself._keepalive_expiryisnotNone:
now=time.monotonic()
self._expire_at=now+self._keepalive_expiry
else:
awaitself.aclose()

This function appears to be the main source of the problem; the issue here is that if we call this method while cancelled, the resources will not be closed.

@lovelydinosaur

lovelydinosaur commented Jun 14, 2023

Copy link
Copy Markdown
Contributor

Perhaps we should add a test case at that level of the API?

(Resolve cancellation shielding for an individual connection, and be confident we've got that sorted before moving up the stack?)

@karpetrosyan

Copy link
Copy Markdown
ContributorAuthor

Sounds good

Comment threadtests/test_cancellations.py Outdated
Comment threadtests/test_cancellations.py Outdated
Comment threadtests/test_cancellations.py Outdated
Comment threadtests/test_cancellations.py Outdated
Comment on lines +40 to +57
@pytest.mark.anyio
async def test_h11_response_closed():
origin = httpcore.Origin(b"http", b"example.com", 80)
stream = SlowStream()
async with httpcore.AsyncHTTP11Connection(origin, stream) as conn:
with anyio.move_on_after(0.001):
await conn.request("GET", "http://example.com")
assert conn.is_closed()


@pytest.mark.anyio
async def test_h2_response_closed():
origin = httpcore.Origin(b"http", b"example.com", 80)
stream = SlowStream()
async with httpcore.AsyncHTTP2Connection(origin, stream) as conn:
with anyio.move_on_after(0.001):
await conn.request("GET", "http://example.com")
assert conn.is_closed()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay these are looking great - good starting point for us working through this really comprehensively.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're done with failing tests, I believe we should decide how and where we want to see shield logic.

@lovelydinosaur

lovelydinosaur commented Jun 15, 2023

Copy link
Copy Markdown
Contributor

Okay, so based on the HTTP/1.1 test case I've got a nice clear example of the existing behaviour...

Here's the behaviour we see when we get a network error during a write operation on an HTTP/1.1 connection:

importtrioimporthttpcoreclassSlowStream(httpcore.AsyncNetworkStream):
asyncdefwrite(self, buffer, timeout=None):
raisehttpcore.WriteError()
asyncdefaclose(self):
passasyncdefmain():
origin=httpcore.Origin(b"http", b"example.com", 80)
stream=SlowStream()
asyncwithhttpcore.AsyncHTTP11Connection(origin, stream) asconn:
try:
awaitconn.request("GET", "http://example.com")
excepthttpcore.NetworkError:
passprint(conn)
# <AsyncHTTP11Connection ['http://example.com:80', CLOSED, Request Count: 1]>

In contrast, here's the behaviour we see when we get a cancellation exception during a write operation on an HTTP/1.1 connection:

importtrioimporthttpcoreclassSlowStream(httpcore.AsyncNetworkStream):
asyncdefwrite(self, buffer, timeout=None):
awaittrio.sleep(1)
asyncdefaclose(self):
passasyncdefmain():
origin=httpcore.Origin(b"http", b"example.com", 80)
stream=SlowStream()
asyncwithhttpcore.AsyncHTTP11Connection(origin, stream) asconn:
withtrio.move_on_after(0.001):
awaitconn.request("GET", "http://example.com")
print(conn)
# <AsyncHTTP11Connection ['http://example.com:80', ACTIVE, Request Count: 1]>trio.run(main)

@lovelydinosaur

lovelydinosaur commented Jun 15, 2023

Copy link
Copy Markdown
Contributor

Starting with the HTTP/1.1 case it looks to me like we want...

  • Two test cases - the existing one that we currently have for a timeout during writing, and an additional one for a timeout during reading.
  • Shielding semantics around our exception handling in the handle_async_request method...
exceptBaseExceptionasexc:
withShieldFromCancellation():
asyncwithTrace("response_closed", logger, request) astrace:
awaitself._response_closed()
raiseexc
  • Shielding semantics around our exception handling in the byte stream __aiter__ method...
exceptBaseExceptionasexc:
# If we get an exception while streaming the response,# we want to close the response (and possibly the connection)# before raising that exception.withShieldFromCancellation():
awaitself.aclose()
raiseexc

I'm not presuming how we implement the ShieldFromCancellation here, just presenting what I think the semantics ought to look like.

(Based on trio's design in this problem space. Because structured concurrency FTW.)

@karpetrosyan

Copy link
Copy Markdown
ContributorAuthor

To me, it appears to be completely correct.

@karpetrosyan

karpetrosyan commented Jun 15, 2023

Copy link
Copy Markdown
ContributorAuthor

Two test cases - the existing one that we currently have for a timeout during writing, and an additional one for a timeout during reading.

Are you referring to slow-write test for ConnectionPool or HTTP11Connection?

@lovelydinosaur

lovelydinosaur commented Jun 15, 2023

Copy link
Copy Markdown
Contributor

Are you referring to slow-write test for ConnectionPool or HTTP11Connection?

I'm suggesting that we'll also want slow-read tests.

Here's how that'd look for the HTTP/1.1 case...

classSlowReadStream(httpcore.AsyncNetworkStream):
def__init__(self, buffer):
self.buffer=bufferasyncdefwrite(self, buffer, timeout=None):
passasyncdefread(self, max_bytes: int, timeout: typing.Optional[float] =None) ->bytes:
ifnotself._buffer:
awaittrio.sleep(1)
else:
returnself._buffer.pop(0)
asyncdefaclose(self):
passstream=SlowReadStream([
b"HTTP/1.1 200 OK\r\n",
b"Content-Type: plain/text\r\n",
b"Content-Length: 1000\r\n",
b"\r\n",
b"Hello, world!...", # The stream will hang after this portion of the incomplete response,# so that we're able to test our async cancellation semantics.
])

@karpetrosyan

Copy link
Copy Markdown
ContributorAuthor

Is a buffer really necessary?

@lovelydinosaur

Copy link
Copy Markdown
Contributor

Is a buffer really necessary?

You'd need this or something like this in order to test the cancellation handling for HTTP11ConnectionByteStream.__aiter__.

@lovelydinosaurlovelydinosaur mentioned this pull request Jun 15, 2023
5 tasks
@lovelydinosaur

Copy link
Copy Markdown
Contributor

Okay, want to try...

Comment threadtests/test_cancellations.py Outdated
@lovelydinosaur

Copy link
Copy Markdown
Contributor

Thanks so much for your work on this.
I've started pulling it into #726, so I think we can close this one as being superseeded at this point.

@karpetrosyan
karpetrosyan deleted the support-async-cancellations branch July 3, 2023 10:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew feature or request

Development

Successfully merging this pull request may close these issues.

3 participants

@karpetrosyan@lovelydinosaur@Pliner