Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue warning on unclosed AsyncClient. #1197

Conversation

cdeler
Copy link
Member

@cdeler cdeler commented Aug 19, 2020

As a part of #871 it has been decided to prevent httpx.Client and httpx.AsyncClient from sending requests after being closed.

Closes #871

Copy link
Member

@tomchristie tomchristie left a comment

Choose a reason for hiding this comment

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

Thanks! ✨😀

Seems simple on the surface, but there's a few bits & pieces to work through.

In particular I'd prefer we don't use the decorator style, and prefer a plain approach instead.
Direct, simple, easier to reason through, neater tracebacks.
I've included some comments to help work through what I think could be tweaked.

httpx/_client.py Outdated
@@ -56,6 +56,18 @@
KEEPALIVE_EXPIRY = 5.0


def check_not_closed(method: typing.Callable) -> typing.Callable:
Copy link
Member

Choose a reason for hiding this comment

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

I'd prefer for us not to use a decorator style.
I'll use inline comments below to walk through the alternative...

Copy link
Member Author

Choose a reason for hiding this comment

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

I've removed the decorator. Simple is better than complex.

httpx/_client.py Outdated
@@ -619,6 +639,7 @@ def _transport_for_url(self, url: URL) -> httpcore.SyncHTTPTransport:

return self._transport

@check_not_closed
Copy link
Member

Choose a reason for hiding this comment

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

I guess we should instead put the check around def send() rather than def request().

Copy link
Member

Choose a reason for hiding this comment

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

Rather than the decorator, I'd prefer the plainer style of starting the method with...

if self._is_closed:
   raise RuntimeError("Cannot send requests on a closed client instance.")

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess we should instead put the check around def send() rather than def request().

Done

Rather than the decorator, I'd prefer the plainer style of starting the method with...

Done, but I used self.is_closed in the condition

httpx/_client.py Outdated
@@ -1015,6 +1036,7 @@ def delete(
timeout=timeout,
)

@check_not_closed
Copy link
Member

Choose a reason for hiding this comment

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

Could we instead just let things pass silently if the client is already closed, and .close() is called again?
So...

if not self._is_closed:
    self._transport.close()
    for proxy in self._proxies.values():
        if proxy is not None:
            proxy.close()

httpx/_client.py Outdated
@@ -1628,6 +1654,8 @@ async def aclose(self) -> None:
if proxy is not None:
await proxy.aclose()

self._is_closed = True
Copy link
Member

Choose a reason for hiding this comment

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

We should put self._is_closed = True as the very first line, rather than the very last line, since that'd handle potential race conditions more cleanly.

Copy link
Member Author

Choose a reason for hiding this comment

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

@tomchristie It a bit confuses me, since there should be another ways to avoid race conditions, e.g. locks.

Copy link
Member Author

@cdeler cdeler Aug 19, 2020

Choose a reason for hiding this comment

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

I saw several same places where race condition is possible

I can try to write a test, which strictly cause a race condition here (if it's possible)

@cdeler cdeler force-pushed the prevent_async_client_from_sending_requests_when_its_closed branch from 350ae77 to 5e2197c Compare August 19, 2020 11:23
@cdeler
Copy link
Member Author

cdeler commented Aug 19, 2020

@tomchristie

I wrote the test, which shows the race condition. I can fix it (but I'd like to do it in separate PR since it involves things such as threading.Lock().

Here is the test:

class SlowTestTransport(httpcore.SyncHTTPTransport):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.closing_counter = 0

    def request(
        self,
        method: bytes,
        url: URL,
        headers: Headers = None,
        stream: SyncByteStream = None,
        timeout: TimeoutDict = None,
    ) -> Tuple[bytes, int, bytes, List[Tuple[bytes, bytes]], SyncByteStream]:
        return b"", 0, b"", [(b"", b"")], ByteStream(b"Hello")

    def close(self) -> None:
        self.closing_counter += 1


class SlowClient(httpx.Client):
    @property
    def is_closed(self) -> bool:
        result = super().is_closed

        time.sleep(0.5)

        return result


def test_that_client_handles_race_condition_while_being_closed():
    slow_transport = SlowTestTransport()
    client = SlowClient(transport=slow_transport)

    futures = []
    with ThreadPoolExecutor(max_workers=2) as executor:
        futures.append(executor.submit(client.close))
        futures.append(executor.submit(client.close))

    for _ in concurrent.futures.as_completed(futures):
        pass

    assert slow_transport.closing_counter == 1

@tomchristie
Copy link
Member

Let's not get into that level of testing at the moment. Yes you can potentially do silly things if you're concurrently closing clients and issuing requests at the same time, but we don't necessarily need to protect users from themselves at quite that level of granularity.

So, working through this has actually made it more clear to me exactly what behaviour we do want here...

  • Clients should start in ._is_closed = True
  • Calling into .send() or calling into either __enter__/__aenter__ should set ._is_closed = False
  • Calling into .close()/.aclose() or calling into either __exit__/__aexit__ should set ._is_closed = True
  • The Client.__del__ method should check ._is_closed and call .close() if needed.
  • The AsyncClient.__del__ method should check ._is_closed and warn if needed.
  • Making a request with a closed client is fine. It just implicitly opens the client.

That's because we don't actually want simply importing this...

client = httpx.AsyncClient()

To raise a warning. Which if we treat the client as open simply by instantiating it, we would.

The client is only opened once you're either using...

  • async with client: - Enter the context on an existing instance.
  • async with httpx.AsyncClient() as client: - Enter the context, creating an instance.
  • await client.request() - Implicitly opening the client, by issuing a request. Not recommended for async, where we advise using the client in a context manager, but perfectly common with httpx.Client().

@cdeler
Copy link
Member Author

cdeler commented Aug 19, 2020

So, working through this has actually made it more clear to me exactly what behaviour we do want here...
...

Nice, then I'm going to implement that (in this PR)

@cdeler cdeler force-pushed the prevent_async_client_from_sending_requests_when_its_closed branch from e56a711 to 7eff422 Compare August 20, 2020 09:57
@cdeler
Copy link
Member Author

cdeler commented Aug 20, 2020

@tomchristie I've done with the changes you mentioned.

Calling into .close()/.aclose() or calling into either exit/aexit should set ._is_closed = True

I prevent the clients from being double-closed:

class AsyncClient(BaseClient):
    # ...
    async def aclose(self) -> None:
        """
        Close transport and proxies.
        """
        if not self.is_closed:
            self._is_closed = True

            await self._transport.aclose()
            for proxy in self._proxies.values():
                if proxy is not None:
                    await proxy.aclose()

Am I right, or I should remove this check?

@tomchristie tomchristie changed the title Prevent Client and AsyncClient from sending any requests when they have been closed (#871) Issue warning on unclosed AsyncClient. Aug 20, 2020
@tomchristie tomchristie added the user-experience Ensuring that users have a good experience using the library label Aug 20, 2020
@tomchristie tomchristie modified the milestone: v1.0 Aug 20, 2020
@tomchristie
Copy link
Member

So I tried this locally, and it took me a while to figure out that I wasn't seeing anything because ResourceWarning is ignored by default. I'm not sure what we think about that?

We could issue it as a UserWarning instead, perhaps?
Or a custom subclass?
Or perhaps it's okay as a mostly silent ResourceWarning and we're okay with that?

@cdeler
Copy link
Member Author

cdeler commented Aug 20, 2020

@tomchristie yes, I didn't pay attention to the fact, that ResourceWarning is silent by default. May be the RuntimeWarning (it's visible)?

exception RuntimeWarning¶
Base class for warnings about dubious runtime behavior.

(c) python language reference (link)

Update
The requests triggers ResourceWarning in this case, for example requests/api.py#L58

"\t>>> async with httpx.AsyncClient() as client:\n"
"\t>>> ...",
ResourceWarning,
)
Copy link
Member

Choose a reason for hiding this comment

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

Right, I'm going to suggest that we use UserWarning for this case, for better visibility.

We'll eventually end up tracking individual connections with proper a ResourceWarning against each of those, but this is a good case for a higher-level warning I think.

Also, let's make sure to use a more concise warning format.

Perhaps...

warnings.warn("Unclosed {self!r}. See https://www.python-httpx.org/async/#opening-and-closing-clients for details.")

Then we can add a little more context in the documentation.

What do we think?

Copy link
Member Author

@cdeler cdeler Aug 21, 2020

Choose a reason for hiding this comment

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

Developing libraries/SDKs, I try to avoid to use such warnings as UserWarning. This warning is for users' applications, not for a middle layer, presented by the SDK. Users should be able to separate their warning and httpx warnings.

But I definitely agree with you, that ResourceWarning (unfortunately) is too silent for this case.

Copy link
Member Author

@cdeler cdeler Aug 21, 2020

Choose a reason for hiding this comment

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

@tomchristie
I changed the warning type to UserWarning, then I have to fix a lot of warning in the project tests (it's a separated commit)

@cdeler cdeler force-pushed the prevent_async_client_from_sending_requests_when_its_closed branch 3 times, most recently from 458e6c9 to 9f88f8f Compare August 21, 2020 14:37
@tomchristie
Copy link
Member

@cdeler - Thanks for all your fantastic work on this.

I think it probably is worth issuing a PR just dealing with the "close async clients properly in our test cases" first, since we can pull that one into the master branch, wheras this one we need to keep holding onto until we're ready to issue a new major release. Doing it that way around would help us avoid possibly messy merge conflicts, and would give us two independently reviewable PRs.

@cdeler
Copy link
Member Author

cdeler commented Aug 26, 2020

I think it probably is worth issuing a PR just dealing with the "close async clients properly in our test cases" first

Yes, sure, I'm to create this "close async clients properly in our test cases" PR today

Update: I created the PR #1219

@cdeler cdeler force-pushed the prevent_async_client_from_sending_requests_when_its_closed branch from e898c07 to 8d85e87 Compare August 31, 2020 15:46
@cdeler
Copy link
Member Author

cdeler commented Aug 31, 2020

@tomchristie

According to your comment, I rebased this branch to the master after #1219 .

The only thing which is not done is

Tackle any cleanup in test cases, and always prefer just using httpx.Client unless there's a good reason.

@cdeler cdeler requested a review from tomchristie September 2, 2020 09:15
@tomchristie tomchristie mentioned this pull request Sep 2, 2020
Copy link
Member

@tomchristie tomchristie left a comment

Choose a reason for hiding this comment

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

Great stuff, let's get this in now! 👍

@tomchristie tomchristie merged commit def9f1c into encode:master Sep 2, 2020
@cdeler cdeler deleted the prevent_async_client_from_sending_requests_when_its_closed branch September 4, 2020 18:12
@tomchristie tomchristie mentioned this pull request Sep 21, 2020
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
user-experience Ensuring that users have a good experience using the library
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add warning about unclosed AsyncClient
2 participants