boring-http 2.0.0__tar.gz → 2.0.2__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 (28) hide show
  1. {boring_http-2.0.0 → boring_http-2.0.2}/PKG-INFO +6 -6
  2. {boring_http-2.0.0 → boring_http-2.0.2}/README.md +4 -4
  3. {boring_http-2.0.0 → boring_http-2.0.2}/pyproject.toml +2 -2
  4. {boring_http-2.0.0 → boring_http-2.0.2}/pyproject.toml.orig +2 -2
  5. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/client.py +27 -7
  6. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/http3.py +11 -1
  7. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/pool.py +2 -1
  8. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/quic/connection.py +69 -21
  9. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/timing.py +3 -0
  10. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/websocket.py +2 -0
  11. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/__init__.py +0 -0
  12. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/codings.py +0 -0
  13. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/cookies.py +0 -0
  14. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/errors.py +0 -0
  15. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/hpack.py +0 -0
  16. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/hpack_tables.py +0 -0
  17. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/http1.py +0 -0
  18. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/http2.py +0 -0
  19. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/message.py +0 -0
  20. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/py.typed +0 -0
  21. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/qpack.py +0 -0
  22. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/qpack_tables.py +0 -0
  23. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/quic/__init__.py +0 -0
  24. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/quic/packet.py +0 -0
  25. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/quic/protocol.py +0 -0
  26. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/quic/recovery.py +0 -0
  27. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/quic/streams.py +0 -0
  28. {boring_http-2.0.0 → boring_http-2.0.2}/src/boring_http/revocation.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: boring-http
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
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
5
  Requires-Dist: boring-ssl2>=1.2.0
6
- Requires-Dist: ssl2>=1.2.0
6
+ Requires-Dist: ssl2>=2.0.1
7
7
  Requires-Python: >=3.14
8
8
  Project-URL: Repository, https://github.com/baobien-dev/boring-http
9
9
  Description-Content-Type: text/markdown
@@ -98,7 +98,7 @@ async def main() -> None:
98
98
  asyncio.run(main())
99
99
  ```
100
100
 
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: `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
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.
102
102
 
103
103
  ## HTTP/3
104
104
 
@@ -152,7 +152,7 @@ async def main() -> None:
152
152
  asyncio.run(main())
153
153
  ```
154
154
 
155
- The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with the dynamic table the SETTINGS allow on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. `TransportParameters` sends the parameters in `order`, in that order; `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends the round trip time measured last time on a resumed connection. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
155
+ The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1; v2 needs `Parameter.VERSION_INFORMATION` in `order`. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with the dynamic table the SETTINGS allow on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. `TransportParameters` sends the parameters in `order`, in that order, and refuses a value the peer would never see: one that differs from the RFC's absent value, or a GREASE version, connection options or round trip time, with its parameter missing from `order`. `max_idle_timeout=0` sets no idle timeout. `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends `initial_round_trip_time`, or else the round trip time measured on the last connection to that origin. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
156
156
 
157
157
  ## Proxies
158
158
 
@@ -425,7 +425,7 @@ async def main() -> None:
425
425
  asyncio.run(main())
426
426
  ```
427
427
 
428
- `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in `versions` or in the ClientHello needs `http2`, and `h3` in `versions` needs `http3`.
428
+ `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in the ALPN offered over TCP, which is the ClientHello's when `tls` is given and that of `versions` otherwise, needs `http2`, and `h3` in `versions` needs `http3`. A connection that must be HTTP/1.1, such as the TLS hop to an `https` proxy or a WebSocket fallback, offers only `http/1.1` and drops the ALPS extension.
429
429
 
430
430
  ## WebSocket
431
431
 
@@ -453,7 +453,7 @@ async def main() -> None:
453
453
  asyncio.run(main())
454
454
  ```
455
455
 
456
- Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen.
456
+ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen. The `total` of `timeout` covers the handshake only; afterwards `read` and `write` still limit each wait, so `Timeout(read=None)` lets `receive()` wait through any silence.
457
457
 
458
458
  ## uvloop
459
459
 
@@ -88,7 +88,7 @@ async def main() -> None:
88
88
  asyncio.run(main())
89
89
  ```
90
90
 
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: `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
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.
92
92
 
93
93
  ## HTTP/3
94
94
 
@@ -142,7 +142,7 @@ async def main() -> None:
142
142
  asyncio.run(main())
143
143
  ```
144
144
 
145
- The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with the dynamic table the SETTINGS allow on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. `TransportParameters` sends the parameters in `order`, in that order; `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends the round trip time measured last time on a resumed connection. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
145
+ The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1; v2 needs `Parameter.VERSION_INFORMATION` in `order`. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with the dynamic table the SETTINGS allow on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. `TransportParameters` sends the parameters in `order`, in that order, and refuses a value the peer would never see: one that differs from the RFC's absent value, or a GREASE version, connection options or round trip time, with its parameter missing from `order`. `max_idle_timeout=0` sets no idle timeout. `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends `initial_round_trip_time`, or else the round trip time measured on the last connection to that origin. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
146
146
 
147
147
  ## Proxies
148
148
 
@@ -415,7 +415,7 @@ async def main() -> None:
415
415
  asyncio.run(main())
416
416
  ```
417
417
 
418
- `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in `versions` or in the ClientHello needs `http2`, and `h3` in `versions` needs `http3`.
418
+ `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in the ALPN offered over TCP, which is the ClientHello's when `tls` is given and that of `versions` otherwise, needs `http2`, and `h3` in `versions` needs `http3`. A connection that must be HTTP/1.1, such as the TLS hop to an `https` proxy or a WebSocket fallback, offers only `http/1.1` and drops the ALPS extension.
419
419
 
420
420
  ## WebSocket
421
421
 
@@ -443,7 +443,7 @@ async def main() -> None:
443
443
  asyncio.run(main())
444
444
  ```
445
445
 
446
- Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen.
446
+ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen. The `total` of `timeout` covers the handshake only; afterwards `read` and `write` still limit each wait, so `Timeout(read=None)` lets `receive()` wait through any silence.
447
447
 
448
448
  ## uvloop
449
449
 
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "boring-http"
3
- version = "2.0.0"
3
+ version = "2.0.2"
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"
7
7
  dependencies = [
8
8
  "boring-ssl2>=1.2.0",
9
- "ssl2>=1.2.0",
9
+ "ssl2>=2.0.1",
10
10
  ]
11
11
 
12
12
  [project.urls]
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "boring-http"
3
- version = "2.0.0"
3
+ version = "2.0.2"
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"
7
7
  dependencies = [
8
8
  "boring-ssl2>=1.2.0",
9
- "ssl2>=1.2.0",
9
+ "ssl2>=2.0.1",
10
10
  ]
11
11
 
12
12
  [project.urls]
@@ -60,6 +60,10 @@ ALPN = {
60
60
  HTTPVersion.HTTP_2: NextProtocol.H2,
61
61
  HTTPVersion.HTTP_1: NextProtocol.HTTP1,
62
62
  }
63
+ ALPS_EXTENSIONS = (
64
+ ExtensionType.application_settings,
65
+ ExtensionType.application_settings_old,
66
+ )
63
67
  ACCEPTED_ENCODINGS = "gzip, deflate, br, zstd"
64
68
  USER_AGENT = "boring-http"
65
69
  DEFAULT_TIMEOUT = Timeout()
@@ -119,10 +123,15 @@ class Client:
119
123
  self._http3 = http3
120
124
  self._alt_svc: dict[tuple[str, int], float] = {}
121
125
  self._versions = tuple(HTTPVersion(version) for version in versions)
122
- if http2 is None and (
123
- HTTPVersion.HTTP_2 in self._versions
124
- or (tls is not None and NextProtocol.H2.value in tls.application_protocols)
125
- ):
126
+ # over TCP the ClientHello's own ALPN goes out when one is given, else the ALPN of versions
127
+ offered = (
128
+ tls.application_protocols
129
+ if tls is not None
130
+ else tuple(
131
+ ALPN[version].value for version in self._versions if version in ALPN
132
+ )
133
+ )
134
+ if http2 is None and NextProtocol.H2.value in offered:
126
135
  raise ValueError("h2 is offered, so http2= needs an Http2Settings")
127
136
  if http3 is None and HTTPVersion.HTTP_3 in self._versions:
128
137
  raise ValueError("h3 is in versions, so http3= needs an Http3Settings")
@@ -460,11 +469,19 @@ class Client:
460
469
  return self._tls
461
470
  # an HTTP/1.1-only connection keeps the ClientHello but can offer only its own protocol
462
471
  names = tuple(protocol.value for protocol in protocols)
472
+ settings = tuple(
473
+ name for name in self._tls.application_settings if name in names
474
+ )
463
475
  return replace(
464
476
  self._tls,
465
477
  application_protocols=names,
466
- application_settings=tuple(
467
- name for name in self._tls.application_settings if name in names
478
+ application_settings=settings,
479
+ extensions=self._tls.extensions
480
+ if settings
481
+ else tuple(
482
+ extension
483
+ for extension in self._tls.extensions
484
+ if extension not in ALPS_EXTENSIONS
468
485
  ),
469
486
  )
470
487
 
@@ -516,6 +533,9 @@ class Client:
516
533
  self._context((NextProtocol.HTTP1,) if http1_only else None),
517
534
  url.address,
518
535
  proxy=proxy,
536
+ proxy_context=self._context((NextProtocol.HTTP1,))
537
+ if proxy is not None and proxy.scheme is ProxyScheme.HTTPS
538
+ else None,
519
539
  )
520
540
  await self._checked(stream, url.authority)
521
541
  if (
@@ -533,7 +553,7 @@ class Client:
533
553
  transport: AsyncTransport
534
554
  if proxy.scheme is ProxyScheme.HTTPS:
535
555
  secured = await TLSStream.connect(
536
- self._context(), (proxy.host, proxy.port)
556
+ self._context((NextProtocol.HTTP1,)), (proxy.host, proxy.port)
537
557
  )
538
558
  await self._checked(secured, f"{proxy.host}:{proxy.port}")
539
559
  transport = secured
@@ -15,7 +15,7 @@ from boring_http.errors import (
15
15
  )
16
16
  from boring_http.message import Headers, HTTPVersion, Request, Response
17
17
  from boring_http.qpack import Qpack
18
- from boring_http.quic.connection import TransportParameters
18
+ from boring_http.quic.connection import Parameter, TransportParameters
19
19
  from boring_http.quic.packet import Reader, Writer
20
20
  from boring_http.quic.protocol import OriginMemory, QuicClient
21
21
  from boring_http.timing import Deadline, Timeout
@@ -76,6 +76,16 @@ class Http3Settings:
76
76
  tls: ClientHello | None = None
77
77
  grease: bool = False
78
78
 
79
+ def __post_init__(self) -> None:
80
+ # RFC 9368 section 2.1: the first packet is v1 and the other version is offered only in version_information
81
+ if (
82
+ self.quic_version is not QuicVersion.V1
83
+ and Parameter.VERSION_INFORMATION not in self.transport.order
84
+ ):
85
+ raise ValueError(
86
+ f"quic_version {self.quic_version.name} needs VERSION_INFORMATION in transport.order"
87
+ )
88
+
79
89
  def setting(self, identifier: Setting, default: int) -> int:
80
90
  return next(
81
91
  (value for key, value in self.settings if key == identifier), default
@@ -68,7 +68,8 @@ class Pool:
68
68
  await limit.acquire()
69
69
  await self._total.acquire()
70
70
  try:
71
- connection = await self._connector(url, proxy, quic)
71
+ async with deadline.phase("connect"):
72
+ connection = await self._connector(url, proxy, quic)
72
73
  except BaseException:
73
74
  limit.release()
74
75
  self._total.release()
@@ -113,6 +113,21 @@ NEVER_REMEMBERED = (
113
113
  Parameter.ORIGINAL_DESTINATION_CONNECTION_ID,
114
114
  Parameter.RETRY_SOURCE_CONNECTION_ID,
115
115
  )
116
+ # RFC 9000 section 18.2 and RFC 9221 section 3: the values a peer assumes when a parameter is absent
117
+ ABSENT = {
118
+ Parameter.MAX_IDLE_TIMEOUT: 0,
119
+ Parameter.MAX_UDP_PAYLOAD_SIZE: 65527,
120
+ Parameter.INITIAL_MAX_DATA: 0,
121
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL: 0,
122
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE: 0,
123
+ Parameter.INITIAL_MAX_STREAM_DATA_UNI: 0,
124
+ Parameter.INITIAL_MAX_STREAMS_BIDI: 0,
125
+ Parameter.INITIAL_MAX_STREAMS_UNI: 0,
126
+ Parameter.ACK_DELAY_EXPONENT: 3,
127
+ Parameter.MAX_ACK_DELAY: 25,
128
+ Parameter.ACTIVE_CONNECTION_ID_LIMIT: 2,
129
+ Parameter.MAX_DATAGRAM_FRAME_SIZE: 0,
130
+ }
116
131
 
117
132
 
118
133
  @dataclass(frozen=True, slots=True)
@@ -126,26 +141,37 @@ class TransportParameters:
126
141
  initial_max_streams_bidi: int
127
142
  initial_max_streams_uni: int
128
143
  order: tuple[Parameter, ...]
129
- # RFC 9000 section 18.2 and RFC 9221 section 3: the values a peer assumes when a parameter is absent
130
- ack_delay_exponent: int = 3
131
- max_ack_delay: int = 25
132
- active_connection_id_limit: int = 2
133
- max_datagram_frame_size: int = 0
144
+ ack_delay_exponent: int = ABSENT[Parameter.ACK_DELAY_EXPONENT]
145
+ max_ack_delay: int = ABSENT[Parameter.MAX_ACK_DELAY]
146
+ active_connection_id_limit: int = ABSENT[Parameter.ACTIVE_CONNECTION_ID_LIMIT]
147
+ max_datagram_frame_size: int = ABSENT[Parameter.MAX_DATAGRAM_FRAME_SIZE]
134
148
  google_connection_options: tuple[bytes, ...] = ()
135
149
  initial_round_trip_time: int | None = None
136
150
  grease: bool = False
137
151
  grease_version: bool = False
138
152
  shuffle: bool = False
139
153
 
140
- def encode(
141
- self,
142
- source_connection_id: bytes,
143
- version: QuicVersion,
144
- versions: tuple[QuicVersion, ...],
145
- round_trip_time: int | None = None,
146
- ) -> bytes:
147
- writer = Writer()
148
- integers = {
154
+ def __post_init__(self) -> None:
155
+ # a value left out of order never reaches the peer, which then works from the absent value instead
156
+ applied = {
157
+ parameter: value != ABSENT[parameter]
158
+ for parameter, value in self._integers.items()
159
+ } | {
160
+ Parameter.VERSION_INFORMATION: self.grease_version,
161
+ Parameter.GOOGLE_CONNECTION_OPTIONS: bool(self.google_connection_options),
162
+ Parameter.INITIAL_ROUND_TRIP_TIME: self.initial_round_trip_time is not None,
163
+ }
164
+ unsent = [
165
+ parameter.name
166
+ for parameter, changed in applied.items()
167
+ if changed and parameter not in self.order
168
+ ]
169
+ if unsent:
170
+ raise ValueError(f"{', '.join(unsent)} must be in order to take effect")
171
+
172
+ @property
173
+ def _integers(self) -> dict[Parameter, int]:
174
+ return {
149
175
  Parameter.MAX_IDLE_TIMEOUT: self.max_idle_timeout,
150
176
  Parameter.MAX_UDP_PAYLOAD_SIZE: self.max_udp_payload_size,
151
177
  Parameter.INITIAL_MAX_DATA: self.initial_max_data,
@@ -159,6 +185,16 @@ class TransportParameters:
159
185
  Parameter.ACTIVE_CONNECTION_ID_LIMIT: self.active_connection_id_limit,
160
186
  Parameter.MAX_DATAGRAM_FRAME_SIZE: self.max_datagram_frame_size,
161
187
  }
188
+
189
+ def encode(
190
+ self,
191
+ source_connection_id: bytes,
192
+ version: QuicVersion,
193
+ versions: tuple[QuicVersion, ...],
194
+ round_trip_time: int | None = None,
195
+ ) -> bytes:
196
+ writer = Writer()
197
+ integers = self._integers
162
198
  pairs: list[tuple[int, bytes]] = []
163
199
  for parameter in self.order:
164
200
  if parameter in integers:
@@ -361,7 +397,7 @@ class QuicConnection:
361
397
  self._closing: ConnectionClose | None = None
362
398
  self._close_sent = False
363
399
  self._terminated = False
364
- self._idle_timeout = parameters.max_idle_timeout / 1000
400
+ self._idle_timeout: float | None = parameters.max_idle_timeout / 1000 or None
365
401
  self._last_activity = now
366
402
  self._ack_timer: float | None = None
367
403
  self._peer_connection_ids: dict[int, tuple[bytes, bytes]] = {}
@@ -586,18 +622,23 @@ class QuicConnection:
586
622
  self._mtu_probe = None
587
623
 
588
624
  def next_timer(self) -> float | None:
589
- candidates = [self._last_activity + self._idle_timeout]
625
+ candidates: list[float] = []
626
+ if self._idle_timeout is not None:
627
+ candidates.append(self._last_activity + self._idle_timeout)
590
628
  if self._ack_timer is not None:
591
629
  candidates.append(self._ack_timer)
592
630
  timer = self._recovery.loss_timer(EncryptionLevel.HANDSHAKE in self._keys)
593
631
  if timer is not None:
594
632
  candidates.append(timer[0])
595
- return min(candidates)
633
+ return min(candidates, default=None)
596
634
 
597
635
  def handle_timer(self, now: float) -> None:
598
636
  if self._terminated:
599
637
  return
600
- if now >= self._last_activity + self._idle_timeout:
638
+ if (
639
+ self._idle_timeout is not None
640
+ and now >= self._last_activity + self._idle_timeout
641
+ ):
601
642
  self._terminated = True
602
643
  self._events.append(
603
644
  ConnectionTerminated(TransportError.NO_ERROR, "idle timeout", False)
@@ -1010,9 +1051,16 @@ class QuicConnection:
1010
1051
  self._peer_max_streams[False] = peer.integer(
1011
1052
  Parameter.INITIAL_MAX_STREAMS_UNI, 0
1012
1053
  )
1013
- peer_idle = peer.integer(Parameter.MAX_IDLE_TIMEOUT, 0) / 1000
1014
- own_idle = self._parameters.max_idle_timeout / 1000
1015
- self._idle_timeout = min(own_idle, peer_idle) if peer_idle else own_idle
1054
+ # RFC 9000 section 10.1: the smaller advertised timeout, where 0 means that side sets none
1055
+ timeouts = [
1056
+ value / 1000
1057
+ for value in (
1058
+ peer.integer(Parameter.MAX_IDLE_TIMEOUT, 0),
1059
+ self._parameters.max_idle_timeout,
1060
+ )
1061
+ if value
1062
+ ]
1063
+ self._idle_timeout = min(timeouts) if timeouts else None
1016
1064
  self._recovery.max_ack_delay = peer.integer(Parameter.MAX_ACK_DELAY, 25) / 1000
1017
1065
 
1018
1066
  def _reject_early_data(self) -> None:
@@ -45,6 +45,9 @@ class Deadline:
45
45
  def phase(self, name: Phase) -> PhaseTimer:
46
46
  return PhaseTimer(self, name)
47
47
 
48
+ def drop_total(self) -> None:
49
+ self._end = None
50
+
48
51
  def limit(self, name: Phase) -> tuple[float | None, bool]:
49
52
  # the total budget wins when it leaves less time than the phase's own limit, and the error then names the total
50
53
  limit = self._limits[name]
@@ -37,6 +37,8 @@ class WebSocket:
37
37
  ) -> None:
38
38
  self._transport = transport
39
39
  self._buffer = bytearray(leftover)
40
+ # the total covered the handshake; from here each read and write keeps only its phase limit
41
+ deadline.drop_total()
40
42
  self._deadline = deadline
41
43
  self._limit = max_message_size
42
44
  self._fragments: list[bytes] = []