httpr 0.7.1__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/CI.yml +47 -0
- {httpr-0.7.1 → httpr-0.7.2}/CLAUDE.md +4 -3
- {httpr-0.7.1 → httpr-0.7.2}/Cargo.lock +1 -1
- {httpr-0.7.1 → httpr-0.7.2}/Cargo.toml +1 -1
- {httpr-0.7.1 → httpr-0.7.2}/PKG-INFO +2 -2
- {httpr-0.7.1 → httpr-0.7.2}/README.md +1 -1
- {httpr-0.7.1 → httpr-0.7.2}/docs/api/response.md +1 -1
- {httpr-0.7.1 → httpr-0.7.2}/httpr/__init__.py +35 -31
- {httpr-0.7.1 → httpr-0.7.2}/httpr/httpr.pyi +13 -3
- {httpr-0.7.1 → httpr-0.7.2}/pyproject.toml +1 -1
- {httpr-0.7.1 → httpr-0.7.2}/src/exceptions.rs +12 -2
- {httpr-0.7.1 → httpr-0.7.2}/src/lib.rs +53 -12
- {httpr-0.7.1 → httpr-0.7.2}/src/lifecycle.rs +38 -7
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_asyncclient.py +13 -14
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_close.py +92 -33
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_proxy.py +6 -4
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_streaming.py +5 -5
- {httpr-0.7.1 → httpr-0.7.2}/.github/actions/set-version/action.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/copilot-instructions.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/benchmark.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/codspeed.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/compare.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/copilot-setup-steps.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/mkdocs.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/set_version.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.gitignore +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/.pre-commit-config.yaml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/LICENSE +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/Taskfile.yaml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/benchmark/README.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/benchmark/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/benchmark/benchmark.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/benchmark/benchmark_cbor.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/benchmark/render_comparison.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/benchmark/server.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/cookies.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/proxy.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/ssl-tls.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/api/async-client.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/api/client.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/api/functions.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/api/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/benchmark.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/quickstart.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/async.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/authentication.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/making-requests.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/response-handling.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/writings/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/httpr/py.typed +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/mkdocs.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/scripts/generate_certs.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/src/params.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/src/response.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/src/timeout.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/src/traits.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/src/utils.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/README.md +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/bench_server.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/conftest.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/test_decoding.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/test_transport.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/test_performance.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/conftest.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_async.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_auth.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_redirects.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_ssl.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_streaming.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_uploads.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/cbor_test_server.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/httpx_conns.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_ca_bundle.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_cbor.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_client.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_defs.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_docs.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_exceptions.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_params.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_response.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_ssl.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_timeout.py +0 -0
- {httpr-0.7.1 → httpr-0.7.2}/uv.lock +0 -0
|
@@ -62,6 +62,53 @@ jobs:
|
|
|
62
62
|
uv run task dev
|
|
63
63
|
uv run task test:unit
|
|
64
64
|
|
|
65
|
+
# Downstream compatibility: pyvespa's unit suite against the wheel built from
|
|
66
|
+
# this commit. pyvespa is the main consumer of httpr and consumed lazy
|
|
67
|
+
# generators after `close()` for a year without anyone noticing, until 0.7.0
|
|
68
|
+
# made close() real. Runs on PRs, main branch, and tag pushes.
|
|
69
|
+
downstream-pyvespa:
|
|
70
|
+
runs-on: ubuntu-22.04
|
|
71
|
+
if: github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')))
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@v5
|
|
74
|
+
- uses: astral-sh/setup-uv@v7
|
|
75
|
+
with:
|
|
76
|
+
python-version: "3.12"
|
|
77
|
+
- name: Cache Rust build
|
|
78
|
+
uses: actions/cache@v5
|
|
79
|
+
with:
|
|
80
|
+
path: |
|
|
81
|
+
~/.cargo/registry
|
|
82
|
+
~/.cargo/git
|
|
83
|
+
target
|
|
84
|
+
key: rust-v2-${{ runner.os }}-downstream-${{ hashFiles('**/Cargo.lock') }}
|
|
85
|
+
restore-keys: |
|
|
86
|
+
rust-v2-${{ runner.os }}-downstream-
|
|
87
|
+
- name: Build wheel
|
|
88
|
+
run: uv run --with maturin maturin build --release --out dist
|
|
89
|
+
- name: Check out pyvespa master
|
|
90
|
+
uses: actions/checkout@v5
|
|
91
|
+
with:
|
|
92
|
+
repository: vespa-engine/pyvespa
|
|
93
|
+
ref: master
|
|
94
|
+
path: pyvespa
|
|
95
|
+
- name: Install pyvespa, then swap in this wheel
|
|
96
|
+
# pyvespa pins httpr>=0.4.0 and the local wheel is 0.0.0.dev0, so it
|
|
97
|
+
# cannot be part of the resolve; install it afterwards with --no-deps.
|
|
98
|
+
run: |
|
|
99
|
+
uv venv .venv-pyvespa
|
|
100
|
+
uv pip install --python .venv-pyvespa/bin/python "./pyvespa[unittest]"
|
|
101
|
+
uv pip install --python .venv-pyvespa/bin/python --no-deps --reinstall dist/httpr-*.whl
|
|
102
|
+
.venv-pyvespa/bin/python -c "import importlib.metadata as m; v = m.version('httpr'); assert v.startswith('0.0.0'), v; print('httpr', v)"
|
|
103
|
+
- name: pyvespa unit tests
|
|
104
|
+
# The venv's bin goes first on PATH: the deployment tests create a
|
|
105
|
+
# throwaway Vespa Cloud cert/key pair with the `vespa` CLI (from the
|
|
106
|
+
# `vespacli` package in pyvespa's unittest extra) and skip it otherwise.
|
|
107
|
+
working-directory: pyvespa
|
|
108
|
+
run: |
|
|
109
|
+
export PATH="$GITHUB_WORKSPACE/.venv-pyvespa/bin:$PATH"
|
|
110
|
+
python -m pytest tests/unit -q -p no:cacheprovider
|
|
111
|
+
|
|
65
112
|
# E2E tests with httpbun Docker container - runs on PRs, main branch, and tag pushes
|
|
66
113
|
e2e:
|
|
67
114
|
runs-on: ubuntu-22.04
|
|
@@ -194,9 +194,10 @@ builds in debug mode and the numbers are meaningless.
|
|
|
194
194
|
- Overlapping requests make hyper race a fresh connect against the idle-pool checkout; a losing connect is finished in the background and holds the pool alive until it resolves, which needs real I/O and against a remote host can take longer than `close()` should wait. So every `reqwest::Client` is built with `CancelConnectsLayer` (a tower layer over reqwest's connector, via `ClientBuilder::connector_layer`) and `close()` cancels the token when nothing is in flight; cancelled connects resolve on the next scheduler turn and drop their pool handle. Do not replace this with waiting or with `num_alive_tasks` heuristics: the count is runtime-global and a pending connect keeps it constant
|
|
195
195
|
- `begin_request()` increments `in_flight` before cloning the `reqwest::Client`, so a request already in flight keeps the pool alive and finishes normally when the client is closed underneath it. While anything is in flight `close()` only drops its own handle and yields; the `InFlight` guard of the last request to finish (in `request()` after the body is buffered, or `StreamingResponse::close()`/drop for streams) cancels pending connects and settles. `StreamingResponse` owns its `InFlight` for its whole lifetime, since its connection stays busy until it is closed
|
|
196
196
|
- `set_proxy` swaps the client via `ClientState::replace()` and settles the old pool; it raises `ClientClosed` on a closed client. `RClient` implements `Drop`, so a client garbage-collected without `close()` releases its pool as well (safe: every `block_on` in the crate runs with the GIL released, so dealloc never happens inside one)
|
|
197
|
-
- Use after close
|
|
198
|
-
- Python: `Client.close()`/`__exit__` call the Rust `close()`; `AsyncClient.aclose()`/`__aexit__` additionally `shutdown(wait=False)` the client's own `ThreadPoolExecutor
|
|
199
|
-
- Leaving a `with`/`async with` block closes the client; re-entering it afterwards
|
|
197
|
+
- Use after close reopens the client (since 0.7.2; `requests.Session` semantics): `RClient::begin_request` finds the slot empty, rebuilds the `reqwest::Client` via `ClientConfig::build()` with a fresh `CancellationToken`, installs it with `ClientState::reopen()` (the old token was cancelled by `close()` and must not be reused) and emits `ClientReopenedWarning`, a `ResourceWarning` subclass, so `warnings.simplefilter("error", httpr.ClientReopenedWarning)` gives httpx's strict behaviour. The rebuilt client starts with an empty cookie store; headers, params, auth, proxy and timeout live on `RClient` and carry over. `is_closed` is true from `close()` until the next request. `ClientClosed` (a `RuntimeError` subclass) stays exported but is only raised if a `close()` races the reopen. Both messages live once in Rust (`CLIENT_CLOSED_MSG`/`CLIENT_REOPENED_MSG`, exported as `_CLIENT_CLOSED_MSG`/`_CLIENT_REOPENED_MSG`). Do not go back to raising on use after close: every released pyvespa (`httpr>=0.4.0`) consumes `visit`/streaming-query generators after the owning `with VespaSync` block has closed the client, and 0.7.0/0.7.1 broke them all (pyvespa PR #1347 fixes the pattern going forward)
|
|
198
|
+
- Python: `Client.close()`/`__exit__` call the Rust `close()`; `AsyncClient.aclose()`/`__aexit__` additionally `shutdown(wait=False)` the client's own `ThreadPoolExecutor` and drop it, and run on the event-loop thread on purpose (sub-millisecond, no I/O wait). The executor is created on demand by `_dispatch_executor()`, so a reopened client gets a new one; `_run_sync_asyncio` retries once on the executor's "cannot schedule new futures after shutdown" `RuntimeError`, which covers a `close()` racing in from another OS thread
|
|
199
|
+
- Leaving a `with`/`async with` block closes the client; re-entering it afterwards works and warns once per reopen
|
|
200
|
+
- CI runs pyvespa's unit suite (`origin/master`) against the wheel built from every PR (`downstream-pyvespa` job in `CI.yml`); it is the guard against this class of regression
|
|
200
201
|
|
|
201
202
|
### Timeouts (issue #81)
|
|
202
203
|
- Default `timeout` is 30 s and lives in the PyO3 `#[new]` signature in `src/lib.rs`; `Client.__init__` in Python only documents the parameters and forwards nothing, so its defaults must match Rust's (`test_python_signature_matches_rust_defaults` enforces this). `None` disables the timeout
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "httpr"
|
|
3
3
|
# Version is set dynamically by CI from git tags (e.g., v1.2.3 -> 1.2.3).
|
|
4
4
|
# Do not edit manually. See .github/actions/set-version/ for details.
|
|
5
|
-
version = "0.7.
|
|
5
|
+
version = "0.7.2"
|
|
6
6
|
edition = "2021"
|
|
7
7
|
description = "Fast HTTP client for python"
|
|
8
8
|
authors = ["thomasht86"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpr
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -170,7 +170,7 @@ finally:
|
|
|
170
170
|
client.close() # idempotent
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes.
|
|
173
|
+
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes. A request made afterwards reopens the client with a fresh pool, the way a `requests.Session` does, and emits `httpr.ClientReopenedWarning` (a `ResourceWarning`, silent by default). Run with `-W error::httpr.ClientReopenedWarning` or `warnings.simplefilter("error", httpr.ClientReopenedWarning)` to get httpx's strict use-after-close behaviour instead. `client.is_closed` tells you which state a client is in. `AsyncClient` offers the same via `aclose()` / `async with`, and additionally shuts down its own thread pool. A client that is garbage-collected without being closed releases its pool too, but only once the interpreter gets to it, so prefer closing explicitly.
|
|
174
174
|
|
|
175
175
|
#### Client methods
|
|
176
176
|
|
|
@@ -126,7 +126,7 @@ finally:
|
|
|
126
126
|
client.close() # idempotent
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
-
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes.
|
|
129
|
+
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes. A request made afterwards reopens the client with a fresh pool, the way a `requests.Session` does, and emits `httpr.ClientReopenedWarning` (a `ResourceWarning`, silent by default). Run with `-W error::httpr.ClientReopenedWarning` or `warnings.simplefilter("error", httpr.ClientReopenedWarning)` to get httpx's strict use-after-close behaviour instead. `client.is_closed` tells you which state a client is in. `AsyncClient` offers the same via `aclose()` / `async with`, and additionally shuts down its own thread pool. A client that is garbage-collected without being closed releases its pool too, but only once the interpreter gets to it, so prefer closing explicitly.
|
|
130
130
|
|
|
131
131
|
#### Client methods
|
|
132
132
|
|
|
@@ -635,7 +635,7 @@ async with httpr.AsyncClient() as client:
|
|
|
635
635
|
handle(line)
|
|
636
636
|
```
|
|
637
637
|
|
|
638
|
-
The synchronous `iter_bytes()`, `iter_text()`, `iter_lines()` and `read()` remain available on the async response but block the event loop until the next chunk arrives; prefer the `a`-prefixed methods in async code.
|
|
638
|
+
The synchronous `iter_bytes()`, `iter_text()`, `iter_lines()` and `read()` remain available on the async response but block the event loop until the next chunk arrives; prefer the `a`-prefixed methods in async code. A stream that is open when `client.aclose()` is called keeps its connection and can still be read to the end.
|
|
639
639
|
|
|
640
640
|
---
|
|
641
641
|
|
|
@@ -42,9 +42,9 @@ else:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
from .httpr import (
|
|
45
|
-
_CLIENT_CLOSED_MSG,
|
|
46
45
|
CaseInsensitiveHeaderMap,
|
|
47
46
|
ClientClosed,
|
|
47
|
+
ClientReopenedWarning,
|
|
48
48
|
RClient,
|
|
49
49
|
Response,
|
|
50
50
|
StreamingResponse,
|
|
@@ -308,9 +308,11 @@ class Client(RClient):
|
|
|
308
308
|
Idle pooled connections are shut down before this returns. Requests
|
|
309
309
|
that are already in flight (including open `stream()` responses) finish
|
|
310
310
|
normally and keep the pool alive until the last of them completes, at
|
|
311
|
-
which point it is released.
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
which point it is released. A request made after `close()` reopens the
|
|
312
|
+
client with a fresh connection pool and emits
|
|
313
|
+
`httpr.ClientReopenedWarning` (a `ResourceWarning`, silent by default);
|
|
314
|
+
turn it into an error with the `warnings` module to get httpx's strict
|
|
315
|
+
behaviour instead. Calling `close()` more than once is a no-op.
|
|
314
316
|
|
|
315
317
|
Example:
|
|
316
318
|
```python
|
|
@@ -738,8 +740,8 @@ class AsyncStreamingResponse:
|
|
|
738
740
|
|
|
739
741
|
async def _aiter(self, it: Iterator[_T]) -> AsyncIterator[_T]:
|
|
740
742
|
# Each `next()` does a blocking read on the Rust side, so it goes through
|
|
741
|
-
# the client's executor like a request does.
|
|
742
|
-
#
|
|
743
|
+
# the client's executor like a request does. The stream holds its own
|
|
744
|
+
# handle to the pool, so it keeps reading after the client is closed.
|
|
743
745
|
sentinel: object = object()
|
|
744
746
|
while True:
|
|
745
747
|
item = await self._client._run_sync_asyncio(next, it, sentinel)
|
|
@@ -896,12 +898,10 @@ class AsyncClient(Client):
|
|
|
896
898
|
"""
|
|
897
899
|
super().__init__(*args, **kwargs)
|
|
898
900
|
self.max_concurrency = max_concurrency
|
|
899
|
-
#
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
else ThreadPoolExecutor(max_workers=max_concurrency, thread_name_prefix="httpr")
|
|
904
|
-
)
|
|
901
|
+
# Created on first use by `_dispatch_executor()`; `close()`/`aclose()`
|
|
902
|
+
# shut it down and drop it, and the next request creates a new one, so
|
|
903
|
+
# a client reopened after close gets its threads back as well.
|
|
904
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
905
905
|
|
|
906
906
|
async def __aenter__(self) -> AsyncClient:
|
|
907
907
|
"""Enter async context manager."""
|
|
@@ -920,19 +920,21 @@ class AsyncClient(Client):
|
|
|
920
920
|
the `Client` contract too.
|
|
921
921
|
"""
|
|
922
922
|
super().close()
|
|
923
|
-
|
|
923
|
+
executor, self._executor = self._executor, None
|
|
924
|
+
if executor is not None:
|
|
924
925
|
# Requests still running on the pool keep their handle to the reqwest
|
|
925
|
-
# client and finish normally
|
|
926
|
-
#
|
|
927
|
-
|
|
926
|
+
# client and finish normally. Not waiting keeps this safe to call
|
|
927
|
+
# from the event-loop thread.
|
|
928
|
+
executor.shutdown(wait=False)
|
|
928
929
|
|
|
929
930
|
async def aclose(self) -> None:
|
|
930
931
|
"""
|
|
931
932
|
Close the async client.
|
|
932
933
|
|
|
933
934
|
Releases the connection pool and shuts down this client's thread pool.
|
|
934
|
-
|
|
935
|
-
|
|
935
|
+
A request made after `aclose()` reopens both, with a
|
|
936
|
+
`httpr.ClientReopenedWarning` (see `Client.close()`). Calling it more
|
|
937
|
+
than once is a no-op.
|
|
936
938
|
|
|
937
939
|
Example:
|
|
938
940
|
```python
|
|
@@ -948,24 +950,25 @@ class AsyncClient(Client):
|
|
|
948
950
|
# millisecond, less than a hop through the executor would cost.
|
|
949
951
|
self.close()
|
|
950
952
|
|
|
953
|
+
def _dispatch_executor(self) -> ThreadPoolExecutor | None:
|
|
954
|
+
"""This client's thread pool, created on demand; `None` means asyncio's default."""
|
|
955
|
+
if self.max_concurrency is None:
|
|
956
|
+
return None
|
|
957
|
+
if self._executor is None:
|
|
958
|
+
self._executor = ThreadPoolExecutor(max_workers=self.max_concurrency, thread_name_prefix="httpr")
|
|
959
|
+
return self._executor
|
|
960
|
+
|
|
951
961
|
async def _run_sync_asyncio(self, fn, *args, **kwargs):
|
|
952
962
|
"""Run a synchronous function on this client's executor."""
|
|
953
|
-
if self.is_closed:
|
|
954
|
-
# Checked here rather than left to the Rust side so a closed client
|
|
955
|
-
# raises ClientClosed instead of the executor's own "cannot schedule
|
|
956
|
-
# new futures after shutdown" RuntimeError.
|
|
957
|
-
raise ClientClosed(_CLIENT_CLOSED_MSG)
|
|
958
963
|
loop = asyncio.get_running_loop()
|
|
964
|
+
call = partial(fn, *args, **kwargs)
|
|
959
965
|
try:
|
|
960
|
-
future = loop.run_in_executor(self.
|
|
966
|
+
future = loop.run_in_executor(self._dispatch_executor(), call)
|
|
961
967
|
except RuntimeError:
|
|
962
|
-
#
|
|
963
|
-
#
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if self.is_closed:
|
|
967
|
-
raise ClientClosed(_CLIENT_CLOSED_MSG) from None
|
|
968
|
-
raise
|
|
968
|
+
# A close() from another thread shut the pool down between the lookup
|
|
969
|
+
# and the submit. The client reopens on use, so does its executor.
|
|
970
|
+
self._executor = None
|
|
971
|
+
future = loop.run_in_executor(self._dispatch_executor(), call)
|
|
969
972
|
return await future
|
|
970
973
|
|
|
971
974
|
async def request( # type: ignore[override]
|
|
@@ -1463,6 +1466,7 @@ __all__ = [
|
|
|
1463
1466
|
"StreamClosed",
|
|
1464
1467
|
# Client lifecycle exceptions
|
|
1465
1468
|
"ClientClosed",
|
|
1469
|
+
"ClientReopenedWarning",
|
|
1466
1470
|
"InvalidURL",
|
|
1467
1471
|
"CookieConflict",
|
|
1468
1472
|
]
|
|
@@ -512,7 +512,8 @@ class Client(RClient):
|
|
|
512
512
|
"""
|
|
513
513
|
Close the client and release its connection pool.
|
|
514
514
|
|
|
515
|
-
|
|
515
|
+
A request made after `close()` reopens the client with a fresh pool and
|
|
516
|
+
emits `ClientReopenedWarning`. Idempotent.
|
|
516
517
|
"""
|
|
517
518
|
...
|
|
518
519
|
def stream(
|
|
@@ -608,7 +609,8 @@ class AsyncClient(Client):
|
|
|
608
609
|
"""
|
|
609
610
|
Close the async client and shut down its thread pool.
|
|
610
611
|
|
|
611
|
-
|
|
612
|
+
A request made after `aclose()` reopens both and emits
|
|
613
|
+
`ClientReopenedWarning`. Idempotent.
|
|
612
614
|
"""
|
|
613
615
|
...
|
|
614
616
|
async def request( # type: ignore[override]
|
|
@@ -763,9 +765,16 @@ class StreamClosed(StreamError):
|
|
|
763
765
|
|
|
764
766
|
# Client lifecycle exceptions
|
|
765
767
|
class ClientClosed(RuntimeError):
|
|
766
|
-
"""
|
|
768
|
+
"""A request could not start because `close()` raced it.
|
|
769
|
+
|
|
770
|
+
Rare since 0.7.2: a request on a closed client reopens it instead.
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
class ClientReopenedWarning(ResourceWarning):
|
|
774
|
+
"""A request was made on a closed client, which was reopened with a fresh connection pool."""
|
|
767
775
|
|
|
768
776
|
_CLIENT_CLOSED_MSG: str
|
|
777
|
+
_CLIENT_REOPENED_MSG: str
|
|
769
778
|
|
|
770
779
|
# Other exceptions
|
|
771
780
|
class InvalidURL(Exception):
|
|
@@ -832,6 +841,7 @@ __all__ = [
|
|
|
832
841
|
"StreamClosed",
|
|
833
842
|
# Client lifecycle exceptions
|
|
834
843
|
"ClientClosed",
|
|
844
|
+
"ClientReopenedWarning",
|
|
835
845
|
# Other exceptions
|
|
836
846
|
"InvalidURL",
|
|
837
847
|
"CookieConflict",
|
|
@@ -11,7 +11,7 @@ keywords = [ "python", "request",]
|
|
|
11
11
|
classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules",]
|
|
12
12
|
# Version is set dynamically by CI from git tags (e.g., v1.2.3 -> 1.2.3).
|
|
13
13
|
# Do not edit manually. See .github/actions/set-version/ for details.
|
|
14
|
-
version = "0.7.
|
|
14
|
+
version = "0.7.2"
|
|
15
15
|
dependencies = []
|
|
16
16
|
[[project.authors]]
|
|
17
17
|
name = "thomasht86"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
use pyo3::create_exception;
|
|
2
|
-
use pyo3::exceptions::{PyException, PyRuntimeError};
|
|
2
|
+
use pyo3::exceptions::{PyException, PyResourceWarning, PyRuntimeError};
|
|
3
3
|
use pyo3::prelude::*;
|
|
4
4
|
|
|
5
5
|
use crate::timeout::TimedOut;
|
|
@@ -184,7 +184,13 @@ create_exception!(
|
|
|
184
184
|
httpr,
|
|
185
185
|
ClientClosed,
|
|
186
186
|
PyRuntimeError,
|
|
187
|
-
"
|
|
187
|
+
"Raised when a request cannot be started because the client was closed underneath it. Subclasses RuntimeError, matching httpx. Since 0.7.2 a request on a closed client reopens it instead (see `ClientReopenedWarning`), so this is only seen when `close()` races the request itself."
|
|
188
|
+
);
|
|
189
|
+
create_exception!(
|
|
190
|
+
httpr,
|
|
191
|
+
ClientReopenedWarning,
|
|
192
|
+
PyResourceWarning,
|
|
193
|
+
"A request was made on a client after `close()`; the client was reopened with a fresh connection pool. Filter this warning with `error` to make use-after-close raise instead."
|
|
188
194
|
);
|
|
189
195
|
|
|
190
196
|
// Other exceptions
|
|
@@ -405,6 +411,10 @@ pub fn register_exceptions(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
405
411
|
|
|
406
412
|
// Client lifecycle exceptions
|
|
407
413
|
m.add("ClientClosed", m.py().get_type::<ClientClosed>())?;
|
|
414
|
+
m.add(
|
|
415
|
+
"ClientReopenedWarning",
|
|
416
|
+
m.py().get_type::<ClientReopenedWarning>(),
|
|
417
|
+
)?;
|
|
408
418
|
|
|
409
419
|
// Other exceptions
|
|
410
420
|
m.add("InvalidURL", m.py().get_type::<InvalidURL>())?;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#![allow(clippy::too_many_arguments)]
|
|
2
|
+
use std::ffi::CStr;
|
|
2
3
|
use std::sync::{Arc, LazyLock, Mutex};
|
|
3
4
|
use std::time::Duration;
|
|
4
5
|
use std::{fs, str};
|
|
@@ -38,7 +39,7 @@ mod utils;
|
|
|
38
39
|
use utils::load_ca_certs;
|
|
39
40
|
|
|
40
41
|
mod exceptions;
|
|
41
|
-
use exceptions::{map_anyhow_error, map_reqwest_error, ClientClosed};
|
|
42
|
+
use exceptions::{map_anyhow_error, map_reqwest_error, ClientClosed, ClientReopenedWarning};
|
|
42
43
|
|
|
43
44
|
mod lifecycle;
|
|
44
45
|
use lifecycle::ClientState;
|
|
@@ -58,6 +59,7 @@ static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
|
|
|
58
59
|
|
|
59
60
|
/// Error message for any operation on a client after `close()`; mirrors httpx.
|
|
60
61
|
const CLIENT_CLOSED_MSG: &str = "Cannot send a request, as the client has been closed.";
|
|
62
|
+
const CLIENT_REOPENED_MSG: &CStr = c"Request made on a closed httpr client; reopening it with a fresh connection pool. Keep the client open for as long as it is used (a generator or stream created inside a `with` block runs after the block has closed the client).";
|
|
61
63
|
|
|
62
64
|
/// The constructor settings a rebuilt `reqwest::Client` has to carry over, so
|
|
63
65
|
/// that assigning `client.proxy` keeps TLS verification, the CA bundle, the
|
|
@@ -150,8 +152,40 @@ pub struct RClient {
|
|
|
150
152
|
|
|
151
153
|
impl RClient {
|
|
152
154
|
/// A handle to the underlying `reqwest::Client` plus the in-flight guard
|
|
153
|
-
/// for one request
|
|
154
|
-
|
|
155
|
+
/// for one request.
|
|
156
|
+
///
|
|
157
|
+
/// On a closed client this rebuilds the `reqwest::Client` from the
|
|
158
|
+
/// constructor settings, installs it with a fresh cancellation token and
|
|
159
|
+
/// emits `ClientReopenedWarning`, so a request after `close()` works the
|
|
160
|
+
/// way it does on a `requests.Session`. Under `warnings.simplefilter("error")`
|
|
161
|
+
/// the warning is raised instead and no request is sent. The rebuilt client
|
|
162
|
+
/// starts with an empty cookie store; default headers, params, auth, proxy
|
|
163
|
+
/// and timeout live on the `RClient` and carry over.
|
|
164
|
+
fn begin_request(&self, py: Python<'_>) -> PyResult<(reqwest::Client, lifecycle::InFlight)> {
|
|
165
|
+
if let Some(started) = self.state.begin_request() {
|
|
166
|
+
return Ok(started);
|
|
167
|
+
}
|
|
168
|
+
let default_headers = self
|
|
169
|
+
.headers
|
|
170
|
+
.lock()
|
|
171
|
+
.map_err(|e| map_anyhow_error(anyhow!("Failed to acquire headers lock: {}", e)))?
|
|
172
|
+
.clone();
|
|
173
|
+
let connects = CancellationToken::new();
|
|
174
|
+
let client = self.config.build(
|
|
175
|
+
lifecycle::CancelConnectsLayer::new(connects.clone()),
|
|
176
|
+
default_headers,
|
|
177
|
+
self.proxy.as_deref(),
|
|
178
|
+
)?;
|
|
179
|
+
// Losing to a concurrent reopen is fine: the slot is filled either way
|
|
180
|
+
// and the unused client here never had a connection to release.
|
|
181
|
+
self.state.reopen(client, connects);
|
|
182
|
+
PyErr::warn(
|
|
183
|
+
py,
|
|
184
|
+
&py.get_type::<ClientReopenedWarning>(),
|
|
185
|
+
CLIENT_REOPENED_MSG,
|
|
186
|
+
2,
|
|
187
|
+
)?;
|
|
188
|
+
// Only a `close()` racing in between can still leave this empty.
|
|
155
189
|
self.state
|
|
156
190
|
.begin_request()
|
|
157
191
|
.ok_or_else(|| ClientClosed::new_err(CLIENT_CLOSED_MSG))
|
|
@@ -579,7 +613,8 @@ impl RClient {
|
|
|
579
613
|
/// is dropped and its idle connections are released; a connect the old
|
|
580
614
|
/// pool still had pending in the background is left to resolve on its own
|
|
581
615
|
/// (it is torn down the next time the runtime is driven). Raises
|
|
582
|
-
///
|
|
616
|
+
/// On a closed client only the proxy is recorded; the request that reopens
|
|
617
|
+
/// the client builds with it.
|
|
583
618
|
#[setter]
|
|
584
619
|
pub fn set_proxy(&mut self, py: Python, proxy: Option<String>) -> PyResult<()> {
|
|
585
620
|
let default_headers = self
|
|
@@ -592,14 +627,15 @@ impl RClient {
|
|
|
592
627
|
default_headers,
|
|
593
628
|
proxy.as_deref(),
|
|
594
629
|
)?;
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
630
|
+
// On a closed client there is nothing to swap; the proxy is recorded
|
|
631
|
+
// and the rebuild on the next request picks it up.
|
|
632
|
+
py.detach(|| self.state.replace(new_client));
|
|
598
633
|
self.proxy = proxy;
|
|
599
634
|
Ok(())
|
|
600
635
|
}
|
|
601
636
|
|
|
602
|
-
/// Whether `close()` has been called
|
|
637
|
+
/// Whether the client is currently closed: `close()` has been called and no
|
|
638
|
+
/// request has reopened it since.
|
|
603
639
|
#[getter]
|
|
604
640
|
pub fn is_closed(&self) -> bool {
|
|
605
641
|
self.state.is_closed()
|
|
@@ -612,8 +648,9 @@ impl RClient {
|
|
|
612
648
|
/// returns. Requests already in flight hold their own handle to the pool
|
|
613
649
|
/// and finish normally; while any of them is running the pool, including
|
|
614
650
|
/// its idle connections, stays alive, and the last one to finish releases
|
|
615
|
-
/// it.
|
|
616
|
-
/// `
|
|
651
|
+
/// it. A later request on this client reopens it with a fresh pool and
|
|
652
|
+
/// emits `ClientReopenedWarning` (see `begin_request`). Calling `close()`
|
|
653
|
+
/// more than once is a no-op.
|
|
617
654
|
pub fn close(&self, py: Python) {
|
|
618
655
|
py.detach(|| self.state.close());
|
|
619
656
|
}
|
|
@@ -672,7 +709,7 @@ impl RClient {
|
|
|
672
709
|
auth_bearer: Option<String>,
|
|
673
710
|
timeout: Option<f64>,
|
|
674
711
|
) -> PyResult<Response> {
|
|
675
|
-
let (client, in_flight) = self.begin_request()?;
|
|
712
|
+
let (client, in_flight) = self.begin_request(py)?;
|
|
676
713
|
let (builder, timeout) = self.build_request(
|
|
677
714
|
client,
|
|
678
715
|
method,
|
|
@@ -767,7 +804,7 @@ impl RClient {
|
|
|
767
804
|
auth_bearer: Option<String>,
|
|
768
805
|
timeout: Option<f64>,
|
|
769
806
|
) -> PyResult<StreamingResponse> {
|
|
770
|
-
let (client, in_flight) = self.begin_request()?;
|
|
807
|
+
let (client, in_flight) = self.begin_request(py)?;
|
|
771
808
|
let (builder, timeout) = self.build_request(
|
|
772
809
|
client,
|
|
773
810
|
method,
|
|
@@ -828,6 +865,10 @@ fn httpr(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
828
865
|
m.add_class::<TextIterator>()?;
|
|
829
866
|
m.add_class::<LineIterator>()?;
|
|
830
867
|
m.add("_CLIENT_CLOSED_MSG", CLIENT_CLOSED_MSG)?;
|
|
868
|
+
m.add(
|
|
869
|
+
"_CLIENT_REOPENED_MSG",
|
|
870
|
+
CLIENT_REOPENED_MSG.to_str().expect("message is ASCII"),
|
|
871
|
+
)?;
|
|
831
872
|
|
|
832
873
|
// Register all exception types
|
|
833
874
|
exceptions::register_exceptions(m)?;
|
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
//! A client that never started a request has nothing to release: its pool
|
|
29
29
|
//! holds no connection and its connector has no connect pending, so closing
|
|
30
30
|
//! or dropping it skips the whole procedure.
|
|
31
|
+
//!
|
|
32
|
+
//! A closed client is not dead. A request made after `close()` rebuilds the
|
|
33
|
+
//! `reqwest::Client` from the constructor settings and carries on with a fresh
|
|
34
|
+
//! pool (`requests.Session` semantics), after warning. [`ClientState::reopen`]
|
|
35
|
+
//! installs the rebuilt client together with a new cancellation token, since
|
|
36
|
+
//! the old token was cancelled by the close and would kill every connect of the
|
|
37
|
+
//! new pool on sight.
|
|
31
38
|
|
|
32
39
|
use std::fmt;
|
|
33
40
|
use std::future::Future;
|
|
@@ -83,8 +90,9 @@ pub struct ClientState {
|
|
|
83
90
|
/// Whether any request was ever started. Until then the pool cannot hold a
|
|
84
91
|
/// connection or a pending connect, so there is nothing to release.
|
|
85
92
|
used: AtomicBool,
|
|
86
|
-
/// Cancels every connect still pending in the pool's connector.
|
|
87
|
-
|
|
93
|
+
/// Cancels every connect still pending in the pool's connector. Replaced
|
|
94
|
+
/// together with the client on [`ClientState::reopen`].
|
|
95
|
+
connects: Mutex<CancellationToken>,
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
impl ClientState {
|
|
@@ -95,14 +103,19 @@ impl ClientState {
|
|
|
95
103
|
client: Mutex::new(Some(client)),
|
|
96
104
|
in_flight: AtomicUsize::new(0),
|
|
97
105
|
used: AtomicBool::new(false),
|
|
98
|
-
connects,
|
|
106
|
+
connects: Mutex::new(connects),
|
|
99
107
|
})
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
/// The layer to install on
|
|
103
|
-
///
|
|
110
|
+
/// The layer to install on a `reqwest::ClientBuilder` whose client will
|
|
111
|
+
/// replace the current one via [`ClientState::replace`], so `close()` can
|
|
112
|
+
/// cancel its pending connects.
|
|
104
113
|
pub fn connector_layer(&self) -> CancelConnectsLayer {
|
|
105
|
-
CancelConnectsLayer(self.
|
|
114
|
+
CancelConnectsLayer(self.token().clone())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn token(&self) -> MutexGuard<'_, CancellationToken> {
|
|
118
|
+
self.connects.lock().unwrap_or_else(|e| e.into_inner())
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
/// The slot holding the client. A poisoned lock is recovered rather than
|
|
@@ -151,6 +164,24 @@ impl ClientState {
|
|
|
151
164
|
true
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
/// Bring a closed client back with a rebuilt `reqwest::Client`. `connects`
|
|
168
|
+
/// must be the fresh token its [`CancelConnectsLayer`] was created from; the
|
|
169
|
+
/// previous token was cancelled by `close()` and must not be reused. Returns
|
|
170
|
+
/// `false`, leaving `new` unused, if the client is open (another thread
|
|
171
|
+
/// reopened it first, or it was never closed).
|
|
172
|
+
pub fn reopen(&self, new: reqwest::Client, connects: CancellationToken) -> bool {
|
|
173
|
+
let mut slot = self.slot();
|
|
174
|
+
if slot.is_some() {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
*slot = Some(new);
|
|
178
|
+
*self.token() = connects;
|
|
179
|
+
// The new pool has no connection and nothing pending; `used` describes
|
|
180
|
+
// the pool in the slot, so it starts over.
|
|
181
|
+
self.used.store(false, Ordering::SeqCst);
|
|
182
|
+
true
|
|
183
|
+
}
|
|
184
|
+
|
|
154
185
|
/// Close the client. Returns `false` if it was already closed.
|
|
155
186
|
pub fn close(&self) -> bool {
|
|
156
187
|
let Some(client) = self.slot().take() else {
|
|
@@ -174,7 +205,7 @@ impl ClientState {
|
|
|
174
205
|
// alive. With requests in flight this is skipped: their connects
|
|
175
206
|
// must not be interrupted, and the last of them to finish will
|
|
176
207
|
// cancel instead (see `InFlight`).
|
|
177
|
-
self.
|
|
208
|
+
self.token().cancel();
|
|
178
209
|
}
|
|
179
210
|
settle_dropped_pool();
|
|
180
211
|
}
|
|
@@ -32,12 +32,12 @@ async def test_asyncclient_init(base_url_ssl, ca_bundle):
|
|
|
32
32
|
def test_default_max_concurrency():
|
|
33
33
|
client = httpr.AsyncClient()
|
|
34
34
|
assert client.max_concurrency == httpr.DEFAULT_MAX_CONCURRENCY
|
|
35
|
-
assert client.
|
|
35
|
+
assert client._dispatch_executor()._max_workers == httpr.DEFAULT_MAX_CONCURRENCY
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def test_max_concurrency_sizes_the_pool():
|
|
39
39
|
client = httpr.AsyncClient(max_concurrency=7)
|
|
40
|
-
assert client.
|
|
40
|
+
assert client._dispatch_executor()._max_workers == 7
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
@pytest.mark.asyncio
|
|
@@ -55,7 +55,7 @@ async def test_requests_run_on_the_clients_own_pool(base_url):
|
|
|
55
55
|
@pytest.mark.asyncio
|
|
56
56
|
async def test_max_concurrency_none_uses_default_executor():
|
|
57
57
|
client = httpr.AsyncClient(max_concurrency=None)
|
|
58
|
-
assert client.
|
|
58
|
+
assert client._dispatch_executor() is None
|
|
59
59
|
thread_name = await client._run_sync_asyncio(lambda: threading.current_thread().name)
|
|
60
60
|
assert thread_name.startswith("asyncio_")
|
|
61
61
|
|
|
@@ -97,15 +97,12 @@ async def test_concurrency_is_not_capped_by_the_default_executor():
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
@pytest.mark.asyncio
|
|
100
|
-
async def
|
|
101
|
-
"""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
httpr client and re-enter the same wrapper object (pyvespa's VespaSync /
|
|
107
|
-
VespaAsync without an external session) now see ClientClosed on the second
|
|
108
|
-
entry; wrappers given an external client never close it and are unaffected.
|
|
100
|
+
async def test_client_is_reusable_across_context_blocks(base_url):
|
|
101
|
+
"""Leaving `async with` closes the client (issue #88); using it again reopens it.
|
|
102
|
+
|
|
103
|
+
0.7.0 and 0.7.1 raised ClientClosed here, which broke every released pyvespa
|
|
104
|
+
(VespaSync/VespaAsync re-enter the same wrapper, and `visit()` generators
|
|
105
|
+
run after the block). Since 0.7.2 the request reopens the client and warns.
|
|
109
106
|
See tests/unit/test_close.py for the full contract.
|
|
110
107
|
"""
|
|
111
108
|
client = httpr.AsyncClient(max_concurrency=2)
|
|
@@ -114,7 +111,9 @@ async def test_client_is_reusable_until_closed(base_url):
|
|
|
114
111
|
|
|
115
112
|
async with client:
|
|
116
113
|
assert (await client.get(f"{base_url}/anything")).status_code == 200
|
|
114
|
+
assert client.is_closed
|
|
117
115
|
|
|
116
|
+
with pytest.warns(httpr.ClientReopenedWarning):
|
|
117
|
+
async with client:
|
|
118
|
+
assert (await client.get(f"{base_url}/anything")).status_code == 200
|
|
118
119
|
assert client.is_closed
|
|
119
|
-
with pytest.raises(httpr.ClientClosed):
|
|
120
|
-
await client.get(f"{base_url}/anything")
|
|
@@ -11,6 +11,7 @@ import asyncio
|
|
|
11
11
|
import gc
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
import warnings
|
|
14
15
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
16
|
|
|
16
17
|
import pytest
|
|
@@ -129,29 +130,73 @@ def test_context_manager_exit_releases_pooled_connections(server):
|
|
|
129
130
|
assert server.wait_for_open_connections(0), "__exit__ must release the pooled connection"
|
|
130
131
|
|
|
131
132
|
|
|
132
|
-
def
|
|
133
|
+
def test_request_after_close_reopens_with_warning(server):
|
|
134
|
+
"""Use after close reopens the client with a fresh pool (requests.Session semantics)."""
|
|
133
135
|
client = httpr.Client()
|
|
134
136
|
client.get(server.url)
|
|
135
137
|
client.close()
|
|
138
|
+
assert server.wait_for_open_connections(0)
|
|
136
139
|
|
|
137
|
-
with pytest.
|
|
138
|
-
client.get(server.url)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
with pytest.raises(httpr.ClientClosed):
|
|
142
|
-
with client.stream("GET", server.url):
|
|
143
|
-
pass
|
|
140
|
+
with pytest.warns(httpr.ClientReopenedWarning, match="reopening"):
|
|
141
|
+
assert client.get(server.url).status_code == 200
|
|
142
|
+
assert client.is_closed is False
|
|
143
|
+
assert server.wait_for_open_connections(1), "the reopened client pools its connection again"
|
|
144
144
|
|
|
145
|
-
#
|
|
146
|
-
|
|
145
|
+
# Only the request that reopens warns; the client is open again afterwards.
|
|
146
|
+
with warnings.catch_warnings():
|
|
147
|
+
warnings.simplefilter("error")
|
|
148
|
+
assert client.request("GET", server.url).status_code == 200
|
|
149
|
+
with client.stream("GET", server.url) as response:
|
|
150
|
+
assert response.status_code == 200
|
|
151
|
+
assert server.requests_served == 4
|
|
152
|
+
|
|
153
|
+
client.close()
|
|
154
|
+
assert client.is_closed
|
|
155
|
+
assert server.wait_for_open_connections(0), "close() releases the reopened pool as well"
|
|
147
156
|
|
|
148
157
|
|
|
149
|
-
def
|
|
150
|
-
"""httpx raises RuntimeError on use-after-close; code written for it keeps working."""
|
|
158
|
+
def test_stream_after_close_reopens(server):
|
|
151
159
|
client = httpr.Client()
|
|
152
160
|
client.close()
|
|
153
|
-
with pytest.
|
|
154
|
-
client.
|
|
161
|
+
with pytest.warns(httpr.ClientReopenedWarning):
|
|
162
|
+
with client.stream("GET", server.url) as response:
|
|
163
|
+
assert response.read()
|
|
164
|
+
assert not client.is_closed
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_generator_consumed_after_with_block(server):
|
|
168
|
+
"""The pyvespa pattern: a lazy generator created inside `with`, consumed after it.
|
|
169
|
+
|
|
170
|
+
Every released pyvespa does this in `Vespa.visit()`; 0.7.0 and 0.7.1 broke it.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def pages(client):
|
|
174
|
+
for _ in range(3):
|
|
175
|
+
yield client.get(server.url).status_code
|
|
176
|
+
|
|
177
|
+
with httpr.Client() as client:
|
|
178
|
+
gen = pages(client)
|
|
179
|
+
assert client.is_closed
|
|
180
|
+
with pytest.warns(httpr.ClientReopenedWarning):
|
|
181
|
+
assert list(gen) == [200, 200, 200]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_reopened_warning_can_be_made_an_error(server):
|
|
185
|
+
"""Filtering the warning as an error restores httpx's strict use-after-close."""
|
|
186
|
+
client = httpr.Client()
|
|
187
|
+
client.get(server.url)
|
|
188
|
+
client.close()
|
|
189
|
+
with warnings.catch_warnings():
|
|
190
|
+
warnings.simplefilter("error", httpr.ClientReopenedWarning)
|
|
191
|
+
with pytest.raises(httpr.ClientReopenedWarning):
|
|
192
|
+
client.get(server.url)
|
|
193
|
+
assert server.requests_served == 1, "no request is sent when the warning is an error"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_reopened_warning_is_a_resource_warning():
|
|
197
|
+
assert issubclass(httpr.ClientReopenedWarning, ResourceWarning)
|
|
198
|
+
# Kept for code written against 0.7.0/0.7.1.
|
|
199
|
+
assert issubclass(httpr.ClientClosed, RuntimeError)
|
|
155
200
|
|
|
156
201
|
|
|
157
202
|
def test_close_is_idempotent(server):
|
|
@@ -171,20 +216,27 @@ def test_is_closed_property():
|
|
|
171
216
|
assert client.is_closed is True
|
|
172
217
|
|
|
173
218
|
|
|
174
|
-
def test_close_never_used_client():
|
|
219
|
+
def test_close_never_used_client(server):
|
|
175
220
|
client = httpr.Client()
|
|
176
221
|
client.close()
|
|
177
222
|
assert client.is_closed
|
|
178
223
|
client.close() # still a no-op
|
|
179
|
-
with pytest.
|
|
180
|
-
client.get(
|
|
224
|
+
with pytest.warns(httpr.ClientReopenedWarning):
|
|
225
|
+
assert client.get(server.url).status_code == 200
|
|
181
226
|
|
|
182
227
|
|
|
183
|
-
def
|
|
228
|
+
def test_proxy_setter_after_close_applies_on_reopen(server):
|
|
229
|
+
"""Assigning the proxy on a closed client is recorded and used by the rebuild."""
|
|
184
230
|
client = httpr.Client()
|
|
231
|
+
client.get(server.url)
|
|
185
232
|
client.close()
|
|
186
|
-
|
|
187
|
-
|
|
233
|
+
client.proxy = "http://127.0.0.1:1"
|
|
234
|
+
assert client.is_closed, "assigning a proxy does not reopen the client by itself"
|
|
235
|
+
assert client.proxy == "http://127.0.0.1:1"
|
|
236
|
+
with pytest.warns(httpr.ClientReopenedWarning), pytest.raises(httpr.HTTPError):
|
|
237
|
+
client.get(server.url) # nothing listens on port 1
|
|
238
|
+
client.proxy = None
|
|
239
|
+
assert client.get(server.url).status_code == 200
|
|
188
240
|
|
|
189
241
|
|
|
190
242
|
def test_headers_still_readable_after_close():
|
|
@@ -289,10 +341,12 @@ async def test_aclose_releases_connections_and_shuts_down_executor(server):
|
|
|
289
341
|
assert (await client.get(server.url)).status_code == 200
|
|
290
342
|
assert server.wait_for_open_connections(1)
|
|
291
343
|
|
|
344
|
+
executor = client._executor
|
|
292
345
|
await client.aclose()
|
|
293
346
|
|
|
294
347
|
assert client.is_closed
|
|
295
|
-
assert
|
|
348
|
+
assert executor._shutdown, "aclose() must shut down the client's own thread pool"
|
|
349
|
+
assert client._executor is None
|
|
296
350
|
assert server.wait_for_open_connections(0)
|
|
297
351
|
|
|
298
352
|
|
|
@@ -303,22 +357,27 @@ async def test_async_context_manager_exit_closes(server):
|
|
|
303
357
|
assert server.wait_for_open_connections(1)
|
|
304
358
|
|
|
305
359
|
assert client.is_closed
|
|
306
|
-
assert client._executor
|
|
360
|
+
assert client._executor is None
|
|
307
361
|
assert server.wait_for_open_connections(0)
|
|
308
362
|
|
|
309
363
|
|
|
310
364
|
@pytest.mark.asyncio
|
|
311
|
-
async def
|
|
312
|
-
client = httpr.AsyncClient()
|
|
365
|
+
async def test_async_request_after_aclose_reopens(server):
|
|
366
|
+
client = httpr.AsyncClient(max_concurrency=4)
|
|
313
367
|
await client.get(server.url)
|
|
314
368
|
await client.aclose()
|
|
315
369
|
|
|
316
|
-
with pytest.
|
|
317
|
-
await client.get(server.url)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
370
|
+
with pytest.warns(httpr.ClientReopenedWarning):
|
|
371
|
+
assert (await client.get(server.url)).status_code == 200
|
|
372
|
+
assert not client.is_closed
|
|
373
|
+
assert client._executor is not None and not client._executor._shutdown, "a new thread pool"
|
|
374
|
+
with warnings.catch_warnings():
|
|
375
|
+
warnings.simplefilter("error")
|
|
376
|
+
async with client.stream("GET", server.url) as response:
|
|
377
|
+
assert await response.aread()
|
|
378
|
+
assert server.requests_served == 3
|
|
379
|
+
await client.aclose()
|
|
380
|
+
assert server.wait_for_open_connections(0)
|
|
322
381
|
|
|
323
382
|
|
|
324
383
|
@pytest.mark.asyncio
|
|
@@ -330,12 +389,12 @@ async def test_aclose_is_idempotent():
|
|
|
330
389
|
|
|
331
390
|
|
|
332
391
|
@pytest.mark.asyncio
|
|
333
|
-
async def test_aclose_never_used_client():
|
|
392
|
+
async def test_aclose_never_used_client(server):
|
|
334
393
|
client = httpr.AsyncClient()
|
|
335
394
|
await client.aclose()
|
|
336
395
|
assert client.is_closed
|
|
337
|
-
with pytest.
|
|
338
|
-
await client.get(
|
|
396
|
+
with pytest.warns(httpr.ClientReopenedWarning):
|
|
397
|
+
assert (await client.get(server.url)).status_code == 200
|
|
339
398
|
|
|
340
399
|
|
|
341
400
|
@pytest.mark.asyncio
|
|
@@ -130,8 +130,10 @@ def test_client_identity_survives_rebuild(tmp_path):
|
|
|
130
130
|
client.proxy = "http://127.0.0.1:1"
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
def
|
|
134
|
-
client
|
|
133
|
+
def test_proxy_setter_after_close_is_recorded():
|
|
134
|
+
"""A closed client has no reqwest client to rebuild; the proxy is kept for the reopen."""
|
|
135
|
+
client = httpr.Client(proxy="http://127.0.0.1:1")
|
|
135
136
|
client.close()
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
client.proxy = None
|
|
138
|
+
assert client.proxy is None
|
|
139
|
+
assert client.is_closed
|
|
@@ -343,14 +343,14 @@ class TestStreamingAsyncClient:
|
|
|
343
343
|
async with client.stream("INVALID", f"{base_url_ssl}/get") as _: # type: ignore[arg-type]
|
|
344
344
|
pass
|
|
345
345
|
|
|
346
|
-
async def
|
|
347
|
-
"""
|
|
346
|
+
async def test_async_stream_survives_client_close(self, base_url_ssl, ca_bundle):
|
|
347
|
+
"""An open stream holds its own handle to the pool and reads to the end after aclose()."""
|
|
348
348
|
client = httpr.AsyncClient(ca_cert_file=ca_bundle)
|
|
349
349
|
async with client.stream("GET", f"{base_url_ssl}/html") as response:
|
|
350
350
|
await client.aclose()
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
351
|
+
body = b"".join([chunk async for chunk in response.aiter_bytes()])
|
|
352
|
+
assert b"<html" in body.lower()
|
|
353
|
+
assert client.is_closed, "reading an in-flight stream does not reopen the client"
|
|
354
354
|
|
|
355
355
|
# -- Issue #85: iteration must not block the event loop ---------------------
|
|
356
356
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|