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.
Files changed (91) hide show
  1. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/CI.yml +47 -0
  2. {httpr-0.7.1 → httpr-0.7.2}/CLAUDE.md +4 -3
  3. {httpr-0.7.1 → httpr-0.7.2}/Cargo.lock +1 -1
  4. {httpr-0.7.1 → httpr-0.7.2}/Cargo.toml +1 -1
  5. {httpr-0.7.1 → httpr-0.7.2}/PKG-INFO +2 -2
  6. {httpr-0.7.1 → httpr-0.7.2}/README.md +1 -1
  7. {httpr-0.7.1 → httpr-0.7.2}/docs/api/response.md +1 -1
  8. {httpr-0.7.1 → httpr-0.7.2}/httpr/__init__.py +35 -31
  9. {httpr-0.7.1 → httpr-0.7.2}/httpr/httpr.pyi +13 -3
  10. {httpr-0.7.1 → httpr-0.7.2}/pyproject.toml +1 -1
  11. {httpr-0.7.1 → httpr-0.7.2}/src/exceptions.rs +12 -2
  12. {httpr-0.7.1 → httpr-0.7.2}/src/lib.rs +53 -12
  13. {httpr-0.7.1 → httpr-0.7.2}/src/lifecycle.rs +38 -7
  14. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_asyncclient.py +13 -14
  15. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_close.py +92 -33
  16. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_proxy.py +6 -4
  17. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_streaming.py +5 -5
  18. {httpr-0.7.1 → httpr-0.7.2}/.github/actions/set-version/action.yml +0 -0
  19. {httpr-0.7.1 → httpr-0.7.2}/.github/copilot-instructions.md +0 -0
  20. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/benchmark.yml +0 -0
  21. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/codspeed.yml +0 -0
  22. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/compare.yml +0 -0
  23. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/copilot-setup-steps.yml +0 -0
  24. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/mkdocs.yml +0 -0
  25. {httpr-0.7.1 → httpr-0.7.2}/.github/workflows/set_version.py +0 -0
  26. {httpr-0.7.1 → httpr-0.7.2}/.gitignore +0 -0
  27. {httpr-0.7.1 → httpr-0.7.2}/.pre-commit-config.yaml +0 -0
  28. {httpr-0.7.1 → httpr-0.7.2}/LICENSE +0 -0
  29. {httpr-0.7.1 → httpr-0.7.2}/Taskfile.yaml +0 -0
  30. {httpr-0.7.1 → httpr-0.7.2}/benchmark/README.md +0 -0
  31. {httpr-0.7.1 → httpr-0.7.2}/benchmark/__init__.py +0 -0
  32. {httpr-0.7.1 → httpr-0.7.2}/benchmark/benchmark.py +0 -0
  33. {httpr-0.7.1 → httpr-0.7.2}/benchmark/benchmark_cbor.py +0 -0
  34. {httpr-0.7.1 → httpr-0.7.2}/benchmark/render_comparison.py +0 -0
  35. {httpr-0.7.1 → httpr-0.7.2}/benchmark/server.py +0 -0
  36. {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/cookies.md +0 -0
  37. {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/index.md +0 -0
  38. {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/proxy.md +0 -0
  39. {httpr-0.7.1 → httpr-0.7.2}/docs/advanced/ssl-tls.md +0 -0
  40. {httpr-0.7.1 → httpr-0.7.2}/docs/api/async-client.md +0 -0
  41. {httpr-0.7.1 → httpr-0.7.2}/docs/api/client.md +0 -0
  42. {httpr-0.7.1 → httpr-0.7.2}/docs/api/functions.md +0 -0
  43. {httpr-0.7.1 → httpr-0.7.2}/docs/api/index.md +0 -0
  44. {httpr-0.7.1 → httpr-0.7.2}/docs/benchmark.md +0 -0
  45. {httpr-0.7.1 → httpr-0.7.2}/docs/index.md +0 -0
  46. {httpr-0.7.1 → httpr-0.7.2}/docs/quickstart.md +0 -0
  47. {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/async.md +0 -0
  48. {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/authentication.md +0 -0
  49. {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/index.md +0 -0
  50. {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/making-requests.md +0 -0
  51. {httpr-0.7.1 → httpr-0.7.2}/docs/tutorial/response-handling.md +0 -0
  52. {httpr-0.7.1 → httpr-0.7.2}/docs/writings/index.md +0 -0
  53. {httpr-0.7.1 → httpr-0.7.2}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  54. {httpr-0.7.1 → httpr-0.7.2}/httpr/py.typed +0 -0
  55. {httpr-0.7.1 → httpr-0.7.2}/mkdocs.yml +0 -0
  56. {httpr-0.7.1 → httpr-0.7.2}/scripts/generate_certs.py +0 -0
  57. {httpr-0.7.1 → httpr-0.7.2}/src/params.rs +0 -0
  58. {httpr-0.7.1 → httpr-0.7.2}/src/response.rs +0 -0
  59. {httpr-0.7.1 → httpr-0.7.2}/src/timeout.rs +0 -0
  60. {httpr-0.7.1 → httpr-0.7.2}/src/traits.rs +0 -0
  61. {httpr-0.7.1 → httpr-0.7.2}/src/utils.rs +0 -0
  62. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/__init__.py +0 -0
  63. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/README.md +0 -0
  64. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/__init__.py +0 -0
  65. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/bench_server.py +0 -0
  66. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/conftest.py +0 -0
  67. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/test_decoding.py +0 -0
  68. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/codspeed/test_transport.py +0 -0
  69. {httpr-0.7.1 → httpr-0.7.2}/tests/benchmark/test_performance.py +0 -0
  70. {httpr-0.7.1 → httpr-0.7.2}/tests/conftest.py +0 -0
  71. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/__init__.py +0 -0
  72. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_async.py +0 -0
  73. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_auth.py +0 -0
  74. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_redirects.py +0 -0
  75. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_ssl.py +0 -0
  76. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_streaming.py +0 -0
  77. {httpr-0.7.1 → httpr-0.7.2}/tests/e2e/test_uploads.py +0 -0
  78. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/__init__.py +0 -0
  79. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/cbor_test_server.py +0 -0
  80. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/httpx_conns.py +0 -0
  81. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_ca_bundle.py +0 -0
  82. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_cbor.py +0 -0
  83. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_client.py +0 -0
  84. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_defs.py +0 -0
  85. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_docs.py +0 -0
  86. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_exceptions.py +0 -0
  87. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_params.py +0 -0
  88. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_response.py +0 -0
  89. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_ssl.py +0 -0
  90. {httpr-0.7.1 → httpr-0.7.2}/tests/unit/test_timeout.py +0 -0
  91. {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 raises `ClientClosed`, a `RuntimeError` subclass (httpx raises plain `RuntimeError` here); header/cookie getters keep working. `is_closed` getter mirrors httpx. The message lives once in Rust (`CLIENT_CLOSED_MSG`, exported as `_CLIENT_CLOSED_MSG`)
198
- - Python: `Client.close()`/`__exit__` call the Rust `close()`; `AsyncClient.aclose()`/`__aexit__` additionally `shutdown(wait=False)` the client's own `ThreadPoolExecutor`, and run on the event-loop thread on purpose (sub-millisecond, no I/O wait). `_run_sync_asyncio` checks `is_closed` first and also maps the executor's "cannot schedule new futures after shutdown" `RuntimeError` to `ClientClosed`, which covers a `close()` racing in from another OS thread
199
- - Leaving a `with`/`async with` block closes the client; re-entering it afterwards raises `ClientClosed` on the next request. pyvespa's `VespaSync`/`VespaAsync` given an external session never close it and are unaffected; with an owned client they close but never null it, so re-entering the same wrapper object raises `ClientClosed` (see memory note; needs a pyvespa-side fix)
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
@@ -516,7 +516,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
516
516
 
517
517
  [[package]]
518
518
  name = "httpr"
519
- version = "0.7.1"
519
+ version = "0.7.2"
520
520
  dependencies = [
521
521
  "anyhow",
522
522
  "bytes",
@@ -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.1"
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.1
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. Any request made afterwards, or assigning `client.proxy`, raises `httpr.ClientClosed` (a `RuntimeError`, as in httpx); `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.
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. Any request made afterwards, or assigning `client.proxy`, raises `httpr.ClientClosed` (a `RuntimeError`, as in httpx); `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.
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. After `client.aclose()`, the next async step raises `httpr.ClientClosed`.
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. Any request made after `close()` raises
312
- `httpr.ClientClosed` (a `RuntimeError`, as in httpx). Calling `close()`
313
- more than once is a no-op.
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. `_run_sync_asyncio` maps a
742
- # closed client to ClientClosed.
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
- # Threads are created on demand; `close()`/`aclose()` shut the pool down.
900
- self._executor = (
901
- None
902
- if max_concurrency is None
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
- if self._executor is not None:
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; queued ones raise ClientClosed when they
926
- # run. Not waiting keeps this safe to call from the event-loop thread.
927
- self._executor.shutdown(wait=False)
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
- Any request made after `aclose()` raises `httpr.ClientClosed`. Calling it
935
- more than once is a no-op.
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._executor, partial(fn, *args, **kwargs))
966
+ future = loop.run_in_executor(self._dispatch_executor(), call)
961
967
  except RuntimeError:
962
- # The executor is only ever shut down by close()/aclose(), so if one
963
- # landed between the check above and submit (from another thread),
964
- # report it as the client being closed rather than leaking the
965
- # executor's own error.
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
- Requests made after `close()` raise `ClientClosed`. Idempotent.
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
- Requests made after `aclose()` raise `ClientClosed`. Idempotent.
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
- """Attempted to use a client after `close()` was called."""
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.1"
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
- "Attempted to use a client after `close()` was called. Subclasses RuntimeError, matching httpx."
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, or `ClientClosed` if `close()` has been called.
154
- fn begin_request(&self) -> PyResult<(reqwest::Client, lifecycle::InFlight)> {
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
- /// `ClientClosed` on a closed client.
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
- if !py.detach(|| self.state.replace(new_client)) {
596
- return Err(ClientClosed::new_err(CLIENT_CLOSED_MSG));
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 on this client.
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. Any later request on this client raises `ClientClosed`. Calling
616
- /// `close()` more than once is a no-op.
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
- connects: CancellationToken,
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 any `reqwest::ClientBuilder` whose client will
103
- /// live in this state, so `close()` can cancel its pending connects.
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.connects.clone())
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.connects.cancel();
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._executor._max_workers == httpr.DEFAULT_MAX_CONCURRENCY
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._executor._max_workers == 7
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._executor is None
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 test_client_is_reusable_until_closed(base_url):
101
- """One client serves many requests; leaving `async with` closes it (issue #88).
102
-
103
- This replaces a test that asserted the opposite -- that a client kept working
104
- after `aclose()` and across repeated `async with` blocks -- which only held
105
- because `aclose()` used to be a no-op. Downstream wrappers that own their
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 test_request_after_close_raises_client_closed(server):
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.raises(httpr.ClientClosed, match="client has been closed"):
138
- client.get(server.url)
139
- with pytest.raises(httpr.ClientClosed):
140
- client.request("GET", server.url)
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
- # Nothing reached the server.
146
- assert server.requests_served == 1
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 test_client_closed_is_a_runtime_error(server):
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.raises(RuntimeError):
154
- client.get(server.url)
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.raises(httpr.ClientClosed):
180
- client.get("http://127.0.0.1:9/")
224
+ with pytest.warns(httpr.ClientReopenedWarning):
225
+ assert client.get(server.url).status_code == 200
181
226
 
182
227
 
183
- def test_proxy_setter_after_close_raises():
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
- with pytest.raises(httpr.ClientClosed):
187
- client.proxy = "http://127.0.0.1:1"
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 client._executor._shutdown, "aclose() must shut down the client's own thread pool"
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._shutdown
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 test_async_request_after_aclose_raises_client_closed(server):
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.raises(httpr.ClientClosed, match="client has been closed"):
317
- await client.get(server.url)
318
- with pytest.raises(httpr.ClientClosed):
319
- async with client.stream("GET", server.url):
320
- pass
321
- assert server.requests_served == 1
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.raises(httpr.ClientClosed):
338
- await client.get("http://127.0.0.1:9/")
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 test_proxy_setter_after_close_raises_client_closed():
134
- client = httpr.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
- with pytest.raises(httpr.ClientClosed):
137
- client.proxy = None
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 test_async_stream_after_close_raises_client_closed(self, base_url_ssl, ca_bundle):
347
- """Once the client is closed, async iteration reports ClientClosed."""
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
- with pytest.raises(httpr.ClientClosed):
352
- async for _ in response.aiter_bytes():
353
- pass
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