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.
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/CI.yml +28 -13
- {httpr-0.7.2 → httpr-0.7.4}/CLAUDE.md +7 -1
- {httpr-0.7.2 → httpr-0.7.4}/Cargo.lock +1 -1
- {httpr-0.7.2 → httpr-0.7.4}/Cargo.toml +1 -1
- {httpr-0.7.2 → httpr-0.7.4}/PKG-INFO +6 -2
- {httpr-0.7.2 → httpr-0.7.4}/README.md +5 -1
- {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/proxy.md +1 -1
- {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/ssl-tls.md +16 -7
- {httpr-0.7.2 → httpr-0.7.4}/docs/api/index.md +1 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/api/response.md +18 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/making-requests.md +15 -5
- {httpr-0.7.2 → httpr-0.7.4}/httpr/__init__.py +12 -1
- {httpr-0.7.2 → httpr-0.7.4}/httpr/httpr.pyi +17 -1
- {httpr-0.7.2 → httpr-0.7.4}/pyproject.toml +1 -1
- {httpr-0.7.2 → httpr-0.7.4}/src/lib.rs +37 -60
- {httpr-0.7.2 → httpr-0.7.4}/src/response.rs +72 -8
- httpr-0.7.4/tests/e2e/test_http_version.py +48 -0
- httpr-0.7.4/tests/unit/test_http_version.py +48 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_timeout.py +13 -11
- {httpr-0.7.2 → httpr-0.7.4}/.github/actions/set-version/action.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/copilot-instructions.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/benchmark.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/codspeed.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/compare.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/copilot-setup-steps.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/mkdocs.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.github/workflows/set_version.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.gitignore +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/.pre-commit-config.yaml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/LICENSE +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/Taskfile.yaml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/benchmark/README.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/benchmark/__init__.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/benchmark/benchmark.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/benchmark/benchmark_cbor.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/benchmark/render_comparison.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/benchmark/server.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/cookies.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/advanced/index.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/api/async-client.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/api/client.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/api/functions.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/benchmark.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/index.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/quickstart.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/async.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/authentication.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/index.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/tutorial/response-handling.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/writings/index.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/httpr/py.typed +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/mkdocs.yml +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/scripts/generate_certs.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/src/exceptions.rs +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/src/lifecycle.rs +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/src/params.rs +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/src/timeout.rs +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/src/traits.rs +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/src/utils.rs +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/__init__.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/README.md +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/__init__.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/bench_server.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/conftest.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/test_decoding.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/codspeed/test_transport.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/benchmark/test_performance.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/conftest.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/__init__.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_async.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_auth.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_redirects.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_ssl.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_streaming.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/e2e/test_uploads.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/__init__.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/cbor_test_server.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/httpx_conns.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_asyncclient.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_ca_bundle.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_cbor.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_client.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_close.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_defs.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_docs.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_exceptions.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_params.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_proxy.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_response.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_ssl.py +0 -0
- {httpr-0.7.2 → httpr-0.7.4}/tests/unit/test_streaming.py +0 -0
- {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:
|
|
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:
|
|
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:
|
|
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.
|
|
242
|
+
name: wheels-linux-${{ matrix.platform.arch }}
|
|
231
243
|
path: dist
|
|
232
244
|
- name: pytest
|
|
233
|
-
if: ${{ matrix.platform.
|
|
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:
|
|
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.
|
|
300
|
+
name: wheels-musllinux-${{ matrix.platform.arch }}
|
|
286
301
|
path: dist
|
|
287
302
|
- name: pytest
|
|
288
|
-
if: ${{ matrix.platform.
|
|
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
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "httpr"
|
|
3
3
|
# Version is set dynamically by CI from git tags (e.g., v1.2.3 -> 1.2.3).
|
|
4
4
|
# Do not edit manually. See .github/actions/set-version/ for details.
|
|
5
|
-
version = "0.7.
|
|
5
|
+
version = "0.7.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.
|
|
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):
|
|
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):
|
|
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
|
|
176
|
+
## HTTP version
|
|
177
177
|
|
|
178
|
-
httpr
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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=
|
|
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
|
|
|
@@ -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
|
|
329
|
+
### HTTP version
|
|
330
330
|
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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::{
|
|
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` -
|
|
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
|
|
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
|
|
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,
|
|
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 (
|
|
756
|
-
|
|
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
|
|
827
|
-
|
|
828
|
-
tracing::info!("streaming response: {} {}", url, status_code);
|
|
829
|
-
Ok::<(reqwest::Response,
|
|
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 (
|
|
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
|
-
|
|
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
|
-
"""
|
|
200
|
-
timeout, but no single wait exceeds it.
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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
|
|
209
|
-
response = client.get(f"{slow.url}/trickle/
|
|
210
|
-
assert response.text == "".join(f"chunk{i}\n" for i in range(
|
|
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
|
|
252
|
-
async with client.stream("GET", f"{slow.url}/trickle/
|
|
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(
|
|
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
|
|
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
|