httpr 0.7.2__tar.gz → 0.7.4__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 (93) hide show
  1. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/CI.yml +28 -13
  2. {httpr-0.7.2 → httpr-0.7.4}/CLAUDE.md +7 -1
  3. {httpr-0.7.2 → httpr-0.7.4}/Cargo.lock +1 -1
  4. {httpr-0.7.2 → httpr-0.7.4}/Cargo.toml +1 -1
  5. {httpr-0.7.2 → httpr-0.7.4}/PKG-INFO +6 -2
  6. {httpr-0.7.2 → httpr-0.7.4}/README.md +5 -1
  7. {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/proxy.md +1 -1
  8. {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/ssl-tls.md +16 -7
  9. {httpr-0.7.2 → httpr-0.7.4}/docs/api/index.md +1 -0
  10. {httpr-0.7.2 → httpr-0.7.4}/docs/api/response.md +18 -0
  11. {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/making-requests.md +15 -5
  12. {httpr-0.7.2 → httpr-0.7.4}/httpr/__init__.py +12 -1
  13. {httpr-0.7.2 → httpr-0.7.4}/httpr/httpr.pyi +17 -1
  14. {httpr-0.7.2 → httpr-0.7.4}/pyproject.toml +1 -1
  15. {httpr-0.7.2 → httpr-0.7.4}/src/lib.rs +37 -60
  16. {httpr-0.7.2 → httpr-0.7.4}/src/response.rs +72 -8
  17. httpr-0.7.4/tests/e2e/test_http_version.py +48 -0
  18. httpr-0.7.4/tests/unit/test_http_version.py +48 -0
  19. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_timeout.py +13 -11
  20. {httpr-0.7.2 → httpr-0.7.4}/.github/actions/set-version/action.yml +0 -0
  21. {httpr-0.7.2 → httpr-0.7.4}/.github/copilot-instructions.md +0 -0
  22. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/benchmark.yml +0 -0
  23. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/codspeed.yml +0 -0
  24. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/compare.yml +0 -0
  25. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/copilot-setup-steps.yml +0 -0
  26. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/mkdocs.yml +0 -0
  27. {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/set_version.py +0 -0
  28. {httpr-0.7.2 → httpr-0.7.4}/.gitignore +0 -0
  29. {httpr-0.7.2 → httpr-0.7.4}/.pre-commit-config.yaml +0 -0
  30. {httpr-0.7.2 → httpr-0.7.4}/LICENSE +0 -0
  31. {httpr-0.7.2 → httpr-0.7.4}/Taskfile.yaml +0 -0
  32. {httpr-0.7.2 → httpr-0.7.4}/benchmark/README.md +0 -0
  33. {httpr-0.7.2 → httpr-0.7.4}/benchmark/__init__.py +0 -0
  34. {httpr-0.7.2 → httpr-0.7.4}/benchmark/benchmark.py +0 -0
  35. {httpr-0.7.2 → httpr-0.7.4}/benchmark/benchmark_cbor.py +0 -0
  36. {httpr-0.7.2 → httpr-0.7.4}/benchmark/render_comparison.py +0 -0
  37. {httpr-0.7.2 → httpr-0.7.4}/benchmark/server.py +0 -0
  38. {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/cookies.md +0 -0
  39. {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/index.md +0 -0
  40. {httpr-0.7.2 → httpr-0.7.4}/docs/api/async-client.md +0 -0
  41. {httpr-0.7.2 → httpr-0.7.4}/docs/api/client.md +0 -0
  42. {httpr-0.7.2 → httpr-0.7.4}/docs/api/functions.md +0 -0
  43. {httpr-0.7.2 → httpr-0.7.4}/docs/benchmark.md +0 -0
  44. {httpr-0.7.2 → httpr-0.7.4}/docs/index.md +0 -0
  45. {httpr-0.7.2 → httpr-0.7.4}/docs/quickstart.md +0 -0
  46. {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/async.md +0 -0
  47. {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/authentication.md +0 -0
  48. {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/index.md +0 -0
  49. {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/response-handling.md +0 -0
  50. {httpr-0.7.2 → httpr-0.7.4}/docs/writings/index.md +0 -0
  51. {httpr-0.7.2 → httpr-0.7.4}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  52. {httpr-0.7.2 → httpr-0.7.4}/httpr/py.typed +0 -0
  53. {httpr-0.7.2 → httpr-0.7.4}/mkdocs.yml +0 -0
  54. {httpr-0.7.2 → httpr-0.7.4}/scripts/generate_certs.py +0 -0
  55. {httpr-0.7.2 → httpr-0.7.4}/src/exceptions.rs +0 -0
  56. {httpr-0.7.2 → httpr-0.7.4}/src/lifecycle.rs +0 -0
  57. {httpr-0.7.2 → httpr-0.7.4}/src/params.rs +0 -0
  58. {httpr-0.7.2 → httpr-0.7.4}/src/timeout.rs +0 -0
  59. {httpr-0.7.2 → httpr-0.7.4}/src/traits.rs +0 -0
  60. {httpr-0.7.2 → httpr-0.7.4}/src/utils.rs +0 -0
  61. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/__init__.py +0 -0
  62. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/README.md +0 -0
  63. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/__init__.py +0 -0
  64. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/bench_server.py +0 -0
  65. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/conftest.py +0 -0
  66. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/test_decoding.py +0 -0
  67. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/test_transport.py +0 -0
  68. {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/test_performance.py +0 -0
  69. {httpr-0.7.2 → httpr-0.7.4}/tests/conftest.py +0 -0
  70. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/__init__.py +0 -0
  71. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_async.py +0 -0
  72. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_auth.py +0 -0
  73. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_redirects.py +0 -0
  74. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_ssl.py +0 -0
  75. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_streaming.py +0 -0
  76. {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_uploads.py +0 -0
  77. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/__init__.py +0 -0
  78. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/cbor_test_server.py +0 -0
  79. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/httpx_conns.py +0 -0
  80. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_asyncclient.py +0 -0
  81. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_ca_bundle.py +0 -0
  82. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_cbor.py +0 -0
  83. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_client.py +0 -0
  84. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_close.py +0 -0
  85. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_defs.py +0 -0
  86. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_docs.py +0 -0
  87. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_exceptions.py +0 -0
  88. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_params.py +0 -0
  89. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_proxy.py +0 -0
  90. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_response.py +0 -0
  91. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_ssl.py +0 -0
  92. {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_streaming.py +0 -0
  93. {httpr-0.7.2 → httpr-0.7.4}/uv.lock +0 -0
@@ -190,13 +190,25 @@ jobs:
190
190
  matrix:
191
191
  platform:
192
192
  - runner: ubuntu-22.04
193
- target: x86_64
193
+ target: x86_64-unknown-linux-gnu
194
+ arch: x86_64
195
+ manylinux: auto
194
196
  - runner: ubuntu-22.04
195
- target: x86
197
+ target: i686-unknown-linux-gnu
198
+ arch: x86
199
+ manylinux: auto
196
200
  - runner: ubuntu-24.04-arm
197
- target: aarch64
201
+ target: aarch64-unknown-linux-gnu
202
+ arch: aarch64
203
+ manylinux: auto
198
204
  - runner: ubuntu-22.04
199
- target: armv7
205
+ target: armv7-unknown-linux-gnueabihf
206
+ arch: armv7
207
+ manylinux: auto
208
+ - runner: ubuntu-24.04-riscv
209
+ target: riscv64gc-unknown-linux-gnu
210
+ arch: riscv64
211
+ manylinux: 2_39
200
212
  # - runner: ubuntu-22.04
201
213
  # target: s390x
202
214
  # - runner: ubuntu-22.04
@@ -216,21 +228,21 @@ jobs:
216
228
  target: ${{ matrix.platform.target }}
217
229
  args: --release --out dist
218
230
  sccache: true
219
- manylinux: auto
231
+ manylinux: ${{ matrix.platform.manylinux }}
220
232
  - name: Build free-threaded wheels
221
233
  uses: PyO3/maturin-action@v1
222
234
  with:
223
235
  target: ${{ matrix.platform.target }}
224
236
  args: --release --out dist -i python3.14t
225
237
  sccache: true
226
- manylinux: auto
238
+ manylinux: ${{ matrix.platform.manylinux }}
227
239
  - name: Upload wheels
228
240
  uses: actions/upload-artifact@v6
229
241
  with:
230
- name: wheels-linux-${{ matrix.platform.target }}
242
+ name: wheels-linux-${{ matrix.platform.arch }}
231
243
  path: dist
232
244
  - name: pytest
233
- if: ${{ matrix.platform.target == 'x86_64' || matrix.platform.target == 'aarch64' }}
245
+ if: ${{ matrix.platform.arch == 'x86_64' || matrix.platform.arch == 'aarch64' }}
234
246
  shell: bash
235
247
  env:
236
248
  RUSTC_WRAPPER: ""
@@ -249,11 +261,14 @@ jobs:
249
261
  matrix:
250
262
  platform:
251
263
  - runner: ubuntu-22.04
252
- target: x86_64
264
+ target: x86_64-unknown-linux-musl
265
+ arch: x86_64
253
266
  - runner: ubuntu-22.04
254
- target: x86
267
+ target: i686-unknown-linux-musl
268
+ arch: x86
255
269
  - runner: ubuntu-24.04-arm
256
- target: aarch64
270
+ target: aarch64-unknown-linux-musl
271
+ arch: aarch64
257
272
  # - runner: ubuntu-22.04
258
273
  # target: armv7
259
274
  steps:
@@ -282,10 +297,10 @@ jobs:
282
297
  - name: Upload wheels
283
298
  uses: actions/upload-artifact@v6
284
299
  with:
285
- name: wheels-musllinux-${{ matrix.platform.target }}
300
+ name: wheels-musllinux-${{ matrix.platform.arch }}
286
301
  path: dist
287
302
  - name: pytest
288
- if: ${{ matrix.platform.target == 'x86_64' || matrix.platform.target == 'aarch64' }}
303
+ if: ${{ matrix.platform.arch == 'x86_64' || matrix.platform.arch == 'aarch64' }}
289
304
  uses: addnab/docker-run-action@v3
290
305
  with:
291
306
  image: alpine:latest
@@ -186,7 +186,13 @@ builds in debug mode and the numbers are meaningless.
186
186
 
187
187
  ### Proxy
188
188
  - Set via `proxy` param or `HTTPR_PROXY` env var; the env var is read once in `new()`, never in the setter
189
- - Changing `client.proxy` rebuilds the entire reqwest client (expensive). `RClient.config: ClientConfig` keeps the construction settings (loaded root certs, mTLS `Identity`, redirects, verify, https_only, http2_only, cookie_store, referer) and `ClientConfig::build()` is the single place a `reqwest::Client` is built, used by `new()` and `set_proxy()`; add any new builder setting there, not inline (issue #84). The setter takes `Option<String>`; `None` removes the proxy. The rebuilt client starts with an empty cookie store
189
+ - Changing `client.proxy` rebuilds the entire reqwest client (expensive). `RClient.config: ClientConfig` keeps the construction settings (loaded root certs, mTLS `Identity`, redirects, verify, https_only, http2_only, http1_only, cookie_store, referer) and `ClientConfig::build()` is the single place a `reqwest::Client` is built, used by `new()` and `set_proxy()`; add any new builder setting there, not inline (issue #84). The setter takes `Option<String>`; `None` removes the proxy. The rebuilt client starts with an empty cookie store
190
+
191
+ ### HTTP Version (issue #113)
192
+ - Default (`http2_only=False`, `http1_only=False`) is reqwest's ALPN negotiation: HTTP/2 over TLS when the server offers it, else HTTP/1.1; plain `http://` is HTTP/1.1. It is not "HTTP/1 only", whatever older docs said
193
+ - `http2_only=True` → `http2_prior_knowledge()` (h2c on `http://`); `http1_only=True` → `http1_only()`; both at once raises `ValueError` in `new()`. Both live in `ClientConfig`
194
+ - pyvespa relies on both current modes (sync client: negotiation, gets h2 on Vespa Cloud; `VespaAsync`: `http2_only=True`, h2c locally). Do not switch the default to httpx's HTTP/1.1-only or deprecate `http2_only` without Thomas's approval (issue #111)
195
+ - `Response.http_version` / `StreamingResponse.http_version` use httpx spelling (`"HTTP/1.1"`, `"HTTP/2"`), set from `ResponseMeta::from_response` in `src/response.rs`
190
196
 
191
197
  ### Client Lifecycle (issue #88)
192
198
  - All lifecycle state lives in `src/lifecycle.rs`: `ClientState` (`Arc`-shared between an `RClient` and its in-flight requests) holds `client: Mutex<Option<reqwest::Client>>`, an `in_flight` counter and a `CancellationToken` for pending connects. `RClient::close()` takes the client (`None`); dropping the `reqwest::Client` drops the client's handle to its connection pool
@@ -516,7 +516,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
516
516
 
517
517
  [[package]]
518
518
  name = "httpr"
519
- version = "0.7.2"
519
+ version = "0.7.4"
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.2"
5
+ version = "0.7.4"
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.2
3
+ Version: 0.7.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -149,7 +149,11 @@ class Client:
149
149
  verify (bool | None): Verify SSL certificates. Default is True.
150
150
  ca_cert_file (str | None): Path to CA certificate store. Default is None.
151
151
  https_only` (bool | None): Restrict the Client to be used with HTTPS only requests. Default is `false`.
152
- http2_only` (bool | None): If true - use only HTTP/2; if false - use only HTTP/1. Default is `false`.
152
+ http2_only` (bool | None): Speak HTTP/2 from the first byte (prior knowledge), including cleartext h2c on
153
+ `http://` URLs. Default is `false`, which negotiates: over TLS, HTTP/2 when the server offers it,
154
+ otherwise HTTP/1.1; plain `http://` uses HTTP/1.1.
155
+ http1_only` (bool | None): Only use HTTP/1.1, never HTTP/2. Cannot be combined with `http2_only`.
156
+ Default is `false`.
153
157
 
154
158
  """
155
159
  ```
@@ -105,7 +105,11 @@ class Client:
105
105
  verify (bool | None): Verify SSL certificates. Default is True.
106
106
  ca_cert_file (str | None): Path to CA certificate store. Default is None.
107
107
  https_only` (bool | None): Restrict the Client to be used with HTTPS only requests. Default is `false`.
108
- http2_only` (bool | None): If true - use only HTTP/2; if false - use only HTTP/1. Default is `false`.
108
+ http2_only` (bool | None): Speak HTTP/2 from the first byte (prior knowledge), including cleartext h2c on
109
+ `http://` URLs. Default is `false`, which negotiates: over TLS, HTTP/2 when the server offers it,
110
+ otherwise HTTP/1.1; plain `http://` uses HTTP/1.1.
111
+ http1_only` (bool | None): Only use HTTP/1.1, never HTTP/2. Cannot be combined with `http2_only`.
112
+ Default is `false`.
109
113
 
110
114
  """
111
115
  ```
@@ -101,7 +101,7 @@ client.proxy = None
101
101
 
102
102
  Every other setting from construction is carried over: `verify`, `ca_cert_file`,
103
103
  the mTLS identity, `follow_redirects`/`max_redirects`, `https_only`, `http2_only`,
104
- the current headers and the timeout. Two things to know:
104
+ `http1_only`, the current headers and the timeout. Two things to know:
105
105
 
106
106
  - Assigning `None` removes the proxy; the `HTTPR_PROXY` environment variable is
107
107
  only consulted when the client is constructed.
@@ -173,21 +173,30 @@ with client:
173
173
  print(response.json())
174
174
  ```
175
175
 
176
- ## HTTP/2
176
+ ## HTTP version
177
177
 
178
- httpr supports HTTP/2 over TLS:
178
+ By default httpr negotiates the protocol: over TLS it offers both HTTP/2 and
179
+ HTTP/1.1 (ALPN) and uses HTTP/2 whenever the server supports it; plain `http://`
180
+ URLs use HTTP/1.1. `response.http_version` tells you which one was used.
179
181
 
180
182
  ```python
181
183
  import httpr
182
184
 
183
- # HTTP/2 only mode
184
- client = httpr.Client(http2_only=True)
185
+ client = httpr.Client()
185
186
  response = client.get("https://http2.example.com")
187
+ print(response.http_version) # "HTTP/2" if the server supports it, else "HTTP/1.1"
188
+
189
+ # Never use HTTP/2
190
+ client = httpr.Client(http1_only=True)
191
+
192
+ # Speak HTTP/2 from the first byte, also over plain http:// (h2c)
193
+ client = httpr.Client(http2_only=True)
186
194
  ```
187
195
 
188
196
  !!! note
189
- HTTP/2 requires TLS. The `http2_only` option forces HTTP/2 protocol.
190
- When `http2_only=False` (default), httpr uses HTTP/1.1.
197
+ `http2_only=True` skips negotiation (HTTP/2 "prior knowledge"), so the server
198
+ must support HTTP/2; over `http://` it must accept cleartext h2c. `http1_only`
199
+ and `http2_only` cannot both be set.
191
200
 
192
201
  ## HTTPS Only Mode
193
202
 
@@ -280,7 +289,7 @@ The CA certificate chain is incomplete:
280
289
  Protocol mismatch:
281
290
 
282
291
  1. Server may not support modern TLS versions
283
- 2. Try `http2_only=False` if using HTTP/2
292
+ 2. Try `http1_only=True` to rule out HTTP/2, or drop `http2_only=True` if the server does not support HTTP/2
284
293
 
285
294
  ### mTLS Errors
286
295
 
@@ -48,6 +48,7 @@ client = httpr.Client(
48
48
  # Protocol
49
49
  https_only=False,
50
50
  http2_only=False,
51
+ http1_only=False,
51
52
  )
52
53
  ```
53
54
 
@@ -208,6 +208,24 @@ print(response.url) # https://httpbin.org/get
208
208
 
209
209
  ---
210
210
 
211
+ ### http_version
212
+
213
+ ```python
214
+ @property
215
+ def http_version(self) -> str
216
+ ```
217
+
218
+ The protocol the response arrived over: `"HTTP/1.1"` or `"HTTP/2"` (also
219
+ `"HTTP/1.0"`, `"HTTP/0.9"` or `"HTTP/3"`). Also available on streaming responses.
220
+
221
+ **Example:**
222
+ ```python
223
+ response = httpr.get("https://httpbin.org/get")
224
+ print(response.http_version) # HTTP/2
225
+ ```
226
+
227
+ ---
228
+
211
229
  ### encoding
212
230
 
213
231
  ```python
@@ -326,20 +326,30 @@ response = client.get("https://example.com")
326
326
  # response = client.get("http://example.com")
327
327
  ```
328
328
 
329
- ### HTTP/2
329
+ ### HTTP version
330
330
 
331
- Enable HTTP/2 only mode:
331
+ By default httpr negotiates the protocol: over TLS it offers both HTTP/2 and
332
+ HTTP/1.1 (ALPN) and uses HTTP/2 whenever the server supports it; plain `http://`
333
+ URLs use HTTP/1.1. `response.http_version` tells you which one was used.
332
334
 
333
335
  ```python
334
336
  import httpr
335
337
 
336
- # Use only HTTP/2
338
+ client = httpr.Client()
339
+ response = client.get("https://http2.example.com")
340
+ print(response.http_version) # "HTTP/2" if the server supports it, else "HTTP/1.1"
341
+
342
+ # Never use HTTP/2
343
+ client = httpr.Client(http1_only=True)
344
+
345
+ # Speak HTTP/2 from the first byte, also over plain http:// (h2c)
337
346
  client = httpr.Client(http2_only=True)
338
- response = client.get("https://example.com")
339
347
  ```
340
348
 
341
349
  !!! note
342
- When `http2_only=False` (default), httpr uses HTTP/1.1. Set to `True` for HTTP/2.
350
+ `http2_only=True` skips negotiation (HTTP/2 "prior knowledge"), so the server
351
+ must support HTTP/2; over `http://` it must accept cleartext h2c. `http1_only`
352
+ and `http2_only` cannot both be set.
343
353
 
344
354
  ## Complete Example
345
355
 
@@ -223,6 +223,7 @@ class Client(RClient):
223
223
  client_pem_data: bytes | None = None,
224
224
  https_only: bool | None = False,
225
225
  http2_only: bool | None = False,
226
+ http1_only: bool | None = False,
226
227
  ):
227
228
  """
228
229
  Initialize an HTTP client.
@@ -253,7 +254,12 @@ class Client(RClient):
253
254
  client_pem_data: Client certificate and key as bytes for mTLS (PEM format).
254
255
  Use this instead of client_pem when you have the certificate in memory.
255
256
  https_only: Only allow HTTPS requests. Default is False.
256
- http2_only: Use HTTP/2 only (False uses HTTP/1.1). Default is False.
257
+ http2_only: Speak HTTP/2 from the first byte (prior knowledge), including
258
+ cleartext h2c on `http://` URLs. Default is False, which negotiates:
259
+ over TLS, HTTP/2 when the server offers it (ALPN), otherwise HTTP/1.1;
260
+ plain `http://` uses HTTP/1.1.
261
+ http1_only: Only use HTTP/1.1, never HTTP/2. Default is False. Cannot be
262
+ combined with `http2_only`.
257
263
 
258
264
  Example:
259
265
  ```python
@@ -686,6 +692,11 @@ class AsyncStreamingResponse:
686
692
  """Final URL after any redirects."""
687
693
  return self._response.url
688
694
 
695
+ @property
696
+ def http_version(self) -> str:
697
+ """Protocol the response arrived over, e.g. "HTTP/1.1" or "HTTP/2"."""
698
+ return self._response.http_version
699
+
689
700
  @property
690
701
  def is_informational(self) -> bool:
691
702
  """True for 1xx status codes."""
@@ -181,6 +181,10 @@ class Response:
181
181
  """Final URL after any redirects."""
182
182
  ...
183
183
  @property
184
+ def http_version(self) -> str:
185
+ """Protocol the response arrived over, e.g. "HTTP/1.1" or "HTTP/2"."""
186
+ ...
187
+ @property
184
188
  def encoding(self) -> str:
185
189
  """
186
190
  Character encoding of the response.
@@ -314,6 +318,10 @@ class StreamingResponse:
314
318
  """Final URL after any redirects."""
315
319
  ...
316
320
  @property
321
+ def http_version(self) -> str:
322
+ """Protocol the response arrived over, e.g. "HTTP/1.1" or "HTTP/2"."""
323
+ ...
324
+ @property
317
325
  def is_closed(self) -> bool:
318
326
  """Whether the stream has been closed."""
319
327
  ...
@@ -381,6 +389,7 @@ class RClient:
381
389
  client_pem_data: bytes | None = None,
382
390
  https_only: bool | None = False,
383
391
  http2_only: bool | None = False,
392
+ http1_only: bool | None = False,
384
393
  ): ...
385
394
  @property
386
395
  def headers(self) -> dict[str, str]: ...
@@ -479,6 +488,7 @@ class Client(RClient):
479
488
  client_pem_data: bytes | None = None,
480
489
  https_only: bool | None = False,
481
490
  http2_only: bool | None = False,
491
+ http1_only: bool | None = False,
482
492
  ) -> None:
483
493
  """
484
494
  Initialize an HTTP client.
@@ -503,7 +513,12 @@ class Client(RClient):
503
513
  client_pem: Path to client certificate for mTLS (PEM format).
504
514
  client_pem_data: Client certificate and key as bytes for mTLS (PEM format).
505
515
  https_only: Only allow HTTPS requests. Default is False.
506
- http2_only: Use HTTP/2 only. Default is False.
516
+ http2_only: Speak HTTP/2 from the first byte (prior knowledge), including
517
+ cleartext h2c on `http://` URLs. Default is False, which negotiates:
518
+ over TLS, HTTP/2 when the server offers it (ALPN), otherwise HTTP/1.1;
519
+ plain `http://` uses HTTP/1.1.
520
+ http1_only: Only use HTTP/1.1, never HTTP/2. Default is False. Cannot be
521
+ combined with `http2_only`.
507
522
  """
508
523
  ...
509
524
  def __enter__(self) -> Client: ...
@@ -588,6 +603,7 @@ class AsyncClient(Client):
588
603
  client_pem_data: bytes | None = None,
589
604
  https_only: bool | None = False,
590
605
  http2_only: bool | None = False,
606
+ http1_only: bool | None = False,
591
607
  max_concurrency: int | None = 64,
592
608
  ) -> None:
593
609
  """Initialize an async HTTP client.
@@ -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.2"
14
+ version = "0.7.4"
15
15
  dependencies = []
16
16
  [[project.authors]]
17
17
  name = "thomasht86"
@@ -27,7 +27,9 @@ use tokio_util::codec::{BytesCodec, FramedRead};
27
27
  use tokio_util::sync::CancellationToken;
28
28
 
29
29
  mod response;
30
- use response::{CaseInsensitiveHeaderMap, LineIterator, Response, StreamingResponse, TextIterator};
30
+ use response::{
31
+ CaseInsensitiveHeaderMap, LineIterator, Response, ResponseMeta, StreamingResponse, TextIterator,
32
+ };
31
33
 
32
34
  mod params;
33
35
  use params::{merge_params, normalize_params, params_to_py, Pairs};
@@ -76,6 +78,7 @@ struct ClientConfig {
76
78
  identity: Option<Identity>,
77
79
  https_only: bool,
78
80
  http2_only: bool,
81
+ http1_only: bool,
79
82
  }
80
83
 
81
84
  impl ClientConfig {
@@ -118,9 +121,14 @@ impl ClientConfig {
118
121
  if let Some(identity) = &self.identity {
119
122
  builder = builder.identity(identity.clone());
120
123
  }
124
+ // Neither flag: ALPN offers h2 and http/1.1 over TLS, plain http:// is
125
+ // HTTP/1.1. `new()` rejects both flags at once.
121
126
  if self.http2_only {
122
127
  builder = builder.http2_prior_knowledge();
123
128
  }
129
+ if self.http1_only {
130
+ builder = builder.http1_only();
131
+ }
124
132
  builder.build().map_err(map_reqwest_error)
125
133
  }
126
134
  }
@@ -325,21 +333,6 @@ async fn send_request(
325
333
  timeout::with_timeout(timeout, Phase::Headers, builder.send()).await
326
334
  }
327
335
 
328
- /// Cookies, headers, status and final URL of a response.
329
- fn response_meta(resp: &reqwest::Response) -> (IndexMapSSR, IndexMapSSR, u16, String) {
330
- let cookies: IndexMapSSR = resp
331
- .cookies()
332
- .map(|cookie| (cookie.name().to_string(), cookie.value().to_string()))
333
- .collect();
334
- let headers: IndexMapSSR = resp.headers().to_indexmap();
335
- (
336
- cookies,
337
- headers,
338
- resp.status().as_u16(),
339
- resp.url().to_string(),
340
- )
341
- }
342
-
343
336
  #[pymethods]
344
337
  impl RClient {
345
338
  /// Initializes an HTTP client that can impersonate web browsers.
@@ -366,7 +359,11 @@ impl RClient {
366
359
  /// * `verify` - An optional boolean indicating whether to verify SSL certificates. Default is `true`.
367
360
  /// * `ca_cert_file` - Path to CA certificate store. Default is None.
368
361
  /// * `https_only` - Restrict the Client to be used with HTTPS only requests. Default is `false`.
369
- /// * `http2_only` - If true - use only HTTP/2, if false - use only HTTP/1. Default is `false`.
362
+ /// * `http2_only` - Speak HTTP/2 from the first byte (prior knowledge), including cleartext h2c on
363
+ /// `http://` URLs. Default is `false`, which negotiates: over TLS, HTTP/2 when the server
364
+ /// offers it via ALPN, otherwise HTTP/1.1; plain `http://` uses HTTP/1.1.
365
+ /// * `http1_only` - Only use HTTP/1.1, never HTTP/2. Default is `false`. Cannot be combined with
366
+ /// `http2_only`.
370
367
  ///
371
368
  /// # Example
372
369
  ///
@@ -394,7 +391,7 @@ impl RClient {
394
391
  #[new]
395
392
  #[pyo3(signature = (auth=None, auth_bearer=None, params=None, headers=None, cookies=None,
396
393
  cookie_store=true, referer=true, proxy=None, timeout=30.0, follow_redirects=true,
397
- max_redirects=20, verify=true, ca_cert_file=None, client_pem=None, client_pem_data=None, https_only=false, http2_only=false))]
394
+ max_redirects=20, verify=true, ca_cert_file=None, client_pem=None, client_pem_data=None, https_only=false, http2_only=false, http1_only=false))]
398
395
  fn new(
399
396
  auth: Option<(String, Option<String>)>,
400
397
  auth_bearer: Option<String>,
@@ -413,12 +410,20 @@ impl RClient {
413
410
  client_pem_data: Option<Vec<u8>>,
414
411
  https_only: Option<bool>,
415
412
  http2_only: Option<bool>,
413
+ http1_only: Option<bool>,
416
414
  ) -> PyResult<Self> {
417
415
  if client_pem.is_some() && client_pem_data.is_some() {
418
416
  return Err(PyValueError::new_err(
419
417
  "Only one of client_pem or client_pem_data may be set.",
420
418
  ));
421
419
  }
420
+ let http2_only = http2_only.unwrap_or(false);
421
+ let http1_only = http1_only.unwrap_or(false);
422
+ if http1_only && http2_only {
423
+ return Err(PyValueError::new_err(
424
+ "Only one of http1_only or http2_only may be set.",
425
+ ));
426
+ }
422
427
  let params = params.map(|p| normalize_params(p, "params")).transpose()?;
423
428
 
424
429
  // Headers || Cookies
@@ -476,7 +481,8 @@ impl RClient {
476
481
  root_certs,
477
482
  identity,
478
483
  https_only: https_only.unwrap_or(false),
479
- http2_only: http2_only.unwrap_or(false),
484
+ http2_only,
485
+ http1_only,
480
486
  };
481
487
 
482
488
  let connects = CancellationToken::new();
@@ -727,17 +733,11 @@ impl RClient {
727
733
 
728
734
  let future = async move {
729
735
  let resp = send_request(builder, files, timeout).await?;
730
- let (cookies, headers, status_code, url) = response_meta(&resp);
736
+ let meta = ResponseMeta::from_response(&resp);
731
737
  let buf = timeout::read_body(resp, timeout).await?;
732
738
 
733
- tracing::info!("response: {} {} {}", url, status_code, buf.len());
734
- Ok::<(Bytes, IndexMapSSR, IndexMapSSR, u16, String), anyhow::Error>((
735
- buf,
736
- cookies,
737
- headers,
738
- status_code,
739
- url,
740
- ))
739
+ tracing::info!("response: {} {} {}", meta.url, meta.status_code, buf.len());
740
+ Ok::<(Bytes, ResponseMeta), anyhow::Error>((buf, meta))
741
741
  };
742
742
 
743
743
  // Execute an async future, releasing the Python GIL for concurrency.
@@ -752,17 +752,9 @@ impl RClient {
752
752
  drop(in_flight);
753
753
  result
754
754
  });
755
- let (f_buf, f_cookies, f_headers, f_status_code, f_url) =
756
- result.map_err(map_anyhow_error)?;
757
-
758
- Ok(Response {
759
- content: PyBytes::new(py, &f_buf).unbind(),
760
- cookies: f_cookies,
761
- encoding: String::new(),
762
- headers: CaseInsensitiveHeaderMap::from_indexmap(f_headers),
763
- status_code: f_status_code,
764
- url: f_url,
765
- })
755
+ let (buf, meta) = result.map_err(map_anyhow_error)?;
756
+
757
+ Ok(Response::new(PyBytes::new(py, &buf).unbind(), meta))
766
758
  }
767
759
 
768
760
  /// Constructs an HTTP request and returns a StreamingResponse for iterating over chunks.
@@ -823,34 +815,19 @@ impl RClient {
823
815
  let future = async move {
824
816
  // Send the request and await the response (but don't read body)
825
817
  let resp = send_request(builder, files, timeout).await?;
826
- let (cookies, headers, status_code, url) = response_meta(&resp);
827
-
828
- tracing::info!("streaming response: {} {}", url, status_code);
829
- Ok::<(reqwest::Response, IndexMapSSR, IndexMapSSR, u16, String), anyhow::Error>((
830
- resp,
831
- cookies,
832
- headers,
833
- status_code,
834
- url,
835
- ))
818
+ let meta = ResponseMeta::from_response(&resp);
819
+
820
+ tracing::info!("streaming response: {} {}", meta.url, meta.status_code);
821
+ Ok::<(reqwest::Response, ResponseMeta), anyhow::Error>((resp, meta))
836
822
  };
837
823
 
838
824
  // Execute an async future, releasing the Python GIL for concurrency.
839
825
  let result = py.detach(|| RUNTIME.block_on(future));
840
- let (f_resp, f_cookies, f_headers, f_status_code, f_url) =
841
- result.map_err(map_anyhow_error)?;
826
+ let (resp, meta) = result.map_err(map_anyhow_error)?;
842
827
 
843
828
  // The response keeps the request in flight until it is closed: its
844
829
  // connection stays busy, and a `close()` meanwhile must wait for it.
845
- Ok(StreamingResponse::new(
846
- f_resp,
847
- in_flight,
848
- f_cookies,
849
- CaseInsensitiveHeaderMap::from_indexmap(f_headers),
850
- f_status_code,
851
- f_url,
852
- timeout,
853
- ))
830
+ Ok(StreamingResponse::new(resp, in_flight, meta, timeout))
854
831
  }
855
832
  }
856
833
 
@@ -5,6 +5,7 @@
5
5
  use crate::exceptions::{map_anyhow_error, HTTPStatusError, StreamClosed, StreamConsumed};
6
6
  use crate::lifecycle::InFlight;
7
7
  use crate::timeout::{next_chunk, read_body, TimedOut};
8
+ use crate::traits::HeadersTraits;
8
9
  use crate::utils::{get_encoding_from_case_insensitive_headers, get_encoding_from_content};
9
10
  use crate::RUNTIME;
10
11
  use anyhow::{anyhow, Result};
@@ -192,6 +193,43 @@ impl CaseInsensitiveHeaderMap {
192
193
  }
193
194
  }
194
195
 
196
+ /// The protocol version as httpx spells it (`response.http_version`).
197
+ fn http_version_str(version: reqwest::Version) -> &'static str {
198
+ match version {
199
+ reqwest::Version::HTTP_09 => "HTTP/0.9",
200
+ reqwest::Version::HTTP_10 => "HTTP/1.0",
201
+ reqwest::Version::HTTP_11 => "HTTP/1.1",
202
+ reqwest::Version::HTTP_2 => "HTTP/2",
203
+ reqwest::Version::HTTP_3 => "HTTP/3",
204
+ _ => "HTTP/unknown",
205
+ }
206
+ }
207
+
208
+ /// Everything about a response except its body: what both `Response` and
209
+ /// `StreamingResponse` are built from.
210
+ pub struct ResponseMeta {
211
+ pub cookies: IndexMap<String, String, RandomState>,
212
+ pub headers: CaseInsensitiveHeaderMap,
213
+ pub status_code: u16,
214
+ pub url: String,
215
+ pub http_version: &'static str,
216
+ }
217
+
218
+ impl ResponseMeta {
219
+ pub fn from_response(resp: &reqwest::Response) -> Self {
220
+ ResponseMeta {
221
+ cookies: resp
222
+ .cookies()
223
+ .map(|cookie| (cookie.name().to_string(), cookie.value().to_string()))
224
+ .collect(),
225
+ headers: CaseInsensitiveHeaderMap::from_indexmap(resp.headers().to_indexmap()),
226
+ status_code: resp.status().as_u16(),
227
+ url: resp.url().to_string(),
228
+ http_version: http_version_str(resp.version()),
229
+ }
230
+ }
231
+ }
232
+
195
233
  #[pyclass]
196
234
  pub struct Response {
197
235
  #[pyo3(get)]
@@ -206,10 +244,31 @@ pub struct Response {
206
244
  pub status_code: u16,
207
245
  #[pyo3(get)]
208
246
  pub url: String,
247
+ pub http_version: &'static str,
248
+ }
249
+
250
+ impl Response {
251
+ pub fn new(content: Py<PyBytes>, meta: ResponseMeta) -> Self {
252
+ Response {
253
+ content,
254
+ cookies: meta.cookies,
255
+ encoding: String::new(),
256
+ headers: meta.headers,
257
+ status_code: meta.status_code,
258
+ url: meta.url,
259
+ http_version: meta.http_version,
260
+ }
261
+ }
209
262
  }
210
263
 
211
264
  #[pymethods]
212
265
  impl Response {
266
+ /// The protocol the response arrived over: `"HTTP/1.1"`, `"HTTP/2"`, ...
267
+ #[getter]
268
+ fn http_version(&self) -> &'static str {
269
+ self.http_version
270
+ }
271
+
213
272
  #[getter]
214
273
  fn reason_phrase(&self) -> &'static str {
215
274
  reason_phrase(self.status_code)
@@ -369,6 +428,7 @@ pub struct StreamingResponse {
369
428
  pub status_code: u16,
370
429
  #[pyo3(get)]
371
430
  pub url: String,
431
+ http_version: &'static str,
372
432
  closed: Arc<Mutex<bool>>,
373
433
  consumed: Arc<Mutex<bool>>,
374
434
  encoding: Arc<Mutex<Option<String>>>,
@@ -385,18 +445,16 @@ impl StreamingResponse {
385
445
  pub fn new(
386
446
  response: reqwest::Response,
387
447
  in_flight: InFlight,
388
- cookies: IndexMap<String, String, RandomState>,
389
- headers: CaseInsensitiveHeaderMap,
390
- status_code: u16,
391
- url: String,
448
+ meta: ResponseMeta,
392
449
  timeout: Option<Duration>,
393
450
  ) -> Self {
394
451
  StreamingResponse {
395
452
  response: Arc::new(Mutex::new(Some(response))),
396
- cookies,
397
- headers,
398
- status_code,
399
- url,
453
+ cookies: meta.cookies,
454
+ headers: meta.headers,
455
+ status_code: meta.status_code,
456
+ url: meta.url,
457
+ http_version: meta.http_version,
400
458
  closed: Arc::new(Mutex::new(false)),
401
459
  consumed: Arc::new(Mutex::new(false)),
402
460
  encoding: Arc::new(Mutex::new(None)),
@@ -447,6 +505,12 @@ impl StreamingResponse {
447
505
 
448
506
  #[pymethods]
449
507
  impl StreamingResponse {
508
+ /// The protocol the response arrived over: `"HTTP/1.1"`, `"HTTP/2"`, ...
509
+ #[getter]
510
+ fn http_version(&self) -> &'static str {
511
+ self.http_version
512
+ }
513
+
450
514
  #[getter]
451
515
  fn reason_phrase(&self) -> &'static str {
452
516
  reason_phrase(self.status_code)
@@ -0,0 +1,48 @@
1
+ """E2E protocol selection tests against httpbun over TLS, which offers h2 (issue #113)."""
2
+
3
+ import pytest
4
+
5
+ import httpr
6
+
7
+
8
+ @pytest.mark.e2e
9
+ class TestHttpVersion:
10
+ def test_default_negotiates_http2(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
11
+ """`http2_only=False` (the default) is ALPN negotiation, not HTTP/1-only."""
12
+ with httpr.Client(ca_cert_file=e2e_ca_cert) as client:
13
+ assert client.get(f"{e2e_base_url}/get").http_version == "HTTP/2"
14
+
15
+ def test_http2_only(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
16
+ with httpr.Client(ca_cert_file=e2e_ca_cert, http2_only=True) as client:
17
+ assert client.get(f"{e2e_base_url}/get").http_version == "HTTP/2"
18
+
19
+ def test_http1_only(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
20
+ with httpr.Client(ca_cert_file=e2e_ca_cert, http1_only=True) as client:
21
+ response = client.get(f"{e2e_base_url}/get")
22
+ assert response.status_code == 200
23
+ assert response.http_version == "HTTP/1.1"
24
+
25
+ def test_http1_only_streaming(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
26
+ with httpr.Client(ca_cert_file=e2e_ca_cert, http1_only=True) as client:
27
+ with client.stream("GET", f"{e2e_base_url}/get") as response:
28
+ assert response.http_version == "HTTP/1.1"
29
+
30
+ def test_http1_only_survives_proxy_rebuild(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
31
+ """Assigning `proxy` rebuilds the reqwest client from `ClientConfig` (issue #84)."""
32
+ client = httpr.Client(ca_cert_file=e2e_ca_cert, http1_only=True)
33
+ client.proxy = None
34
+ assert client.get(f"{e2e_base_url}/get").http_version == "HTTP/1.1"
35
+
36
+ def test_http1_only_survives_reopen(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
37
+ client = httpr.Client(ca_cert_file=e2e_ca_cert, http1_only=True)
38
+ client.close()
39
+ with pytest.warns(httpr.ClientReopenedWarning):
40
+ response = client.get(f"{e2e_base_url}/get")
41
+ assert response.http_version == "HTTP/1.1"
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_async_client(self, e2e_base_url: str, e2e_ca_cert: str) -> None:
45
+ async with httpr.AsyncClient(ca_cert_file=e2e_ca_cert) as client:
46
+ assert (await client.get(f"{e2e_base_url}/get")).http_version == "HTTP/2"
47
+ async with httpr.AsyncClient(ca_cert_file=e2e_ca_cert, http1_only=True) as client:
48
+ assert (await client.get(f"{e2e_base_url}/get")).http_version == "HTTP/1.1"
@@ -0,0 +1,48 @@
1
+ """`http1_only` and `response.http_version` (issue #113).
2
+
3
+ pytest-httpbin only speaks HTTP/1.1, so these tests cover the plumbing; the
4
+ HTTP/2 cases run against httpbun over TLS in tests/e2e/test_http_version.py.
5
+ """
6
+
7
+ import pytest
8
+
9
+ import httpr
10
+
11
+
12
+ def test_http_version_buffered(base_url):
13
+ response = httpr.Client().get(f"{base_url}/get")
14
+ assert response.http_version == "HTTP/1.1"
15
+
16
+
17
+ def test_http_version_over_tls_without_h2(base_url_ssl, ca_bundle):
18
+ """ALPN falls back to HTTP/1.1 when the server doesn't offer h2."""
19
+ response = httpr.Client(ca_cert_file=ca_bundle).get(f"{base_url_ssl}/get")
20
+ assert response.status_code == 200
21
+ assert response.http_version == "HTTP/1.1"
22
+
23
+
24
+ def test_http_version_streaming(base_url):
25
+ with httpr.Client().stream("GET", f"{base_url}/get") as response:
26
+ assert response.http_version == "HTTP/1.1"
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_http_version_async(base_url):
31
+ async with httpr.AsyncClient() as client:
32
+ response = await client.get(f"{base_url}/get")
33
+ assert response.http_version == "HTTP/1.1"
34
+ async with client.stream("GET", f"{base_url}/get") as streamed:
35
+ assert streamed.http_version == "HTTP/1.1"
36
+
37
+
38
+ @pytest.mark.parametrize("client_cls", [httpr.Client, httpr.AsyncClient])
39
+ def test_http1_only_accepted(base_url, client_cls):
40
+ client = client_cls(http1_only=True)
41
+ if client_cls is httpr.Client:
42
+ assert client.get(f"{base_url}/get").http_version == "HTTP/1.1"
43
+
44
+
45
+ @pytest.mark.parametrize("client_cls", [httpr.Client, httpr.AsyncClient])
46
+ def test_http1_only_and_http2_only_conflict(client_cls):
47
+ with pytest.raises(ValueError, match="http1_only or http2_only"):
48
+ client_cls(http1_only=True, http2_only=True)
@@ -196,18 +196,20 @@ def test_timeout_survives_proxy_rebuild(slow):
196
196
 
197
197
 
198
198
  def test_slow_stream_is_not_cut_off(slow):
199
- """Five chunks 0.3 s apart take 1.2 s in total, longer than the 0.5 s
200
- timeout, but no single wait exceeds it."""
201
- client = httpr.Client(timeout=0.5)
202
- with client.stream("GET", f"{slow.url}/trickle/5/0.3") as response:
199
+ """Eight chunks 0.2 s apart take 1.4 s in total, longer than the 1 s
200
+ timeout, but no single wait exceeds it. The 5x margin per gap absorbs
201
+ sleep overshoot on slow CI runners (macOS overshoots by >100%); overshoot
202
+ only lengthens the total, so the body still outlasts the timeout."""
203
+ client = httpr.Client(timeout=1.0)
204
+ with client.stream("GET", f"{slow.url}/trickle/8/0.2") as response:
203
205
  chunks = list(response.iter_bytes())
204
- assert b"".join(chunks) == b"".join(f"chunk{i}\n".encode() for i in range(5))
206
+ assert b"".join(chunks) == b"".join(f"chunk{i}\n".encode() for i in range(8))
205
207
 
206
208
 
207
209
  def test_slow_buffered_body_is_not_cut_off(slow):
208
- client = httpr.Client(timeout=0.5)
209
- response = client.get(f"{slow.url}/trickle/5/0.3")
210
- assert response.text == "".join(f"chunk{i}\n" for i in range(5))
210
+ client = httpr.Client(timeout=1.0)
211
+ response = client.get(f"{slow.url}/trickle/8/0.2")
212
+ assert response.text == "".join(f"chunk{i}\n" for i in range(8))
211
213
 
212
214
 
213
215
  def test_stalled_stream_times_out(slow):
@@ -248,10 +250,10 @@ def test_stalled_stream_read_times_out(slow):
248
250
 
249
251
  @pytest.mark.asyncio
250
252
  async def test_async_slow_stream_is_not_cut_off(slow):
251
- async with httpr.AsyncClient(timeout=0.5) as client:
252
- async with client.stream("GET", f"{slow.url}/trickle/5/0.3") as response:
253
+ async with httpr.AsyncClient(timeout=1.0) as client:
254
+ async with client.stream("GET", f"{slow.url}/trickle/8/0.2") as response:
253
255
  lines = [line async for line in response.aiter_lines()]
254
- assert lines == [f"chunk{i}\n" for i in range(5)]
256
+ assert lines == [f"chunk{i}\n" for i in range(8)]
255
257
 
256
258
 
257
259
  @pytest.mark.asyncio
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