From 825e7e3c6c56843a17abf08002bcbed97f8de229 Mon Sep 17 00:00:00 2001 From: cono Date: Thu, 23 Nov 2023 12:07:14 +0200 Subject: [PATCH 01/21] Fix bug with async socks proxy and tracing. (#849) * Fix bug with async socks proxy and tracing. * Add Changelog and change version. * Update CHANGELOG.md * Update httpcore/__init__.py * Update CHANGELOG.md --------- Co-authored-by: cono Co-authored-by: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> Co-authored-by: Tom Christie --- CHANGELOG.md | 6 +++++- httpcore/_async/socks_proxy.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de62ceea..d42bc585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## 1.0.2 (November 10th, 2023) +## Unreleased + +- Fix trace extension when used with socks proxy. (#849) + +## 1.0.2 (November 10th, 2023) - Fix `float("inf")` timeouts in `Event.wait` function. (#846) diff --git a/httpcore/_async/socks_proxy.py b/httpcore/_async/socks_proxy.py index 08a065d6..f839603f 100644 --- a/httpcore/_async/socks_proxy.py +++ b/httpcore/_async/socks_proxy.py @@ -228,7 +228,7 @@ async def handle_async_request(self, request: Request) -> Response: "port": self._proxy_origin.port, "timeout": timeout, } - with Trace("connect_tcp", logger, request, kwargs) as trace: + async with Trace("connect_tcp", logger, request, kwargs) as trace: stream = await self._network_backend.connect_tcp(**kwargs) trace.return_value = stream @@ -239,7 +239,7 @@ async def handle_async_request(self, request: Request) -> Response: "port": self._remote_origin.port, "auth": self._proxy_auth, } - with Trace( + async with Trace( "setup_socks5_connection", logger, request, kwargs ) as trace: await _init_socks5_connection(**kwargs) From e5ef2643e909e51851bb5a3f5227e963b837975c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:37:19 +0400 Subject: [PATCH 02/21] Bump coverage[toml] from 7.3.0 to 7.3.2 (#854) Bumps [coverage[toml]](https://github.com/nedbat/coveragepy) from 7.3.0 to 7.3.2. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.3.0...7.3.2) --- updated-dependencies: - dependency-name: coverage[toml] dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 74daf197..673b8bdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ twine # Tests & Linting black==23.10.1 -coverage[toml]==7.3.0 +coverage[toml]==7.3.2 ruff==0.1.3 mypy==1.5.1 trio-typing==0.9.0 From 632e2d2652117e0ddb1cfe3c4dde07c76cbb7f76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:47:25 +0000 Subject: [PATCH 03/21] Bump mypy from 1.5.1 to 1.7.1 (#851) Bumps [mypy](https://github.com/python/mypy) from 1.5.1 to 1.7.1. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.5.1...v1.7.1) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 673b8bdd..bc6e9c23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ twine black==23.10.1 coverage[toml]==7.3.2 ruff==0.1.3 -mypy==1.5.1 +mypy==1.7.1 trio-typing==0.9.0 types-certifi==2021.10.8.3 pytest==7.4.3 From a0d5440084bbb1de0ce831e543b8971e4b8184ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 13:08:29 +0000 Subject: [PATCH 04/21] Bump mkdocs-material from 9.4.7 to 9.4.14 (#853) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.4.7 to 9.4.14. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.4.7...9.4.14) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tom Christie --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bc6e9c23..db02ab2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # Docs mkdocs==1.5.3 mkdocs-autorefs==0.5.0 -mkdocs-material==9.4.7 +mkdocs-material==9.4.14 mkdocs-material-extensions==1.3 mkdocstrings[python-legacy]==0.22.0 jinja2==3.1.2 From ebd3b65eb6fdd5d2c4357161d8f7e0c8578517cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 13:14:49 +0000 Subject: [PATCH 05/21] Bump black from 23.10.1 to 23.11.0 (#852) Bumps [black](https://github.com/psf/black) from 23.10.1 to 23.11.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.10.1...23.11.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index db02ab2c..1a625f26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ build==1.0.3 twine # Tests & Linting -black==23.10.1 +black==23.11.0 coverage[toml]==7.3.2 ruff==0.1.3 mypy==1.7.1 From ef610e26b554382bfa3404cbc37a623d0d1bb278 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:22:14 -0800 Subject: [PATCH 06/21] Bump mkdocstrings[python-legacy] from 0.22.0 to 0.24.0 (#850) Bumps [mkdocstrings[python-legacy]](https://github.com/mkdocstrings/mkdocstrings) from 0.22.0 to 0.24.0. - [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases) - [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/0.22.0...0.24.0) --- updated-dependencies: - dependency-name: mkdocstrings[python-legacy] dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tom Christie --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1a625f26..fc973502 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ mkdocs==1.5.3 mkdocs-autorefs==0.5.0 mkdocs-material==9.4.14 mkdocs-material-extensions==1.3 -mkdocstrings[python-legacy]==0.22.0 +mkdocstrings[python-legacy]==0.24.0 jinja2==3.1.2 # Packaging From 6633b9fe7c447fa65f04d233e6d8d9940aea0112 Mon Sep 17 00:00:00 2001 From: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> Date: Mon, 4 Dec 2023 08:01:54 -0500 Subject: [PATCH 07/21] Use ruff format (#831) * Use ruff format instead of black * Update scripts/check * Keep the 120 line limit * Simplify ruff format * Update scripts/lint * Update pyproject.toml Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com> * Revert "Update pyproject.toml" This reverts commit fd0ef87db200c634aa5639cb53ad8499eef6d71b. --------- Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com> --- pyproject.toml | 5 ++++- requirements.txt | 1 - scripts/check | 2 +- scripts/lint | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 719fb180..8e7ccf50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ exclude = [ "httpcore/_sync", "tests/_sync", ] -line-length = 120 +line-length = 88 select = [ "E", "F", @@ -114,5 +114,8 @@ select = [ "I" ] +[tool.ruff.pycodestyle] +max-line-length = 120 + [tool.ruff.isort] combine-as-imports = true diff --git a/requirements.txt b/requirements.txt index fc973502..64aca0be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,6 @@ build==1.0.3 twine # Tests & Linting -black==23.11.0 coverage[toml]==7.3.2 ruff==0.1.3 mypy==1.7.1 diff --git a/scripts/check b/scripts/check index cf0dba53..02ed35c0 100755 --- a/scripts/check +++ b/scripts/check @@ -9,6 +9,6 @@ export SOURCE_FILES="httpcore tests" set -x ${PREFIX}ruff check --show-source $SOURCE_FILES -${PREFIX}black --exclude '/(_sync|sync_tests)/' --check --diff $SOURCE_FILES +${PREFIX}ruff format $SOURCE_FILES --diff ${PREFIX}mypy $SOURCE_FILES scripts/unasync --check diff --git a/scripts/lint b/scripts/lint index 22a40d75..ec38987d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -9,7 +9,7 @@ export SOURCE_FILES="httpcore tests" set -x ${PREFIX}ruff --fix $SOURCE_FILES -${PREFIX}black --exclude '/(_sync|sync_tests)/' $SOURCE_FILES +${PREFIX}ruff format $SOURCE_FILES # Run unasync last because its `--check` mode is not aware of code formatters. # (This means sync code isn't prettified, and that's mostly okay.) From 2fcd062df71555cc7de55774c6dc137551eb8692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Dec 2023 23:56:20 -1000 Subject: [PATCH 08/21] Remove expensive run time type casting (#856) * Guard casting type subscripting with TYPE_CHECKING * lint * lint * lint * lint * lint * lint * lint * Adjust to use ignore instead * Adjust to use ignore instead --- httpcore/_async/http11.py | 3 +-- httpcore/_sync/http11.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/httpcore/_async/http11.py b/httpcore/_async/http11.py index 32fa3a6f..a5eb4808 100644 --- a/httpcore/_async/http11.py +++ b/httpcore/_async/http11.py @@ -10,7 +10,6 @@ Tuple, Type, Union, - cast, ) import h11 @@ -228,7 +227,7 @@ async def _receive_event( self._h11_state.receive_data(data) else: # mypy fails to narrow the type in the above if statement above - return cast(Union[h11.Event, Type[h11.PAUSED]], event) + return event # type: ignore[return-value] async def _response_closed(self) -> None: async with self._state_lock: diff --git a/httpcore/_sync/http11.py b/httpcore/_sync/http11.py index 0cc100e3..e108f88b 100644 --- a/httpcore/_sync/http11.py +++ b/httpcore/_sync/http11.py @@ -10,7 +10,6 @@ Tuple, Type, Union, - cast, ) import h11 @@ -228,7 +227,7 @@ def _receive_event( self._h11_state.receive_data(data) else: # mypy fails to narrow the type in the above if statement above - return cast(Union[h11.Event, Type[h11.PAUSED]], event) + return event # type: ignore[return-value] def _response_closed(self) -> None: with self._state_lock: From 62c72aff0a958ffcef079bed96139c75a656c318 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:17:36 +0400 Subject: [PATCH 09/21] Bump ruff from 0.1.3 to 0.1.9 (#867) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.3 to 0.1.9. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.3...v0.1.9) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 64aca0be..0c74201a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ twine # Tests & Linting coverage[toml]==7.3.2 -ruff==0.1.3 +ruff==0.1.9 mypy==1.7.1 trio-typing==0.9.0 types-certifi==2021.10.8.3 From be5b618f1572ad656be55e1c1f56d9c444427924 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:21:57 +0400 Subject: [PATCH 10/21] Bump trio-typing from 0.9.0 to 0.10.0 (#866) Bumps [trio-typing](https://github.com/python-trio/trio-typing) from 0.9.0 to 0.10.0. - [Commits](https://github.com/python-trio/trio-typing/compare/v0.9.0...v0.10.0) --- updated-dependencies: - dependency-name: trio-typing dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0c74201a..51e63981 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ twine coverage[toml]==7.3.2 ruff==0.1.9 mypy==1.7.1 -trio-typing==0.9.0 +trio-typing==0.10.0 types-certifi==2021.10.8.3 pytest==7.4.3 pytest-httpbin==2.0.0 From a80b994a07e7789a0f0857466094924c51fa103f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:26:53 +0400 Subject: [PATCH 11/21] Bump mkdocs-material-extensions from 1.3 to 1.3.1 (#864) Bumps [mkdocs-material-extensions](https://github.com/facelessuser/mkdocs-material-extensions) from 1.3 to 1.3.1. - [Release notes](https://github.com/facelessuser/mkdocs-material-extensions/releases) - [Changelog](https://github.com/facelessuser/mkdocs-material-extensions/blob/master/changelog.md) - [Commits](https://github.com/facelessuser/mkdocs-material-extensions/compare/1.3...1.3.1) --- updated-dependencies: - dependency-name: mkdocs-material-extensions dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 51e63981..cfebcce5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ mkdocs==1.5.3 mkdocs-autorefs==0.5.0 mkdocs-material==9.4.14 -mkdocs-material-extensions==1.3 +mkdocs-material-extensions==1.3.1 mkdocstrings[python-legacy]==0.24.0 jinja2==3.1.2 From ad1ddcfb94f8304e7b95662a38120df4a2a2ffb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:53:10 +0000 Subject: [PATCH 12/21] Bump mypy from 1.7.1 to 1.8.0 (#863) Bumps [mypy](https://github.com/python/mypy) from 1.7.1 to 1.8.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.7.1...v1.8.0) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfebcce5..c7829207 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ twine # Tests & Linting coverage[toml]==7.3.2 ruff==0.1.9 -mypy==1.7.1 +mypy==1.8.0 trio-typing==0.10.0 types-certifi==2021.10.8.3 pytest==7.4.3 From f60e99bf0e21e4390949fd96899d4029e331b550 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 12:14:30 +0000 Subject: [PATCH 13/21] Update trio requirement from <0.23.0,>=0.22.0 to >=0.22.0,<0.24.0 (#865) Updates the requirements on [trio](https://github.com/python-trio/trio) to permit the latest version. - [Release notes](https://github.com/python-trio/trio/releases) - [Commits](https://github.com/python-trio/trio/compare/v0.22.0...v0.23.2) --- updated-dependencies: - dependency-name: trio dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e7ccf50..a35ad6b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ socks = [ "socksio==1.*", ] trio = [ - "trio>=0.22.0,<0.23.0", + "trio>=0.22.0,<0.24.0", ] asyncio = [ "anyio>=4.0,<5.0", From b2de19e594b0ef89521afaa5e6ea9f33cabf3f67 Mon Sep 17 00:00:00 2001 From: MtkN1 <51289448+MtkN1@users.noreply.github.com> Date: Wed, 10 Jan 2024 00:25:44 +0900 Subject: [PATCH 14/21] Use SSL context for "wss" scheme (#869) * Add failing tests * Use SSL context for "wss" scheme * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: Tom Christie --- CHANGELOG.md | 1 + httpcore/_async/connection.py | 2 +- httpcore/_sync/connection.py | 2 +- tests/_async/test_connection_pool.py | 22 ++++++++++++++++++++++ tests/_sync/test_connection_pool.py | 22 ++++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42bc585..061358f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased - Fix trace extension when used with socks proxy. (#849) +- Fix SSL context for connections using the "wss" scheme (#869) ## 1.0.2 (November 10th, 2023) diff --git a/httpcore/_async/connection.py b/httpcore/_async/connection.py index 45ee22a6..3aeb8ed9 100644 --- a/httpcore/_async/connection.py +++ b/httpcore/_async/connection.py @@ -137,7 +137,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: ) trace.return_value = stream - if self._origin.scheme == b"https": + if self._origin.scheme in (b"https", b"wss"): ssl_context = ( default_ssl_context() if self._ssl_context is None diff --git a/httpcore/_sync/connection.py b/httpcore/_sync/connection.py index 81e4172a..f6b99f1b 100644 --- a/httpcore/_sync/connection.py +++ b/httpcore/_sync/connection.py @@ -137,7 +137,7 @@ def _connect(self, request: Request) -> NetworkStream: ) trace.return_value = stream - if self._origin.scheme == b"https": + if self._origin.scheme in (b"https", b"wss"): ssl_context = ( default_ssl_context() if self._ssl_context is None diff --git a/tests/_async/test_connection_pool.py b/tests/_async/test_connection_pool.py index 2392ca17..61ee1e54 100644 --- a/tests/_async/test_connection_pool.py +++ b/tests/_async/test_connection_pool.py @@ -767,6 +767,12 @@ async def test_http11_upgrade_connection(): b"...", ] ) + + called = [] + + async def trace(name, kwargs): + called.append(name) + async with httpcore.AsyncConnectionPool( network_backend=network_backend, max_connections=1 ) as pool: @@ -774,8 +780,24 @@ async def test_http11_upgrade_connection(): "GET", "wss://example.com/", headers={"Connection": "upgrade", "Upgrade": "custom"}, + extensions={"trace": trace}, ) as response: assert response.status == 101 network_stream = response.extensions["network_stream"] content = await network_stream.read(max_bytes=1024) assert content == b"..." + + assert called == [ + "connection.connect_tcp.started", + "connection.connect_tcp.complete", + "connection.start_tls.started", + "connection.start_tls.complete", + "http11.send_request_headers.started", + "http11.send_request_headers.complete", + "http11.send_request_body.started", + "http11.send_request_body.complete", + "http11.receive_response_headers.started", + "http11.receive_response_headers.complete", + "http11.response_closed.started", + "http11.response_closed.complete", + ] diff --git a/tests/_sync/test_connection_pool.py b/tests/_sync/test_connection_pool.py index 287c2bcc..c9621c7b 100644 --- a/tests/_sync/test_connection_pool.py +++ b/tests/_sync/test_connection_pool.py @@ -767,6 +767,12 @@ def test_http11_upgrade_connection(): b"...", ] ) + + called = [] + + def trace(name, kwargs): + called.append(name) + with httpcore.ConnectionPool( network_backend=network_backend, max_connections=1 ) as pool: @@ -774,8 +780,24 @@ def test_http11_upgrade_connection(): "GET", "wss://example.com/", headers={"Connection": "upgrade", "Upgrade": "custom"}, + extensions={"trace": trace}, ) as response: assert response.status == 101 network_stream = response.extensions["network_stream"] content = network_stream.read(max_bytes=1024) assert content == b"..." + + assert called == [ + "connection.connect_tcp.started", + "connection.connect_tcp.complete", + "connection.start_tls.started", + "connection.start_tls.complete", + "http11.send_request_headers.started", + "http11.send_request_headers.complete", + "http11.send_request_body.started", + "http11.send_request_body.complete", + "http11.receive_response_headers.started", + "http11.receive_response_headers.complete", + "http11.response_closed.started", + "http11.response_closed.complete", + ] From 1a14acd7197c855880f80602e6a1301883d4f380 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:22:41 -0600 Subject: [PATCH 15/21] Bump jinja2 from 3.1.2 to 3.1.3 (#870) Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c7829207..b755628e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ mkdocs-autorefs==0.5.0 mkdocs-material==9.4.14 mkdocs-material-extensions==1.3.1 mkdocstrings[python-legacy]==0.24.0 -jinja2==3.1.2 +jinja2==3.1.3 # Packaging build==1.0.3 From 5cffb40b9ca73cc0d8f379971c5a4dfe0c059b04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Feb 2024 13:47:31 +0400 Subject: [PATCH 16/21] Bump pytest from 7.4.3 to 8.0.0 (#876) Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.3 to 8.0.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.4.3...8.0.0) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b755628e..64466d2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ ruff==0.1.9 mypy==1.8.0 trio-typing==0.10.0 types-certifi==2021.10.8.3 -pytest==7.4.3 +pytest==8.0.0 pytest-httpbin==2.0.0 pytest-trio==0.7.0 werkzeug<2.1 # See: https://github.com/postmanlabs/httpbin/issues/673 From 9f822446ed17c61f45656d2282f2f02ba0ef0c81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Feb 2024 14:57:13 +0400 Subject: [PATCH 17/21] Bump coverage[toml] from 7.3.2 to 7.4.1 (#877) Bumps [coverage[toml]](https://github.com/nedbat/coveragepy) from 7.3.2 to 7.4.1. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.3.2...7.4.1) --- updated-dependencies: - dependency-name: coverage[toml] dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 64466d2a..c040ed78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ build==1.0.3 twine # Tests & Linting -coverage[toml]==7.3.2 +coverage[toml]==7.4.1 ruff==0.1.9 mypy==1.8.0 trio-typing==0.10.0 From 416b89c36b2fc860e1644764e2f19a9d124a14a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:18:56 +0100 Subject: [PATCH 18/21] Bump mkdocs-material from 9.4.14 to 9.5.7 (#878) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.4.14 to 9.5.7. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.4.14...9.5.7) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c040ed78..fd7c5e67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # Docs mkdocs==1.5.3 mkdocs-autorefs==0.5.0 -mkdocs-material==9.4.14 +mkdocs-material==9.5.7 mkdocs-material-extensions==1.3.1 mkdocstrings[python-legacy]==0.24.0 jinja2==3.1.3 From 79fa6bf0dfcf3820d1ae7e52a2d268f33022c5a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Feb 2024 22:40:41 +0400 Subject: [PATCH 19/21] Bump ruff from 0.1.9 to 0.2.1 (#881) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.9 to 0.2.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.9...v0.2.1) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fd7c5e67..71cf2164 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ twine # Tests & Linting coverage[toml]==7.4.1 -ruff==0.1.9 +ruff==0.2.1 mypy==1.8.0 trio-typing==0.10.0 types-certifi==2021.10.8.3 From 7b04cda582b32cba34a7bfb0eb1dd535f0ea88a5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 12 Feb 2024 11:20:30 +0000 Subject: [PATCH 20/21] Safe async cancellations. (#880) * Connection pool work * Connection pool work * Connection pool work * Connection pool work * Comments * Comments * Connection pool work * Reraise * Lookin sharp * nocover directive * Safe cancellations * Update CHANGELOG --- CHANGELOG.md | 1 + httpcore/_async/connection.py | 16 +- httpcore/_async/connection_pool.py | 364 ++++++++++++++------------- httpcore/_sync/connection.py | 16 +- httpcore/_sync/connection_pool.py | 364 ++++++++++++++------------- httpcore/_synchronization.py | 58 +++++ requirements.txt | 2 +- tests/_async/test_connection_pool.py | 35 ++- tests/_sync/test_connection_pool.py | 35 ++- 9 files changed, 512 insertions(+), 379 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 061358f4..3d537c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +- Fix support for async cancellations. (#880) - Fix trace extension when used with socks proxy. (#849) - Fix SSL context for connections using the "wss" scheme (#869) diff --git a/httpcore/_async/connection.py b/httpcore/_async/connection.py index 3aeb8ed9..2f439cf0 100644 --- a/httpcore/_async/connection.py +++ b/httpcore/_async/connection.py @@ -6,7 +6,7 @@ from .._backends.auto import AutoBackend from .._backends.base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream -from .._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout +from .._exceptions import ConnectError, ConnectTimeout from .._models import Origin, Request, Response from .._ssl import default_ssl_context from .._synchronization import AsyncLock @@ -70,9 +70,9 @@ async def handle_async_request(self, request: Request) -> Response: f"Attempted to send request to {request.url.origin} on connection to {self._origin}" ) - async with self._request_lock: - if self._connection is None: - try: + try: + async with self._request_lock: + if self._connection is None: stream = await self._connect(request) ssl_object = stream.get_extra_info("ssl_object") @@ -94,11 +94,9 @@ async def handle_async_request(self, request: Request) -> Response: stream=stream, keepalive_expiry=self._keepalive_expiry, ) - except Exception as exc: - self._connect_failed = True - raise exc - elif not self._connection.is_available(): - raise ConnectionNotAvailable() + except BaseException as exc: + self._connect_failed = True + raise exc return await self._connection.handle_async_request(request) diff --git a/httpcore/_async/connection_pool.py b/httpcore/_async/connection_pool.py index 0320c6d8..018b0ba2 100644 --- a/httpcore/_async/connection_pool.py +++ b/httpcore/_async/connection_pool.py @@ -1,31 +1,30 @@ import ssl import sys -import time from types import TracebackType from typing import AsyncIterable, AsyncIterator, Iterable, List, Optional, Type from .._backends.auto import AutoBackend from .._backends.base import SOCKET_OPTION, AsyncNetworkBackend -from .._exceptions import ConnectionNotAvailable, PoolTimeout, UnsupportedProtocol +from .._exceptions import ConnectionNotAvailable, UnsupportedProtocol from .._models import Origin, Request, Response -from .._synchronization import AsyncEvent, AsyncLock, AsyncShieldCancellation +from .._synchronization import AsyncEvent, AsyncShieldCancellation, AsyncThreadLock from .connection import AsyncHTTPConnection from .interfaces import AsyncConnectionInterface, AsyncRequestInterface -class RequestStatus: - def __init__(self, request: Request): +class AsyncPoolRequest: + def __init__(self, request: Request) -> None: self.request = request self.connection: Optional[AsyncConnectionInterface] = None self._connection_acquired = AsyncEvent() - def set_connection(self, connection: AsyncConnectionInterface) -> None: - assert self.connection is None + def assign_to_connection( + self, connection: Optional[AsyncConnectionInterface] + ) -> None: self.connection = connection self._connection_acquired.set() - def unset_connection(self) -> None: - assert self.connection is not None + def clear_connection(self) -> None: self.connection = None self._connection_acquired = AsyncEvent() @@ -37,6 +36,9 @@ async def wait_for_connection( assert self.connection is not None return self.connection + def is_queued(self) -> bool: + return self.connection is None + class AsyncConnectionPool(AsyncRequestInterface): """ @@ -107,14 +109,21 @@ def __init__( self._local_address = local_address self._uds = uds - self._pool: List[AsyncConnectionInterface] = [] - self._requests: List[RequestStatus] = [] - self._pool_lock = AsyncLock() self._network_backend = ( AutoBackend() if network_backend is None else network_backend ) self._socket_options = socket_options + # The mutable state on a connection pool is the queue of incoming requests, + # and the set of connections that are servicing those requests. + self._connections: List[AsyncConnectionInterface] = [] + self._requests: List[AsyncPoolRequest] = [] + + # We only mutate the state of the connection pool within an 'optional_thread_lock' + # context. This holds a threading lock unless we're running in async mode, + # in which case it is a no-op. + self._optional_thread_lock = AsyncThreadLock() + def create_connection(self, origin: Origin) -> AsyncConnectionInterface: return AsyncHTTPConnection( origin=origin, @@ -145,64 +154,7 @@ def connections(self) -> List[AsyncConnectionInterface]: ] ``` """ - return list(self._pool) - - async def _attempt_to_acquire_connection(self, status: RequestStatus) -> bool: - """ - Attempt to provide a connection that can handle the given origin. - """ - origin = status.request.url.origin - - # If there are queued requests in front of us, then don't acquire a - # connection. We handle requests strictly in order. - waiting = [s for s in self._requests if s.connection is None] - if waiting and waiting[0] is not status: - return False - - # Reuse an existing connection if one is currently available. - for idx, connection in enumerate(self._pool): - if connection.can_handle_request(origin) and connection.is_available(): - self._pool.pop(idx) - self._pool.insert(0, connection) - status.set_connection(connection) - return True - - # If the pool is currently full, attempt to close one idle connection. - if len(self._pool) >= self._max_connections: - for idx, connection in reversed(list(enumerate(self._pool))): - if connection.is_idle(): - await connection.aclose() - self._pool.pop(idx) - break - - # If the pool is still full, then we cannot acquire a connection. - if len(self._pool) >= self._max_connections: - return False - - # Otherwise create a new connection. - connection = self.create_connection(origin) - self._pool.insert(0, connection) - status.set_connection(connection) - return True - - async def _close_expired_connections(self) -> None: - """ - Clean up the connection pool by closing off any connections that have expired. - """ - # Close any connections that have expired their keep-alive time. - for idx, connection in reversed(list(enumerate(self._pool))): - if connection.has_expired(): - await connection.aclose() - self._pool.pop(idx) - - # If the pool size exceeds the maximum number of allowed keep-alive connections, - # then close off idle connections as required. - pool_size = len(self._pool) - for idx, connection in reversed(list(enumerate(self._pool))): - if connection.is_idle() and pool_size > self._max_keepalive_connections: - await connection.aclose() - self._pool.pop(idx) - pool_size -= 1 + return list(self._connections) async def handle_async_request(self, request: Request) -> Response: """ @@ -220,116 +172,147 @@ async def handle_async_request(self, request: Request) -> Response: f"Request URL has an unsupported protocol '{scheme}://'." ) - status = RequestStatus(request) timeouts = request.extensions.get("timeout", {}) timeout = timeouts.get("pool", None) - if timeout is not None: - deadline = time.monotonic() + timeout - else: - deadline = float("inf") - - async with self._pool_lock: - self._requests.append(status) - await self._close_expired_connections() - await self._attempt_to_acquire_connection(status) - - while True: - try: - connection = await status.wait_for_connection(timeout=timeout) - except BaseException as exc: - # If we timeout here, or if the task is cancelled, then make - # sure to remove the request from the queue before bubbling - # up the exception. - async with self._pool_lock: - # Ensure only remove when task exists. - if status in self._requests: - self._requests.remove(status) - raise exc - - try: - response = await connection.handle_async_request(request) - except ConnectionNotAvailable: - # The ConnectionNotAvailable exception is a special case, that - # indicates we need to retry the request on a new connection. - # - # The most common case where this can occur is when multiple - # requests are queued waiting for a single connection, which - # might end up as an HTTP/2 connection, but which actually ends - # up as HTTP/1.1. - async with self._pool_lock: - # Maintain our position in the request queue, but reset the - # status so that the request becomes queued again. - status.unset_connection() - await self._attempt_to_acquire_connection(status) - except BaseException as exc: - with AsyncShieldCancellation(): - await self.response_closed(status) - raise exc - else: - break - - timeout = deadline - time.monotonic() - if timeout < 0: - raise PoolTimeout # pragma: nocover - - # When we return the response, we wrap the stream in a special class - # that handles notifying the connection pool once the response - # has been released. + with self._optional_thread_lock: + # Add the incoming request to our request queue. + pool_request = AsyncPoolRequest(request) + self._requests.append(pool_request) + + try: + while True: + with self._optional_thread_lock: + # Assign incoming requests to available connections, + # closing or creating new connections as required. + closing = self._assign_requests_to_connections() + await self._close_connections(closing) + + # Wait until this request has an assigned connection. + connection = await pool_request.wait_for_connection(timeout=timeout) + + try: + # Send the request on the assigned connection. + response = await connection.handle_async_request( + pool_request.request + ) + except ConnectionNotAvailable: + # In some cases a connection may initially be available to + # handle a request, but then become unavailable. + # + # In this case we clear the connection and try again. + pool_request.clear_connection() + else: + break # pragma: nocover + + except BaseException as exc: + with self._optional_thread_lock: + # For any exception or cancellation we remove the request from + # the queue, and then re-assign requests to connections. + self._requests.remove(pool_request) + closing = self._assign_requests_to_connections() + + await self._close_connections(closing) + raise exc from None + + # Return the response. Note that in this case we still have to manage + # the point at which the response is closed. assert isinstance(response.stream, AsyncIterable) return Response( status=response.status, headers=response.headers, - content=ConnectionPoolByteStream(response.stream, self, status), + content=PoolByteStream( + stream=response.stream, pool_request=pool_request, pool=self + ), extensions=response.extensions, ) - async def response_closed(self, status: RequestStatus) -> None: + def _assign_requests_to_connections(self) -> List[AsyncConnectionInterface]: """ - This method acts as a callback once the request/response cycle is complete. + Manage the state of the connection pool, assigning incoming + requests to connections as available. - It is called into from the `ConnectionPoolByteStream.aclose()` method. - """ - assert status.connection is not None - connection = status.connection - - async with self._pool_lock: - # Update the state of the connection pool. - if status in self._requests: - self._requests.remove(status) - - if connection.is_closed() and connection in self._pool: - self._pool.remove(connection) - - # Since we've had a response closed, it's possible we'll now be able - # to service one or more requests that are currently pending. - for status in self._requests: - if status.connection is None: - acquired = await self._attempt_to_acquire_connection(status) - # If we could not acquire a connection for a queued request - # then we don't need to check anymore requests that are - # queued later behind it. - if not acquired: - break - - # Housekeeping. - await self._close_expired_connections() + Called whenever a new request is added or removed from the pool. - async def aclose(self) -> None: + Any closing connections are returned, allowing the I/O for closing + those connections to be handled seperately. """ - Close any connections in the pool. - """ - async with self._pool_lock: - for connection in self._pool: + closing_connections = [] + + # First we handle cleaning up any connections that are closed, + # have expired their keep-alive, or surplus idle connections. + for connection in list(self._connections): + if connection.is_closed(): + # log: "removing closed connection" + self._connections.remove(connection) + elif connection.has_expired(): + # log: "closing expired connection" + self._connections.remove(connection) + closing_connections.append(connection) + elif ( + connection.is_idle() + and len([connection.is_idle() for connection in self._connections]) + > self._max_keepalive_connections + ): + # log: "closing idle connection" + self._connections.remove(connection) + closing_connections.append(connection) + + # Assign queued requests to connections. + queued_requests = [request for request in self._requests if request.is_queued()] + for pool_request in queued_requests: + origin = pool_request.request.url.origin + avilable_connections = [ + connection + for connection in self._connections + if connection.can_handle_request(origin) and connection.is_available() + ] + idle_connections = [ + connection for connection in self._connections if connection.is_idle() + ] + + # There are three cases for how we may be able to handle the request: + # + # 1. There is an existing connection that can handle the request. + # 2. We can create a new connection to handle the request. + # 3. We can close an idle connection and then create a new connection + # to handle the request. + if avilable_connections: + # log: "reusing existing connection" + connection = avilable_connections[0] + pool_request.assign_to_connection(connection) + elif len(self._connections) < self._max_connections: + # log: "creating new connection" + connection = self.create_connection(origin) + self._connections.append(connection) + pool_request.assign_to_connection(connection) + elif idle_connections: + # log: "closing idle connection" + connection = idle_connections[0] + self._connections.remove(connection) + closing_connections.append(connection) + # log: "creating new connection" + connection = self.create_connection(origin) + self._connections.append(connection) + pool_request.assign_to_connection(connection) + + return closing_connections + + async def _close_connections(self, closing: List[AsyncConnectionInterface]) -> None: + # Close connections which have been removed from the pool. + with AsyncShieldCancellation(): + for connection in closing: await connection.aclose() - self._pool = [] - self._requests = [] + + async def aclose(self) -> None: + # Explicitly close the connection pool. + # Clears all existing requests and connections. + with self._optional_thread_lock: + closing_connections = list(self._connections) + self._connections = [] + await self._close_connections(closing_connections) async def __aenter__(self) -> "AsyncConnectionPool": - # Acquiring the pool lock here ensures that we have the - # correct dependencies installed as early as possible. - async with self._pool_lock: - pass return self async def __aexit__( @@ -340,31 +323,58 @@ async def __aexit__( ) -> None: await self.aclose() + def __repr__(self) -> str: + class_name = self.__class__.__name__ + with self._optional_thread_lock: + request_is_queued = [request.is_queued() for request in self._requests] + connection_is_idle = [ + connection.is_idle() for connection in self._connections + ] + + num_active_requests = request_is_queued.count(False) + num_queued_requests = request_is_queued.count(True) + num_active_connections = connection_is_idle.count(False) + num_idle_connections = connection_is_idle.count(True) + + requests_info = ( + f"Requests: {num_active_requests} active, {num_queued_requests} queued" + ) + connection_info = ( + f"Connections: {num_active_connections} active, {num_idle_connections} idle" + ) + + return f"<{class_name} [{requests_info} | {connection_info}]>" -class ConnectionPoolByteStream: - """ - A wrapper around the response byte stream, that additionally handles - notifying the connection pool when the response has been closed. - """ +class PoolByteStream: def __init__( self, stream: AsyncIterable[bytes], + pool_request: AsyncPoolRequest, pool: AsyncConnectionPool, - status: RequestStatus, ) -> None: self._stream = stream + self._pool_request = pool_request self._pool = pool - self._status = status + self._closed = False async def __aiter__(self) -> AsyncIterator[bytes]: - async for part in self._stream: - yield part + try: + async for part in self._stream: + yield part + except BaseException as exc: + await self.aclose() + raise exc from None async def aclose(self) -> None: - try: - if hasattr(self._stream, "aclose"): - await self._stream.aclose() - finally: + if not self._closed: + self._closed = True with AsyncShieldCancellation(): - await self._pool.response_closed(self._status) + if hasattr(self._stream, "aclose"): + await self._stream.aclose() + + with self._pool._optional_thread_lock: + self._pool._requests.remove(self._pool_request) + closing = self._pool._assign_requests_to_connections() + + await self._pool._close_connections(closing) diff --git a/httpcore/_sync/connection.py b/httpcore/_sync/connection.py index f6b99f1b..c3890f34 100644 --- a/httpcore/_sync/connection.py +++ b/httpcore/_sync/connection.py @@ -6,7 +6,7 @@ from .._backends.sync import SyncBackend from .._backends.base import SOCKET_OPTION, NetworkBackend, NetworkStream -from .._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout +from .._exceptions import ConnectError, ConnectTimeout from .._models import Origin, Request, Response from .._ssl import default_ssl_context from .._synchronization import Lock @@ -70,9 +70,9 @@ def handle_request(self, request: Request) -> Response: f"Attempted to send request to {request.url.origin} on connection to {self._origin}" ) - with self._request_lock: - if self._connection is None: - try: + try: + with self._request_lock: + if self._connection is None: stream = self._connect(request) ssl_object = stream.get_extra_info("ssl_object") @@ -94,11 +94,9 @@ def handle_request(self, request: Request) -> Response: stream=stream, keepalive_expiry=self._keepalive_expiry, ) - except Exception as exc: - self._connect_failed = True - raise exc - elif not self._connection.is_available(): - raise ConnectionNotAvailable() + except BaseException as exc: + self._connect_failed = True + raise exc return self._connection.handle_request(request) diff --git a/httpcore/_sync/connection_pool.py b/httpcore/_sync/connection_pool.py index ccfb8d22..8dcf348c 100644 --- a/httpcore/_sync/connection_pool.py +++ b/httpcore/_sync/connection_pool.py @@ -1,31 +1,30 @@ import ssl import sys -import time from types import TracebackType from typing import Iterable, Iterator, Iterable, List, Optional, Type from .._backends.sync import SyncBackend from .._backends.base import SOCKET_OPTION, NetworkBackend -from .._exceptions import ConnectionNotAvailable, PoolTimeout, UnsupportedProtocol +from .._exceptions import ConnectionNotAvailable, UnsupportedProtocol from .._models import Origin, Request, Response -from .._synchronization import Event, Lock, ShieldCancellation +from .._synchronization import Event, ShieldCancellation, ThreadLock from .connection import HTTPConnection from .interfaces import ConnectionInterface, RequestInterface -class RequestStatus: - def __init__(self, request: Request): +class PoolRequest: + def __init__(self, request: Request) -> None: self.request = request self.connection: Optional[ConnectionInterface] = None self._connection_acquired = Event() - def set_connection(self, connection: ConnectionInterface) -> None: - assert self.connection is None + def assign_to_connection( + self, connection: Optional[ConnectionInterface] + ) -> None: self.connection = connection self._connection_acquired.set() - def unset_connection(self) -> None: - assert self.connection is not None + def clear_connection(self) -> None: self.connection = None self._connection_acquired = Event() @@ -37,6 +36,9 @@ def wait_for_connection( assert self.connection is not None return self.connection + def is_queued(self) -> bool: + return self.connection is None + class ConnectionPool(RequestInterface): """ @@ -107,14 +109,21 @@ def __init__( self._local_address = local_address self._uds = uds - self._pool: List[ConnectionInterface] = [] - self._requests: List[RequestStatus] = [] - self._pool_lock = Lock() self._network_backend = ( SyncBackend() if network_backend is None else network_backend ) self._socket_options = socket_options + # The mutable state on a connection pool is the queue of incoming requests, + # and the set of connections that are servicing those requests. + self._connections: List[ConnectionInterface] = [] + self._requests: List[PoolRequest] = [] + + # We only mutate the state of the connection pool within an 'optional_thread_lock' + # context. This holds a threading lock unless we're running in async mode, + # in which case it is a no-op. + self._optional_thread_lock = ThreadLock() + def create_connection(self, origin: Origin) -> ConnectionInterface: return HTTPConnection( origin=origin, @@ -145,64 +154,7 @@ def connections(self) -> List[ConnectionInterface]: ] ``` """ - return list(self._pool) - - def _attempt_to_acquire_connection(self, status: RequestStatus) -> bool: - """ - Attempt to provide a connection that can handle the given origin. - """ - origin = status.request.url.origin - - # If there are queued requests in front of us, then don't acquire a - # connection. We handle requests strictly in order. - waiting = [s for s in self._requests if s.connection is None] - if waiting and waiting[0] is not status: - return False - - # Reuse an existing connection if one is currently available. - for idx, connection in enumerate(self._pool): - if connection.can_handle_request(origin) and connection.is_available(): - self._pool.pop(idx) - self._pool.insert(0, connection) - status.set_connection(connection) - return True - - # If the pool is currently full, attempt to close one idle connection. - if len(self._pool) >= self._max_connections: - for idx, connection in reversed(list(enumerate(self._pool))): - if connection.is_idle(): - connection.close() - self._pool.pop(idx) - break - - # If the pool is still full, then we cannot acquire a connection. - if len(self._pool) >= self._max_connections: - return False - - # Otherwise create a new connection. - connection = self.create_connection(origin) - self._pool.insert(0, connection) - status.set_connection(connection) - return True - - def _close_expired_connections(self) -> None: - """ - Clean up the connection pool by closing off any connections that have expired. - """ - # Close any connections that have expired their keep-alive time. - for idx, connection in reversed(list(enumerate(self._pool))): - if connection.has_expired(): - connection.close() - self._pool.pop(idx) - - # If the pool size exceeds the maximum number of allowed keep-alive connections, - # then close off idle connections as required. - pool_size = len(self._pool) - for idx, connection in reversed(list(enumerate(self._pool))): - if connection.is_idle() and pool_size > self._max_keepalive_connections: - connection.close() - self._pool.pop(idx) - pool_size -= 1 + return list(self._connections) def handle_request(self, request: Request) -> Response: """ @@ -220,116 +172,147 @@ def handle_request(self, request: Request) -> Response: f"Request URL has an unsupported protocol '{scheme}://'." ) - status = RequestStatus(request) timeouts = request.extensions.get("timeout", {}) timeout = timeouts.get("pool", None) - if timeout is not None: - deadline = time.monotonic() + timeout - else: - deadline = float("inf") - - with self._pool_lock: - self._requests.append(status) - self._close_expired_connections() - self._attempt_to_acquire_connection(status) - - while True: - try: - connection = status.wait_for_connection(timeout=timeout) - except BaseException as exc: - # If we timeout here, or if the task is cancelled, then make - # sure to remove the request from the queue before bubbling - # up the exception. - with self._pool_lock: - # Ensure only remove when task exists. - if status in self._requests: - self._requests.remove(status) - raise exc - - try: - response = connection.handle_request(request) - except ConnectionNotAvailable: - # The ConnectionNotAvailable exception is a special case, that - # indicates we need to retry the request on a new connection. - # - # The most common case where this can occur is when multiple - # requests are queued waiting for a single connection, which - # might end up as an HTTP/2 connection, but which actually ends - # up as HTTP/1.1. - with self._pool_lock: - # Maintain our position in the request queue, but reset the - # status so that the request becomes queued again. - status.unset_connection() - self._attempt_to_acquire_connection(status) - except BaseException as exc: - with ShieldCancellation(): - self.response_closed(status) - raise exc - else: - break - - timeout = deadline - time.monotonic() - if timeout < 0: - raise PoolTimeout # pragma: nocover - - # When we return the response, we wrap the stream in a special class - # that handles notifying the connection pool once the response - # has been released. + with self._optional_thread_lock: + # Add the incoming request to our request queue. + pool_request = PoolRequest(request) + self._requests.append(pool_request) + + try: + while True: + with self._optional_thread_lock: + # Assign incoming requests to available connections, + # closing or creating new connections as required. + closing = self._assign_requests_to_connections() + self._close_connections(closing) + + # Wait until this request has an assigned connection. + connection = pool_request.wait_for_connection(timeout=timeout) + + try: + # Send the request on the assigned connection. + response = connection.handle_request( + pool_request.request + ) + except ConnectionNotAvailable: + # In some cases a connection may initially be available to + # handle a request, but then become unavailable. + # + # In this case we clear the connection and try again. + pool_request.clear_connection() + else: + break # pragma: nocover + + except BaseException as exc: + with self._optional_thread_lock: + # For any exception or cancellation we remove the request from + # the queue, and then re-assign requests to connections. + self._requests.remove(pool_request) + closing = self._assign_requests_to_connections() + + self._close_connections(closing) + raise exc from None + + # Return the response. Note that in this case we still have to manage + # the point at which the response is closed. assert isinstance(response.stream, Iterable) return Response( status=response.status, headers=response.headers, - content=ConnectionPoolByteStream(response.stream, self, status), + content=PoolByteStream( + stream=response.stream, pool_request=pool_request, pool=self + ), extensions=response.extensions, ) - def response_closed(self, status: RequestStatus) -> None: + def _assign_requests_to_connections(self) -> List[ConnectionInterface]: """ - This method acts as a callback once the request/response cycle is complete. + Manage the state of the connection pool, assigning incoming + requests to connections as available. - It is called into from the `ConnectionPoolByteStream.close()` method. - """ - assert status.connection is not None - connection = status.connection - - with self._pool_lock: - # Update the state of the connection pool. - if status in self._requests: - self._requests.remove(status) - - if connection.is_closed() and connection in self._pool: - self._pool.remove(connection) - - # Since we've had a response closed, it's possible we'll now be able - # to service one or more requests that are currently pending. - for status in self._requests: - if status.connection is None: - acquired = self._attempt_to_acquire_connection(status) - # If we could not acquire a connection for a queued request - # then we don't need to check anymore requests that are - # queued later behind it. - if not acquired: - break - - # Housekeeping. - self._close_expired_connections() + Called whenever a new request is added or removed from the pool. - def close(self) -> None: + Any closing connections are returned, allowing the I/O for closing + those connections to be handled seperately. """ - Close any connections in the pool. - """ - with self._pool_lock: - for connection in self._pool: + closing_connections = [] + + # First we handle cleaning up any connections that are closed, + # have expired their keep-alive, or surplus idle connections. + for connection in list(self._connections): + if connection.is_closed(): + # log: "removing closed connection" + self._connections.remove(connection) + elif connection.has_expired(): + # log: "closing expired connection" + self._connections.remove(connection) + closing_connections.append(connection) + elif ( + connection.is_idle() + and len([connection.is_idle() for connection in self._connections]) + > self._max_keepalive_connections + ): + # log: "closing idle connection" + self._connections.remove(connection) + closing_connections.append(connection) + + # Assign queued requests to connections. + queued_requests = [request for request in self._requests if request.is_queued()] + for pool_request in queued_requests: + origin = pool_request.request.url.origin + avilable_connections = [ + connection + for connection in self._connections + if connection.can_handle_request(origin) and connection.is_available() + ] + idle_connections = [ + connection for connection in self._connections if connection.is_idle() + ] + + # There are three cases for how we may be able to handle the request: + # + # 1. There is an existing connection that can handle the request. + # 2. We can create a new connection to handle the request. + # 3. We can close an idle connection and then create a new connection + # to handle the request. + if avilable_connections: + # log: "reusing existing connection" + connection = avilable_connections[0] + pool_request.assign_to_connection(connection) + elif len(self._connections) < self._max_connections: + # log: "creating new connection" + connection = self.create_connection(origin) + self._connections.append(connection) + pool_request.assign_to_connection(connection) + elif idle_connections: + # log: "closing idle connection" + connection = idle_connections[0] + self._connections.remove(connection) + closing_connections.append(connection) + # log: "creating new connection" + connection = self.create_connection(origin) + self._connections.append(connection) + pool_request.assign_to_connection(connection) + + return closing_connections + + def _close_connections(self, closing: List[ConnectionInterface]) -> None: + # Close connections which have been removed from the pool. + with ShieldCancellation(): + for connection in closing: connection.close() - self._pool = [] - self._requests = [] + + def close(self) -> None: + # Explicitly close the connection pool. + # Clears all existing requests and connections. + with self._optional_thread_lock: + closing_connections = list(self._connections) + self._connections = [] + self._close_connections(closing_connections) def __enter__(self) -> "ConnectionPool": - # Acquiring the pool lock here ensures that we have the - # correct dependencies installed as early as possible. - with self._pool_lock: - pass return self def __exit__( @@ -340,31 +323,58 @@ def __exit__( ) -> None: self.close() + def __repr__(self) -> str: + class_name = self.__class__.__name__ + with self._optional_thread_lock: + request_is_queued = [request.is_queued() for request in self._requests] + connection_is_idle = [ + connection.is_idle() for connection in self._connections + ] + + num_active_requests = request_is_queued.count(False) + num_queued_requests = request_is_queued.count(True) + num_active_connections = connection_is_idle.count(False) + num_idle_connections = connection_is_idle.count(True) + + requests_info = ( + f"Requests: {num_active_requests} active, {num_queued_requests} queued" + ) + connection_info = ( + f"Connections: {num_active_connections} active, {num_idle_connections} idle" + ) + + return f"<{class_name} [{requests_info} | {connection_info}]>" -class ConnectionPoolByteStream: - """ - A wrapper around the response byte stream, that additionally handles - notifying the connection pool when the response has been closed. - """ +class PoolByteStream: def __init__( self, stream: Iterable[bytes], + pool_request: PoolRequest, pool: ConnectionPool, - status: RequestStatus, ) -> None: self._stream = stream + self._pool_request = pool_request self._pool = pool - self._status = status + self._closed = False def __iter__(self) -> Iterator[bytes]: - for part in self._stream: - yield part + try: + for part in self._stream: + yield part + except BaseException as exc: + self.close() + raise exc from None def close(self) -> None: - try: - if hasattr(self._stream, "close"): - self._stream.close() - finally: + if not self._closed: + self._closed = True with ShieldCancellation(): - self._pool.response_closed(self._status) + if hasattr(self._stream, "close"): + self._stream.close() + + with self._pool._optional_thread_lock: + self._pool._requests.remove(self._pool_request) + closing = self._pool._assign_requests_to_connections() + + self._pool._close_connections(closing) diff --git a/httpcore/_synchronization.py b/httpcore/_synchronization.py index 119d89fc..9619a398 100644 --- a/httpcore/_synchronization.py +++ b/httpcore/_synchronization.py @@ -45,6 +45,13 @@ def current_async_library() -> str: class AsyncLock: + """ + This is a standard lock. + + In the sync case `Lock` provides thread locking. + In the async case `AsyncLock` provides async locking. + """ + def __init__(self) -> None: self._backend = "" @@ -82,6 +89,26 @@ async def __aexit__( self._anyio_lock.release() +class AsyncThreadLock: + """ + This is a threading-only lock for no-I/O contexts. + + In the sync case `ThreadLock` provides thread locking. + In the async case `AsyncThreadLock` is a no-op. + """ + + def __enter__(self) -> "AsyncThreadLock": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + pass + + class AsyncEvent: def __init__(self) -> None: self._backend = "" @@ -202,6 +229,13 @@ def __exit__( class Lock: + """ + This is a standard lock. + + In the sync case `Lock` provides thread locking. + In the async case `AsyncLock` provides async locking. + """ + def __init__(self) -> None: self._lock = threading.Lock() @@ -218,6 +252,30 @@ def __exit__( self._lock.release() +class ThreadLock: + """ + This is a threading-only lock for no-I/O contexts. + + In the sync case `ThreadLock` provides thread locking. + In the async case `AsyncThreadLock` is a no-op. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + + def __enter__(self) -> "ThreadLock": + self._lock.acquire() + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + self._lock.release() + + class Event: def __init__(self) -> None: self._event = threading.Event() diff --git a/requirements.txt b/requirements.txt index 71cf2164..d125b321 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,5 +20,5 @@ trio-typing==0.10.0 types-certifi==2021.10.8.3 pytest==8.0.0 pytest-httpbin==2.0.0 -pytest-trio==0.7.0 +pytest-trio==0.8.0 werkzeug<2.1 # See: https://github.com/postmanlabs/httpbin/issues/673 diff --git a/tests/_async/test_connection_pool.py b/tests/_async/test_connection_pool.py index 61ee1e54..2fc27204 100644 --- a/tests/_async/test_connection_pool.py +++ b/tests/_async/test_connection_pool.py @@ -38,6 +38,10 @@ async def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) await response.aread() assert response.status == 200 @@ -46,6 +50,10 @@ async def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) # Sending a second request to the same origin will reuse the existing IDLE connection. async with pool.stream("GET", "https://example.com/") as response: @@ -53,6 +61,10 @@ async def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) await response.aread() assert response.status == 200 @@ -61,23 +73,35 @@ async def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) # Sending a request to a different origin will not reuse the existing IDLE connection. async with pool.stream("GET", "http://example.com/") as response: info = [repr(c) for c in pool.connections] assert info == [ - "", "", + "", ] + assert ( + repr(pool) + == "" + ) await response.aread() assert response.status == 200 assert response.content == b"Hello, world!" info = [repr(c) for c in pool.connections] assert info == [ - "", "", + "", ] + assert ( + repr(pool) + == "" + ) @pytest.mark.anyio @@ -219,6 +243,7 @@ async def test_connection_pool_with_http2_goaway(): ] # Sending a second request to the same origin will require a new connection. + # The original connection has now been closed. response = await pool.request("GET", "https://example.com/") assert response.status == 200 assert response.content == b"Hello, world!" @@ -226,7 +251,6 @@ async def test_connection_pool_with_http2_goaway(): info = [repr(c) for c in pool.connections] assert info == [ "", - "", ] @@ -620,6 +644,11 @@ async def fetch(pool, domain, info_list): "", ] + assert ( + repr(pool) + == "" + ) + @pytest.mark.anyio async def test_unsupported_protocol(): diff --git a/tests/_sync/test_connection_pool.py b/tests/_sync/test_connection_pool.py index c9621c7b..ee303e5c 100644 --- a/tests/_sync/test_connection_pool.py +++ b/tests/_sync/test_connection_pool.py @@ -38,6 +38,10 @@ def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) response.read() assert response.status == 200 @@ -46,6 +50,10 @@ def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) # Sending a second request to the same origin will reuse the existing IDLE connection. with pool.stream("GET", "https://example.com/") as response: @@ -53,6 +61,10 @@ def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) response.read() assert response.status == 200 @@ -61,23 +73,35 @@ def test_connection_pool_with_keepalive(): assert info == [ "" ] + assert ( + repr(pool) + == "" + ) # Sending a request to a different origin will not reuse the existing IDLE connection. with pool.stream("GET", "http://example.com/") as response: info = [repr(c) for c in pool.connections] assert info == [ - "", "", + "", ] + assert ( + repr(pool) + == "" + ) response.read() assert response.status == 200 assert response.content == b"Hello, world!" info = [repr(c) for c in pool.connections] assert info == [ - "", "", + "", ] + assert ( + repr(pool) + == "" + ) @@ -219,6 +243,7 @@ def test_connection_pool_with_http2_goaway(): ] # Sending a second request to the same origin will require a new connection. + # The original connection has now been closed. response = pool.request("GET", "https://example.com/") assert response.status == 200 assert response.content == b"Hello, world!" @@ -226,7 +251,6 @@ def test_connection_pool_with_http2_goaway(): info = [repr(c) for c in pool.connections] assert info == [ "", - "", ] @@ -620,6 +644,11 @@ def fetch(pool, domain, info_list): "", ] + assert ( + repr(pool) + == "" + ) + def test_unsupported_protocol(): From 908013ca0d70afd44d75cb18e28b94aca5983611 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 13 Feb 2024 15:25:52 +0000 Subject: [PATCH 21/21] Version 1.0.3 (#883) --- CHANGELOG.md | 2 +- httpcore/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d537c8e..f3fe41e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## Unreleased +## 1.0.3 (February 13th, 2024) - Fix support for async cancellations. (#880) - Fix trace extension when used with socks proxy. (#849) diff --git a/httpcore/__init__.py b/httpcore/__init__.py index eb3e5771..3709fc40 100644 --- a/httpcore/__init__.py +++ b/httpcore/__init__.py @@ -130,7 +130,7 @@ def __init__(self, *args, **kwargs): # type: ignore "WriteError", ] -__version__ = "1.0.2" +__version__ = "1.0.3" __locals = locals()