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.
Files changed (29) hide show
  1. boring_http-2.0.2/README.md → boring_http-2.1.0/PKG-INFO +13 -1
  2. boring_http-2.0.2/PKG-INFO → boring_http-2.1.0/README.md +3 -11
  3. {boring_http-2.0.2 → boring_http-2.1.0}/pyproject.toml +1 -1
  4. {boring_http-2.0.2 → boring_http-2.1.0}/pyproject.toml.orig +1 -1
  5. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/cookies.py +15 -0
  6. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/http2.py +25 -11
  7. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/http3.py +5 -0
  8. boring_http-2.1.0/src/boring_http/public_suffixes.py +10407 -0
  9. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/connection.py +23 -10
  10. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/protocol.py +19 -1
  11. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/streams.py +6 -0
  12. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/__init__.py +0 -0
  13. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/client.py +0 -0
  14. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/codings.py +0 -0
  15. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/errors.py +0 -0
  16. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/hpack.py +0 -0
  17. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/hpack_tables.py +0 -0
  18. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/http1.py +0 -0
  19. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/message.py +0 -0
  20. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/pool.py +0 -0
  21. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/py.typed +0 -0
  22. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/qpack.py +0 -0
  23. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/qpack_tables.py +0 -0
  24. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/__init__.py +0 -0
  25. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/packet.py +0 -0
  26. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/quic/recovery.py +0 -0
  27. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/revocation.py +0 -0
  28. {boring_http-2.0.2 → boring_http-2.1.0}/src/boring_http/timing.py +0 -0
  29. {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.2"
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.2"
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
- self.received = 0
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
- yield stream.take()
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
- stream.received += len(payload)
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: