boring-http 2.0.2__tar.gz → 2.1.0__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.
- boring_http-2.0.2/README.md → boring_http-2.1.0/PKG-INFO +13 -1
- boring_http-2.0.2/PKG-INFO → boring_http-2.1.0/README.md +3 -11
- {boring_http-2.0.2 → boring_http-2.1.0}/pyproject.toml +1 -1
- {boring_http-2.0.2 → boring_http-2.1.0}/pyproject.toml.orig +1 -1
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/cookies.py +15 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/http2.py +25 -11
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/http3.py +5 -0
- boring_http-2.1.0/src/boring_http/public_suffixes.py +10407 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/connection.py +23 -10
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/protocol.py +19 -1
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/streams.py +6 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/__init__.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/client.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/codings.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/errors.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/hpack.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/hpack_tables.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/http1.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/message.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/pool.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/py.typed +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/qpack.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/qpack_tables.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/__init__.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/packet.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/recovery.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/revocation.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/timing.py +0 -0
- {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/websocket.py +0 -0
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: boring-http
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order
|
|
5
|
+
Requires-Dist: boring-ssl2>=1.2.0
|
|
6
|
+
Requires-Dist: ssl2>=2.0.1
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Project-URL: Repository, https://github.com/baobien-dev/boring-http
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
1
11
|
# boring-http
|
|
2
12
|
|
|
3
13
|
An asyncio HTTP client on BoringSSL through `ssl2` and `boring-ssl2`. HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers sent in the order you give them, cookies, redirects, phased timeouts, gzip, deflate, brotli and zstd decoding. Runs on asyncio and on uvloop. Requires Python 3.14.
|
|
@@ -60,7 +70,7 @@ async def main() -> None:
|
|
|
60
70
|
asyncio.run(main())
|
|
61
71
|
```
|
|
62
72
|
|
|
63
|
-
`response.cookies` holds only the cookies this response set; `client.cookies` is the jar that follows every request. `location`, `content_type`, `charset`, `content_length`, `content_encoding`, `retry_after`, `etag`, `last_modified` and `date` are parsed, `response.headers` keeps the raw fields in order, `raise_for_status()` raises `HTTPStatusError`.
|
|
73
|
+
`response.cookies` holds only the cookies this response set; `client.cookies` is the jar that follows every request. A `Domain` that is a public suffix on the bundled Public Suffix List, such as `co.uk` or `github.io`, is refused unless it is the host itself, which then keeps the cookie to that host. `location`, `content_type`, `charset`, `content_length`, `content_encoding`, `retry_after`, `etag`, `last_modified` and `date` are parsed, `response.headers` keeps the raw fields in order, `raise_for_status()` raises `HTTPStatusError`.
|
|
64
74
|
|
|
65
75
|
## Streaming and timeouts
|
|
66
76
|
|
|
@@ -90,6 +100,8 @@ asyncio.run(main())
|
|
|
90
100
|
|
|
91
101
|
A single number sets every phase and the total, so the request gives up after that many seconds whatever it was doing. With a `Timeout` the phases are separate: `connect` covers opening a connection, the proxy, TCP and the TLS or QUIC handshake included, `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
|
|
92
102
|
|
|
103
|
+
A body is held to what the application reads: over HTTP/2 and HTTP/3 the stream window opens again only as chunks are taken, so a response nobody reads stops at one window (`INITIAL_WINDOW_SIZE` or `initial_max_stream_data_bidi_local`), while the connection window keeps opening on arrival so other streams go on. An HTTP/3 upload keeps at most 1 MiB of the body unsent before it asks the body for the next chunk; HTTP/1.1 and HTTP/2 uploads wait on the socket and the peer's window.
|
|
104
|
+
|
|
93
105
|
## HTTP/3
|
|
94
106
|
|
|
95
107
|
```python
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: boring-http
|
|
3
|
-
Version: 2.0.2
|
|
4
|
-
Summary: Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order
|
|
5
|
-
Requires-Dist: boring-ssl2>=1.2.0
|
|
6
|
-
Requires-Dist: ssl2>=2.0.1
|
|
7
|
-
Requires-Python: >=3.14
|
|
8
|
-
Project-URL: Repository, https://github.com/baobien-dev/boring-http
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
|
|
11
1
|
# boring-http
|
|
12
2
|
|
|
13
3
|
An asyncio HTTP client on BoringSSL through `ssl2` and `boring-ssl2`. HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers sent in the order you give them, cookies, redirects, phased timeouts, gzip, deflate, brotli and zstd decoding. Runs on asyncio and on uvloop. Requires Python 3.14.
|
|
@@ -70,7 +60,7 @@ async def main() -> None:
|
|
|
70
60
|
asyncio.run(main())
|
|
71
61
|
```
|
|
72
62
|
|
|
73
|
-
`response.cookies` holds only the cookies this response set; `client.cookies` is the jar that follows every request. `location`, `content_type`, `charset`, `content_length`, `content_encoding`, `retry_after`, `etag`, `last_modified` and `date` are parsed, `response.headers` keeps the raw fields in order, `raise_for_status()` raises `HTTPStatusError`.
|
|
63
|
+
`response.cookies` holds only the cookies this response set; `client.cookies` is the jar that follows every request. A `Domain` that is a public suffix on the bundled Public Suffix List, such as `co.uk` or `github.io`, is refused unless it is the host itself, which then keeps the cookie to that host. `location`, `content_type`, `charset`, `content_length`, `content_encoding`, `retry_after`, `etag`, `last_modified` and `date` are parsed, `response.headers` keeps the raw fields in order, `raise_for_status()` raises `HTTPStatusError`.
|
|
74
64
|
|
|
75
65
|
## Streaming and timeouts
|
|
76
66
|
|
|
@@ -100,6 +90,8 @@ asyncio.run(main())
|
|
|
100
90
|
|
|
101
91
|
A single number sets every phase and the total, so the request gives up after that many seconds whatever it was doing. With a `Timeout` the phases are separate: `connect` covers opening a connection, the proxy, TCP and the TLS or QUIC handshake included, `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
|
|
102
92
|
|
|
93
|
+
A body is held to what the application reads: over HTTP/2 and HTTP/3 the stream window opens again only as chunks are taken, so a response nobody reads stops at one window (`INITIAL_WINDOW_SIZE` or `initial_max_stream_data_bidi_local`), while the connection window keeps opening on arrival so other streams go on. An HTTP/3 upload keeps at most 1 MiB of the body unsent before it asks the body for the next chunk; HTTP/1.1 and HTTP/2 uploads wait on the socket and the peer's window.
|
|
94
|
+
|
|
103
95
|
## HTTP/3
|
|
104
96
|
|
|
105
97
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boring-http"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boring-http"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -4,6 +4,16 @@ from dataclasses import dataclass
|
|
|
4
4
|
from email.utils import parsedate_to_datetime
|
|
5
5
|
from typing import Self
|
|
6
6
|
|
|
7
|
+
from boring_http.public_suffixes import EXCEPTIONS, RULES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_public_suffix(domain: str) -> bool:
|
|
11
|
+
# publicsuffix.org's algorithm: an exception rule wins, then a listed rule or a wildcard over the parent, and an unlisted top-level label is one
|
|
12
|
+
if domain in EXCEPTIONS:
|
|
13
|
+
return False
|
|
14
|
+
parent = domain.partition(".")[2]
|
|
15
|
+
return domain in RULES or f"*.{parent}" in RULES or not parent
|
|
16
|
+
|
|
7
17
|
|
|
8
18
|
@dataclass(frozen=True, slots=True)
|
|
9
19
|
class Cookie:
|
|
@@ -46,6 +56,11 @@ class Cookie:
|
|
|
46
56
|
pass
|
|
47
57
|
if domain and not (host == domain or host.endswith("." + domain)):
|
|
48
58
|
return None
|
|
59
|
+
# RFC 6265 section 5.3 step 5: a public suffix as Domain is refused, unless it is the host itself, which keeps the cookie to that host
|
|
60
|
+
if domain and is_public_suffix(domain):
|
|
61
|
+
if domain != host:
|
|
62
|
+
return None
|
|
63
|
+
domain = ""
|
|
49
64
|
if not path.startswith("/"):
|
|
50
65
|
path = request_path[: request_path.rfind("/")] or "/"
|
|
51
66
|
return cls(
|
|
@@ -105,7 +105,8 @@ class Http2Stream:
|
|
|
105
105
|
self.waiter: asyncio.Future[None] | None = None
|
|
106
106
|
self.trailers = Headers()
|
|
107
107
|
self.block = bytearray()
|
|
108
|
-
|
|
108
|
+
# what the application took since the last WINDOW_UPDATE for this stream
|
|
109
|
+
self.consumed = 0
|
|
109
110
|
self.finished = False
|
|
110
111
|
|
|
111
112
|
def fail(self, error: BaseException) -> None:
|
|
@@ -382,7 +383,9 @@ class Http2Connection:
|
|
|
382
383
|
try:
|
|
383
384
|
while True:
|
|
384
385
|
if stream.chunks:
|
|
385
|
-
|
|
386
|
+
data = stream.take()
|
|
387
|
+
await self.credit(stream, len(data))
|
|
388
|
+
yield data
|
|
386
389
|
elif stream.error is not None:
|
|
387
390
|
raise stream.error
|
|
388
391
|
elif stream.finished:
|
|
@@ -409,6 +412,24 @@ class Http2Connection:
|
|
|
409
412
|
)
|
|
410
413
|
)
|
|
411
414
|
|
|
415
|
+
async def credit(self, stream: Http2Stream, size: int) -> None:
|
|
416
|
+
# the stream window opens again as the application takes data, so a reader that stops holds the server at one window
|
|
417
|
+
stream.consumed += size
|
|
418
|
+
if (
|
|
419
|
+
stream.consumed > self._local_window // 2
|
|
420
|
+
and not stream.finished
|
|
421
|
+
and not self.closed
|
|
422
|
+
):
|
|
423
|
+
increment, stream.consumed = stream.consumed, 0
|
|
424
|
+
await self._write(
|
|
425
|
+
self._frame(
|
|
426
|
+
FrameType.WINDOW_UPDATE,
|
|
427
|
+
0,
|
|
428
|
+
stream.identifier,
|
|
429
|
+
struct.pack("!I", increment),
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
|
|
412
433
|
async def _write(self, data: bytes) -> None:
|
|
413
434
|
async with self._write_lock:
|
|
414
435
|
await self._transport.write(data)
|
|
@@ -512,15 +533,7 @@ class Http2Connection:
|
|
|
512
533
|
if flags & END_STREAM:
|
|
513
534
|
stream.finished = True
|
|
514
535
|
stream.wake()
|
|
515
|
-
|
|
516
|
-
if stream.received > self._local_window // 2 and not stream.finished:
|
|
517
|
-
updates = self._frame(
|
|
518
|
-
FrameType.WINDOW_UPDATE,
|
|
519
|
-
0,
|
|
520
|
-
identifier,
|
|
521
|
-
struct.pack("!I", stream.received),
|
|
522
|
-
)
|
|
523
|
-
stream.received = 0
|
|
536
|
+
# the connection window opens on arrival, so one stream that is not read leaves the others their share
|
|
524
537
|
self._connection_received += len(payload)
|
|
525
538
|
if self._connection_received > self._connection_window // 2:
|
|
526
539
|
updates += self._frame(
|
|
@@ -657,6 +670,7 @@ class Http2StreamTransport:
|
|
|
657
670
|
while not self._pending:
|
|
658
671
|
if stream.chunks:
|
|
659
672
|
self._pending = stream.take()
|
|
673
|
+
await self._connection.credit(stream, len(self._pending))
|
|
660
674
|
elif stream.error is not None:
|
|
661
675
|
raise stream.error
|
|
662
676
|
elif stream.finished:
|
|
@@ -94,6 +94,8 @@ class Http3Settings:
|
|
|
94
94
|
|
|
95
95
|
# RFC 8470 section 5.2: the requests a client may send before the handshake, since a server may see them twice
|
|
96
96
|
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
97
|
+
# how much of a body may wait unsent in its QUIC stream before the next chunk is taken from the caller
|
|
98
|
+
SEND_BUFFER = 1 << 20
|
|
97
99
|
|
|
98
100
|
|
|
99
101
|
class StreamFrames:
|
|
@@ -266,6 +268,7 @@ class Http3Connection:
|
|
|
266
268
|
async with deadline.phase("write"):
|
|
267
269
|
async for chunk in request.body.chunks():
|
|
268
270
|
self._quic.send(stream_id, self._frame(FrameType.DATA, chunk))
|
|
271
|
+
await self._quic.drain(stream_id, SEND_BUFFER)
|
|
269
272
|
self._quic.send(stream_id, b"", end=True)
|
|
270
273
|
frames = StreamFrames(self._quic, stream_id, deadline)
|
|
271
274
|
status, headers = await self._response_head(frames, stream_id)
|
|
@@ -554,6 +557,8 @@ class Http3StreamTransport:
|
|
|
554
557
|
+ Writer.encode_varint(len(data))
|
|
555
558
|
+ data,
|
|
556
559
|
)
|
|
560
|
+
async with self._deadline.phase("write"):
|
|
561
|
+
await self._quic.drain(self._stream_id, SEND_BUFFER)
|
|
557
562
|
|
|
558
563
|
async def close(self) -> None:
|
|
559
564
|
if not self._closed:
|