boring-http 2.0.3__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.3/README.md → boring_http-2.1.0/PKG-INFO +12 -0
- boring_http-2.0.3/PKG-INFO → boring_http-2.1.0/README.md +2 -10
- {boring_http-2.0.3 → boring_http-2.1.0}/pyproject.toml +1 -1
- {boring_http-2.0.3 → boring_http-2.1.0}/pyproject.toml.orig +1 -1
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/http2.py +25 -11
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/http3.py +5 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/quic/connection.py +23 -10
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/quic/protocol.py +19 -1
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/quic/streams.py +6 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/__init__.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/client.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/codings.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/cookies.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/errors.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/hpack.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/hpack_tables.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/http1.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/message.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/pool.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/public_suffixes.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/py.typed +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/qpack.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/qpack_tables.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/quic/__init__.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/quic/packet.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/quic/recovery.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/revocation.py +0 -0
- {boring_http-2.0.3 → boring_http-2.1.0}/src/boring_http/timing.py +0 -0
- {boring_http-2.0.3 → 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.
|
|
@@ -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.3
|
|
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.
|
|
@@ -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"
|
|
@@ -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:
|
|
@@ -499,6 +499,29 @@ class QuicConnection:
|
|
|
499
499
|
if end:
|
|
500
500
|
stream.end()
|
|
501
501
|
|
|
502
|
+
def unsent(self, stream_id: int) -> int:
|
|
503
|
+
stream = self._send_streams.get(stream_id)
|
|
504
|
+
return 0 if stream is None else stream.unsent
|
|
505
|
+
|
|
506
|
+
def stream_read(self, stream_id: int, size: int) -> bool:
|
|
507
|
+
# the stream window opens again as the application takes data, so a reader that stops holds the server at one window
|
|
508
|
+
receive = self._receive_streams.get(stream_id)
|
|
509
|
+
if receive is None or receive.final_size is not None:
|
|
510
|
+
return False
|
|
511
|
+
receive.read += size
|
|
512
|
+
window = (
|
|
513
|
+
self._parameters.initial_max_stream_data_bidi_local
|
|
514
|
+
if stream_id % 4 < 2
|
|
515
|
+
else self._parameters.initial_max_stream_data_uni
|
|
516
|
+
)
|
|
517
|
+
if receive.max_data - receive.read >= window // 2:
|
|
518
|
+
return False
|
|
519
|
+
receive.max_data = receive.read + window
|
|
520
|
+
self._pending[EncryptionLevel.APPLICATION].append(
|
|
521
|
+
MaxStreamData(stream_id, receive.max_data)
|
|
522
|
+
)
|
|
523
|
+
return True
|
|
524
|
+
|
|
502
525
|
def reset_stream(self, stream_id: int, error: int) -> None:
|
|
503
526
|
stream = self._send_streams.get(stream_id)
|
|
504
527
|
if stream is not None and not stream.reset_sent:
|
|
@@ -935,16 +958,6 @@ class QuicConnection:
|
|
|
935
958
|
if data:
|
|
936
959
|
receive.chunks.append(data)
|
|
937
960
|
self._readable[stream_id] = receive
|
|
938
|
-
window = (
|
|
939
|
-
self._parameters.initial_max_stream_data_bidi_local
|
|
940
|
-
if bidirectional
|
|
941
|
-
else self._parameters.initial_max_stream_data_uni
|
|
942
|
-
)
|
|
943
|
-
if receive.max_data - receive.consumed < window // 2 and not end:
|
|
944
|
-
receive.max_data = receive.consumed + window
|
|
945
|
-
self._pending[EncryptionLevel.APPLICATION].append(
|
|
946
|
-
MaxStreamData(stream_id, receive.max_data)
|
|
947
|
-
)
|
|
948
961
|
if end:
|
|
949
962
|
del self._receive_streams[stream_id]
|
|
950
963
|
if (
|
|
@@ -135,6 +135,8 @@ class QuicClient:
|
|
|
135
135
|
)
|
|
136
136
|
self._inboxes: dict[int, Inbox] = {}
|
|
137
137
|
self._unsent: deque[bytes] = deque()
|
|
138
|
+
# set after every flush, so a writer waiting for its stream to drain looks again
|
|
139
|
+
self._progress = asyncio.Event()
|
|
138
140
|
self._closed = False
|
|
139
141
|
self.incoming: asyncio.Queue[int] = asyncio.Queue()
|
|
140
142
|
self.datagrams: asyncio.Queue[bytes] = asyncio.Queue()
|
|
@@ -261,7 +263,10 @@ class QuicClient:
|
|
|
261
263
|
while True:
|
|
262
264
|
if inbox.chunks:
|
|
263
265
|
chunks, inbox.chunks = inbox.chunks, []
|
|
264
|
-
|
|
266
|
+
data = chunks[0] if len(chunks) == 1 else b"".join(chunks)
|
|
267
|
+
if self._connection.stream_read(stream_id, len(data)):
|
|
268
|
+
self._flush_soon()
|
|
269
|
+
return data
|
|
265
270
|
if inbox.error is not None:
|
|
266
271
|
raise inbox.error
|
|
267
272
|
if inbox.ended:
|
|
@@ -270,6 +275,17 @@ class QuicClient:
|
|
|
270
275
|
await inbox.waiter
|
|
271
276
|
inbox.waiter = None
|
|
272
277
|
|
|
278
|
+
async def drain(self, stream_id: int, limit: int) -> None:
|
|
279
|
+
# a writer waits while more than limit bytes of its stream are unsent, so a large body is never queued whole
|
|
280
|
+
while self._connection.unsent(stream_id) > limit:
|
|
281
|
+
inbox = self._inboxes.get(stream_id)
|
|
282
|
+
if inbox is not None and inbox.error is not None:
|
|
283
|
+
raise inbox.error
|
|
284
|
+
if self._terminated.done():
|
|
285
|
+
raise RemoteDisconnected("QUIC connection closed with data unsent")
|
|
286
|
+
self._progress.clear()
|
|
287
|
+
await self._progress.wait()
|
|
288
|
+
|
|
273
289
|
def close(self, error: int = 0, reason: str = "") -> None:
|
|
274
290
|
if not self._terminated.done():
|
|
275
291
|
self._connection.close(error, reason)
|
|
@@ -385,6 +401,7 @@ class QuicClient:
|
|
|
385
401
|
# these count as lost, and the connection goes back to the size every path carries
|
|
386
402
|
self._connection.datagram_too_long()
|
|
387
403
|
index = end
|
|
404
|
+
self._progress.set()
|
|
388
405
|
self._dispatch()
|
|
389
406
|
self._arm_timer()
|
|
390
407
|
if len(datagrams) >= MAX_BURST and not self._unsent and self._pacer is None:
|
|
@@ -492,3 +509,4 @@ class QuicClient:
|
|
|
492
509
|
inbox.wake()
|
|
493
510
|
if not self._terminated.done():
|
|
494
511
|
self._terminated.set_result(ConnectionTerminated(0, str(error), False))
|
|
512
|
+
self._progress.set()
|
|
@@ -24,6 +24,10 @@ class SendStream:
|
|
|
24
24
|
def active(self) -> bool:
|
|
25
25
|
return bool(self.pending) or (self.ended and not self.fin_sent)
|
|
26
26
|
|
|
27
|
+
@property
|
|
28
|
+
def unsent(self) -> int:
|
|
29
|
+
return sum(len(data) for _, data in self.pending) - self.position
|
|
30
|
+
|
|
27
31
|
def write(self, data: bytes) -> None:
|
|
28
32
|
if self.ended:
|
|
29
33
|
raise ProtocolError(f"stream {self.stream_id} already ended")
|
|
@@ -90,6 +94,8 @@ class ReceiveStream:
|
|
|
90
94
|
self.stream_id = stream_id
|
|
91
95
|
self.max_data = max_data
|
|
92
96
|
self.consumed = 0
|
|
97
|
+
# what the application has taken, which is what moves max_data
|
|
98
|
+
self.read = 0
|
|
93
99
|
# in-order data the application has not been handed yet
|
|
94
100
|
self.chunks: list[bytes] = []
|
|
95
101
|
self.fragments: dict[int, bytes] = {}
|
|
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
|