lkrequest 0.2.2__tar.gz → 0.2.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.
Files changed (135) hide show
  1. {lkrequest-0.2.2 → lkrequest-0.2.4}/.github/workflows/ci.yml +3 -1
  2. lkrequest-0.2.4/CHANGELOG.md +262 -0
  3. {lkrequest-0.2.2 → lkrequest-0.2.4}/CONTRIBUTING.md +1 -1
  4. {lkrequest-0.2.2 → lkrequest-0.2.4}/Cargo.lock +187 -238
  5. {lkrequest-0.2.2 → lkrequest-0.2.4}/Cargo.toml +11 -11
  6. {lkrequest-0.2.2 → lkrequest-0.2.4}/PKG-INFO +252 -36
  7. {lkrequest-0.2.2 → lkrequest-0.2.4}/README-zh.md +246 -32
  8. {lkrequest-0.2.2 → lkrequest-0.2.4}/README.md +248 -32
  9. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/accept_encoding.py +10 -6
  10. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/authentication.py +2 -2
  11. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/basic_requests.py +3 -1
  12. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/browser_fingerprints.py +6 -1
  13. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/certificate_config.py +6 -6
  14. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/client_pool.py +5 -6
  15. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/connection_prewarming.py +30 -22
  16. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/cookie_management.py +7 -7
  17. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/custom_fingerprint.py +37 -13
  18. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/error_handling.py +31 -13
  19. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/event_hooks.py +4 -4
  20. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/fingerprint_validation.py +3 -3
  21. lkrequest-0.2.4/examples/http1_header_case.py +157 -0
  22. lkrequest-0.2.4/examples/logging_config.py +40 -0
  23. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/metrics.py +4 -4
  24. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/middleware.py +11 -7
  25. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/multipart_upload.py +11 -3
  26. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/pool_stats.py +11 -7
  27. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/proxy_pool.py +36 -7
  28. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/redirect_history.py +5 -5
  29. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/response_inspection.py +6 -4
  30. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/retry_strategies.py +38 -18
  31. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/session_pool.py +5 -4
  32. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/streaming_response.py +4 -4
  33. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/timeout_config.py +10 -31
  34. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/websocket.py +8 -2
  35. {lkrequest-0.2.2 → lkrequest-0.2.4}/pyproject.toml +4 -4
  36. {lkrequest-0.2.2 → lkrequest-0.2.4}/python/lkrequest/__init__.py +24 -4
  37. {lkrequest-0.2.2 → lkrequest-0.2.4}/python/lkrequest/_lkrequest.pyi +1132 -103
  38. {lkrequest-0.2.2 → lkrequest-0.2.4}/python/lkrequest/blocking.py +16 -4
  39. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/bridge.rs +15 -10
  40. lkrequest-0.2.4/src/build_info.rs +76 -0
  41. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/client.rs +812 -342
  42. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/client_pool.rs +5 -4
  43. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/dns.rs +15 -3
  44. lkrequest-0.2.4/src/error.rs +425 -0
  45. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/fingerprint.rs +380 -30
  46. lkrequest-0.2.4/src/hsts.rs +180 -0
  47. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/lib.rs +43 -9
  48. lkrequest-0.2.4/src/logging.rs +147 -0
  49. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/masque.rs +21 -2
  50. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/metrics.rs +0 -10
  51. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/middleware.rs +81 -20
  52. lkrequest-0.2.4/src/multipart.rs +133 -0
  53. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/network_partition.rs +3 -3
  54. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/priority.rs +1 -1
  55. lkrequest-0.2.4/src/protocol.rs +664 -0
  56. lkrequest-0.2.4/src/proxy.rs +622 -0
  57. lkrequest-0.2.4/src/proxy_source.rs +122 -0
  58. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/quic.rs +32 -1
  59. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/randomize.rs +3 -3
  60. lkrequest-0.2.4/src/resolver.rs +185 -0
  61. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/response.rs +37 -23
  62. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/retry.rs +54 -12
  63. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/session.rs +437 -287
  64. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/session_pool.rs +84 -65
  65. lkrequest-0.2.4/src/session_state.rs +231 -0
  66. lkrequest-0.2.4/src/trust_anchor.rs +108 -0
  67. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/types.rs +37 -112
  68. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/upload.rs +6 -6
  69. {lkrequest-0.2.2 → lkrequest-0.2.4}/src/websocket.rs +172 -95
  70. lkrequest-0.2.4/tests/_raw_http.py +148 -0
  71. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/conftest.py +34 -13
  72. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_advanced.py +157 -7
  73. lkrequest-0.2.4/tests/test_broken_quic.py +181 -0
  74. lkrequest-0.2.4/tests/test_build_info.py +65 -0
  75. lkrequest-0.2.4/tests/test_check_proxy.py +80 -0
  76. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_client.py +181 -12
  77. lkrequest-0.2.4/tests/test_client_requests.py +79 -0
  78. lkrequest-0.2.4/tests/test_client_settings.py +135 -0
  79. lkrequest-0.2.4/tests/test_compression.py +91 -0
  80. lkrequest-0.2.4/tests/test_cookie_max_age.py +113 -0
  81. lkrequest-0.2.4/tests/test_dns.py +241 -0
  82. lkrequest-0.2.4/tests/test_durations.py +54 -0
  83. lkrequest-0.2.4/tests/test_error_details.py +239 -0
  84. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_followup_features.py +221 -8
  85. lkrequest-0.2.4/tests/test_h2_profile_validate.py +89 -0
  86. lkrequest-0.2.4/tests/test_h2_wire.py +430 -0
  87. lkrequest-0.2.4/tests/test_h3_retry.py +197 -0
  88. lkrequest-0.2.4/tests/test_hsts.py +126 -0
  89. lkrequest-0.2.4/tests/test_http1_header_case.py +169 -0
  90. lkrequest-0.2.4/tests/test_http1_host_case.py +169 -0
  91. lkrequest-0.2.4/tests/test_keylog.py +84 -0
  92. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_local_server.py +19 -19
  93. lkrequest-0.2.4/tests/test_logging.py +94 -0
  94. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_masque.py +24 -0
  95. lkrequest-0.2.4/tests/test_protocol_policy.py +182 -0
  96. lkrequest-0.2.4/tests/test_proxy_connect.py +78 -0
  97. lkrequest-0.2.4/tests/test_proxy_custom_auth.py +152 -0
  98. lkrequest-0.2.4/tests/test_proxy_pools.py +338 -0
  99. lkrequest-0.2.4/tests/test_randomize_fingerprint.py +115 -0
  100. lkrequest-0.2.4/tests/test_redirect_safety.py +196 -0
  101. lkrequest-0.2.4/tests/test_request_options.py +71 -0
  102. lkrequest-0.2.4/tests/test_request_order.py +262 -0
  103. lkrequest-0.2.4/tests/test_session_lifecycle.py +385 -0
  104. lkrequest-0.2.4/tests/test_tcp_fingerprint.py +143 -0
  105. lkrequest-0.2.4/tests/test_tls_profile_ech.py +93 -0
  106. lkrequest-0.2.4/tests/test_trust_anchor.py +248 -0
  107. lkrequest-0.2.4/tests/test_websocket.py +122 -0
  108. lkrequest-0.2.4/tests/test_websocket_concurrency.py +122 -0
  109. lkrequest-0.2.2/CHANGELOG.md +0 -138
  110. lkrequest-0.2.2/examples/logging_config.py +0 -58
  111. lkrequest-0.2.2/src/error.rs +0 -158
  112. lkrequest-0.2.2/src/hsts.rs +0 -121
  113. lkrequest-0.2.2/src/multipart.rs +0 -70
  114. lkrequest-0.2.2/src/protocol.rs +0 -200
  115. lkrequest-0.2.2/src/proxy.rs +0 -321
  116. {lkrequest-0.2.2 → lkrequest-0.2.4}/.editorconfig +0 -0
  117. {lkrequest-0.2.2 → lkrequest-0.2.4}/.gitignore +0 -0
  118. {lkrequest-0.2.2 → lkrequest-0.2.4}/LICENSE +0 -0
  119. {lkrequest-0.2.2 → lkrequest-0.2.4}/NOTICE +0 -0
  120. {lkrequest-0.2.2 → lkrequest-0.2.4}/SECURITY.md +0 -0
  121. {lkrequest-0.2.2 → lkrequest-0.2.4}/benchmarks/README.md +0 -0
  122. {lkrequest-0.2.2 → lkrequest-0.2.4}/benchmarks/bench_latency.py +0 -0
  123. {lkrequest-0.2.2 → lkrequest-0.2.4}/benchmarks/bench_memory.py +0 -0
  124. {lkrequest-0.2.2 → lkrequest-0.2.4}/benchmarks/bench_throughput.py +0 -0
  125. {lkrequest-0.2.2 → lkrequest-0.2.4}/benchmarks/requirements.txt +0 -0
  126. {lkrequest-0.2.2 → lkrequest-0.2.4}/build.rs +0 -0
  127. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/http_methods.py +0 -0
  128. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/ip_family.py +0 -0
  129. {lkrequest-0.2.2 → lkrequest-0.2.4}/examples/streaming_upload.py +0 -0
  130. {lkrequest-0.2.2 → lkrequest-0.2.4}/justfile +0 -0
  131. {lkrequest-0.2.2 → lkrequest-0.2.4}/python/lkrequest/pcap.py +0 -0
  132. {lkrequest-0.2.2 → lkrequest-0.2.4}/rustfmt.toml +0 -0
  133. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/_masque_proxy.py +0 -0
  134. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_package_metadata.py +0 -0
  135. {lkrequest-0.2.2 → lkrequest-0.2.4}/tests/test_stub_parity.py +0 -0
@@ -113,7 +113,9 @@ jobs:
113
113
  fail-fast: false
114
114
  matrix:
115
115
  include:
116
- - os: ubuntu-latest
116
+ # Pinned: actions/python-versions has no Python 3.9 build for
117
+ # Ubuntu 26.04, which ubuntu-latest now is.
118
+ - os: ubuntu-24.04
117
119
  wheel: wheel-x86_64-unknown-linux-gnu
118
120
  python: "3.9"
119
121
  - os: ubuntu-latest
@@ -0,0 +1,262 @@
1
+ # Changelog
2
+
3
+ All important changes to this project will be documented in this file.
4
+
5
+ ## lkrequest-py 0.2.4
6
+
7
+ Adds the Chrome 156 and Chrome 157 fingerprints, Trust Anchor Identifiers and a
8
+ switch for sending HTTP/1.1 header names in lowercase, and much of what the Rust
9
+ library offers and the bindings did not:
10
+ every exception now says what failed, pools take their proxies from a function,
11
+ names can be looked up by a DNS server of your own or by Python code, log events
12
+ can go to Python's `logging`, and protocol policies, QUIC cooldowns, HSTS
13
+ policies, proxy credentials, WebSocket connections and TCP and TLS fingerprints
14
+ can be set in full, among the rest listed below. It also moves the bindings to
15
+ PyO3 0.30 and refreshes the Rust dependencies, and fixes the proxy pool, which
16
+ handed out a key rather than a proxy. Default request headers and request
17
+ cookies now go out in the order they were given, as the Rust library sends
18
+ them; a copy made by `randomize_fingerprint()` keeps the options of the client
19
+ it copies; and the default fingerprint, Chrome 155, sends its trust-anchor IDs
20
+ in Chrome's sorted order instead of shuffling them. Apart from those three, and
21
+ unless a new option is used or one `Hsts.dynamic()` is given to several
22
+ sessions, requests go out on the wire exactly as before. Six changes are visible
23
+ from Python and worth reading before upgrading: `ProxyPool.acquire()` now
24
+ returns a `ProxyLease` instead of a string; `TimeoutConfig` and
25
+ `ResourceLimits`, which nothing accepted, are gone; the exception raised for an
26
+ argument of the wrong type no longer names the argument in its message;
27
+ `Client.session(proxy=...)` now raises `ValueError` for a proxy URL it cannot
28
+ parse, where it used to build a session that connected direct; a
29
+ `ProtocolPolicy` whose axes contradict one another raises `ValueError` when it
30
+ is given to a client or a session, rather than on the first request; and the
31
+ sessions given one `Hsts.dynamic()` now share what it learns.
32
+
33
+ ### 🚀 Features
34
+ - **Chrome 157 Fingerprint** — Added `Client.chrome_157()` / `BlockingClient.chrome_157()`, the `"chrome_157"` name for `tls_profile=` / `h2_profile=` / `quic_profile=`, and `TlsProfile.chrome_157()`, `TlsProfile.chrome_157_quic()`, `H2Profile.chrome_157()`, `QuicProfile.chrome_157()`. Captured from Chrome 157.0.8103.39 on Windows, it matches Chrome 156 throughout: the TCP and QUIC ClientHellos differ only in their name, and the H2, QUIC and HTTP/3 profiles are Chrome 156's. The capture confirmed the HTTP/3 SETTINGS and the navigation and favicon priority; timing, loss recovery and resumption carry over from Chrome 156 without being observed again. The default fingerprint stays Chrome 155.
35
+ - **Trust Anchor Identifiers** — `Client` and `BlockingClient` take `trust_anchor_advertisement=`, `trust_anchor_recovery=` and `trust_anchor_bindings=`, for the Rust library's new support of draft-ietf-tls-trust-anchor-ids: the IDs of the roots a client accepts, which the Chrome 152 to 157 profiles send in extension `0xca34`. `trust_anchor_advertisement="trusted"` sends the sorted IDs of the roots the client trusts instead of the profile's list. `trust_anchor_recovery="once"` opens one more connection when a server lists the IDs it can serve and then its certificate chain fails to verify, or it ends the handshake, asking for the first of those IDs the client trusts; at most once per request, with 0-RTT off, and a second failure raises the error of its cause with a message beginning `trust anchor recovery failed`. `trust_anchor_bindings={id: anchor_identity}` gives IDs to roots of your own, and the new `lkrequest.trust_anchor_identity(der)` computes the identity a binding names a root by; the bundled IDs of the Chrome 157 root store need none. The IDs never change which certificates are trusted. A setting the Rust library rejects raises `ValueError`: recovery without `verify`, or with `proxy_fallback_direct=True`, `"trusted"` or `"once"` on a profile without the extension or with TLS synthesis, and a binding to a root the client does not load. `Response.diagnostics` and the `diagnostics` of an exception gained `trust_anchor`, the negotiation of the TLS connection the request opened: the IDs requested and offered, the root that verified the chain and, after a recovery, the first attempt; it is `None` on a pooled connection. Whatever the settings, a TLS failure on a server that listed IDs of its own is no longer retried.
36
+ - **Chrome 156 Fingerprint** — Added `Client.chrome_156()` / `BlockingClient.chrome_156()`, the `"chrome_156"` name for `tls_profile=` / `h2_profile=` / `quic_profile=`, and `TlsProfile.chrome_156()`, `TlsProfile.chrome_156_quic()`, `H2Profile.chrome_156()`, `QuicProfile.chrome_156()`. Captured from Chrome 156.0.8078.48 on Windows, it matches Chrome 155 throughout: the TCP and QUIC ClientHellos differ only in their name, and the H2, QUIC and HTTP/3 profiles are Chrome 155's. The capture confirmed the HTTP/3 SETTINGS and the navigation and favicon priority updates; timing, loss recovery and resumption carry over from Chrome 155 without being observed again. The default fingerprint stays Chrome 155.
37
+ - **Lowercase HTTP/1.1 Header Names** — Added `Client(http1_title_case_headers=...)` on both clients, and a read-only `http1_title_case_headers` property. HTTP/1.1 header names go out in Title-Case (`Content-Type`) by default, as before; `False` sends them in lowercase (`content-type`), including generated headers such as `content-length`, except for `Host`, which keeps its capital `H`. It applies to ordinary and streamed requests, preconnected connections and WebSocket handshakes, and leaves header values and order alone. Either way, the spelling a header was given in is not kept, and HTTP/2 and HTTP/3 always send lowercase names. Lowercase names alone do not make a request match what any particular browser sends over HTTP/1.1.
38
+ - **What An Exception Knows About The Failure** — Every `RequestError` now carries `phase`, `retryable`, `safe_to_replay`, `status_code`, `proxy_error_kind`, `tls_alert` and `diagnostics`, read from the Rust library's error, where an exception used to carry its message alone: an `HttpStatusError` could not give its status code, a `ProxyError` did not say whether the proxy wanted credentials or could not be reached, and the timings of a failed request were thrown away. `phase` names the step of the connection that failed (`"tcp_connect"`, `"proxy_tunnel"`, `"tls_handshake"`, …); `retryable` says whether trying again could succeed, and `safe_to_replay` whether the server cannot have processed the request, so that even a POST can be sent again; `proxy_error_kind` says what went wrong with a proxy (`"authentication_required"`, `"tunnel_refused"`, …) and `tls_alert` which alert a handshake ended in; `diagnostics` holds the timings and transport so far, in the form of `Response.diagnostics`, for a failed request of the HTTP methods and `request()`, and the response's own for `error_for_status()`. An attribute that does not apply is `None`, or `False` for the two flags, on an error built by hand as well, and the attributes survive pickling. A retry callable that also takes `exception=` (or `**kwargs`) is given the exception itself, or `None` when a status is being retried; one taking only `(attempt, error, status)` is called exactly as before.
39
+ - **Proxies From A Function** — `ProxyPool`, `SessionPool` and `BlockingSessionPool` take a function in place of the list of proxies. It is called with no arguments for every proxy the pool hands out and returns a URL or a `ProxyConfig`: the way to use a gateway that picks the exit by a session id in the username, with a new id each time. What the function raises comes out of the `acquire()` that called it, and so does a `ValueError` for a URL that does not parse or a `TypeError` for anything but a URL or a `ProxyConfig`, where the Rust library would only log it and keep asking, so that `acquire()` waited for good. An `async def` is refused with `TypeError`. Every proxy from a function is a new one, so `rotation`, `bad_proxy_config` and `health_check` do not apply to them, and `max_proxies` / `max_sessions` still cap how many are out at once. The async pools call the function on a worker thread that waits for it; `proxy_buffer=n` has `n` proxies made ahead of time on a thread of their own, where a failure is logged and the function is called again a second later, rather than at once and over and over as the Rust library would. `proxy_buffer=` with a list raises `ValueError`.
40
+ - **Blocking `ProxyPool`** — Added `lkrequest.blocking.ProxyPool` (`lkrequest.BlockingProxyPool`), which takes the same arguments as `ProxyPool`, blocks in `acquire()` and `acquire_fresh()`, and hands out the same `ProxyLease`.
41
+ - **`bad_proxy_config=` And `health_check=` For Session Pools** — `SessionPool` and `BlockingSessionPool` now take the `BadProxyConfig` and `HealthCheckConfig` that `ProxyPool` already did, so a session pool can cool a failing proxy down after fewer or more failures than the default three, and probe its proxies in the background.
42
+ - **A DNS Server Of Your Own** — `Client(dns=...)` and `BlockingClient(dns=...)` now take the address of a DNS server besides the seven resolver names: `"ip"` for port 53, or `"ip:port"`, an IPv6 address with a port in brackets (`"[2606:4700::1111]:53"`). It is asked over UDP, and over TCP for an answer too long for UDP. Like the named public resolvers and unlike the system one, it also answers HTTPS records, which is how the client learns a site's ECH configuration and HTTP/3 support from DNS. Anything that is neither a name nor an address still raises `ValueError`, whose message now mentions the address form.
43
+ - **Python Code As The Resolver** — `dns=` also takes Python code that looks names up: a function `resolve(host, port)` returning a list of addresses, each an IP, which takes the requested port, or `"ip:port"`; or an object with that method and, optionally, `lookup_https(host)`, returning `None` or the host's HTTPS record as a dict of `alpn`, `no_default_alpn`, `target`, `port`, `ech_config_list`, `ipv4_hints` and `ipv6_hints`, from which the client learns a site's HTTP/3 support and ECH configuration as it would from DNS. The code must be synchronous, and an `async def` raises `TypeError`; it runs on a thread of the runtime's blocking pool, so a slow lookup holds up no other request. `dns_timeout` bounds each call and `ip_family` filters the answers, as for any resolver. What `resolve` raises becomes the `__cause__` of the request's `ConnectionError`, so its traceback shows the line that failed; an answer that is not an address fails the request with a message saying so, and a `lookup_https` that fails is logged and counts as no record. A copy made by `randomize_fingerprint()` keeps the resolver.
44
+ - **Log Events In Python's `logging`** — `set_log_level(level, format="logging")` hands every event that passes `level` to Python's `logging` instead of writing it to stderr, on the logger `lkrequest.` followed by the event's Rust target with `::` as `.` (`lkrequest.lkrequest.session`, `lkrequest.lktls.handshake`), so `logging.getLogger("lkrequest")` takes them all. The message carries the event's fields as `name=value`; a TRACE event, which `logging` has no level for, has level 5. The events are queued and passed on from a thread of their own, a batch per hold of the GIL, so a request never waits for the GIL to log; a queue that fills up drops events, and none are passed on once the interpreter starts to exit. As with the other formats, only the first call in a process takes effect.
45
+ - **Every Axis Of A Protocol Policy** — `ProtocolPolicy` gained `with_acquisition()`, `with_upgrade()` and `with_fallback()`, the read-only `acquisition`, `upgrade` and `fallback`, and `validate()`, alongside the new `AcquisitionPolicy` (`ReuseExistingFirst`, `EstablishBestAvailable`), `RacePolicy`, `UpgradePolicy` (`Off`, `ProbeOnly`, `ProbeAndMigrate`, `ForceForNewRequests`) and `FallbackPolicy` (`AllowFallback`, `AllowFallbackOnNetworkErrors`, `NoFallback`). Only the four presets and `with_intent()` were available before. A `RacePolicy` races TCP against QUIC with the timing given: whether each takes part, the delay before each starts, and whether to race only an origin that has advertised HTTP/3; what it leaves out keeps `RacePolicy.chrome_like()`, which starts QUIC at once and TCP 0.3 s later. A policy whose axes contradict one another now raises `ValueError` when it is given to a client or a session, where the Rust library would only refuse it once the first request was sent; given to a single request, it is still refused before anything goes out.
46
+ - **WebSocket Extensions, Pings And URL** — `ws_connect()` on both session classes takes `extensions=`, offered in the upgrade's `Sec-WebSocket-Extensions` header in the order given; the header is part of what a browser's handshake looks like. Both connection classes gained `ping(data=b"")`, whose pong comes back through `recv()`, `pong(data=b"")` for an unsolicited pong, and a read-only `url`, still there once the connection is closed. A ping or pong of more than 125 bytes, which a peer would fail the connection for, raises `ValueError` before anything is sent. The frames are still read and written plain, so only an extension that leaves them alone can be used: once a server accepts `permessage-deflate`, the compression Chrome offers, every compressed message it sends fails to be received with `RequestError`.
47
+ - **TCP Options, Keep-Alive And Two More TCP Presets** — `TcpFingerprint(...)` takes `tcp_options=`, the SYN's option order as kind numbers — 0 (EOL), 1 (NOP), 2 (MSS), 3 (window scale), 4 (SACK permitted), 8 (timestamps), the second part of JA4T — and `keepalive=`, `keepalive_idle=` and `keepalive_interval=`. The option order only describes the target fingerprint, since the OS builds the SYN, and shows in `to_ja4t()`; keep-alive is set on the socket: `keepalive=True` turns it on with the OS's timing, either timing in seconds turns it on with that timing, and `keepalive=False` turns it off. An unknown option kind, or `keepalive=False` given with a timing, raises `ValueError`. Added `TcpFingerprint.to_dict()`, which reads every setting back under its argument's name, and the presets `TcpFingerprint.none()`, which sets nothing, and `TcpFingerprint.safari_ios()`.
48
+ - **QUIC Cooldowns Of Your Own** — `session(broken_quic_policy=...)` on both clients also takes a `BrokenQuicConfig`, for when none of the `BrokenQuicPolicy` presets fits how long an origin whose QUIC connection failed should stay on TCP. It takes `initial_cooldown`, the seconds after the first failure; `max_cooldown`, which the cooldown grows to over `failure_escalation_threshold` failures in a row; `route_cooldown`, for a proxy route that cannot carry QUIC; and `max_entries` and `max_route_entries`, how many origins and routes are remembered. What is left out keeps the value of `BrokenQuicPolicy.Strict`, the default, and each value reads back under its own name. A duration that is negative, NaN or infinite, an `initial_cooldown` above `max_cooldown` and a count of zero raise `ValueError`, where the Rust library would quietly cap `initial_cooldown` at `max_cooldown` and take a count of zero as one. `Strict`, `Resilient` and `Disabled` are unchanged.
49
+ - **Proxy Credentials Worked Out Per Connection** — Added `ProxyConfig.with_custom_auth(identity, authorize)`, for a proxy credential that has to be fresh, such as a short-lived token. `authorize()` takes no arguments and returns the value of the `Proxy-Authorization` header, or of the header `with_auth_header()` chose, or `None` to send none. It is called on the thread setting each connection up, which waits for it, and more than once for a connection the proxy challenges with a 407. One that raises, or returns something other than a string, is logged as a warning and the connection goes without a credential, so the request fails with the proxy's answer. `identity` is a stable string, not a secret, that becomes part of `identity()`, so that pools tell two credentials on the same proxy address apart. Like `with_http_auth()`, it replaces any credential already set and raises `ValueError` on a SOCKS5 proxy; an `async def` raises `TypeError`.
50
+ - **Check A Proxy Without A Pool** — Added `Client.check_proxy(proxy, target_host="www.google.com", target_port=443, timeout=5.0)`, and the blocking `BlockingClient.check_proxy()`, which set a tunnel up through a proxy URL or `ProxyConfig`, chains and credentials included, and return the seconds that took: the health check a `ProxyPool` runs in the background, made once. Nothing is sent through the tunnel. Names are resolved as the client resolves them, so `dns=` applies. A proxy that refuses the tunnel or cannot be reached raises `ProxyError`, one that takes longer than `timeout` raises `TimeoutError`, and a MASQUE proxy, which carries no TCP, raises `ProxyError`.
51
+ - **What A `ProxyConfig` Points At** — Added the read-only `ProxyConfig.host` and `ProxyConfig.port`, of the proxy the client talks to last — the only one, or a chain's final hop — with the scheme's port when the URL gives none and an IPv6 address in brackets, and `ProxyConfig.forbids_direct_fallback`, true for a route through a MASQUE proxy, which never falls back to a direct connection, even with `proxy_fallback_direct`.
52
+ - **HSTS Policies Of Your Own** — Added `Hsts.custom(should_upgrade, record_sts=None)`, a policy written in Python. `should_upgrade(host)` says whether an `http://` URL, a redirect's included, is upgraded to `https://`; `record_sts(host, header)`, if given, is told the value of each `Strict-Transport-Security` header received over HTTPS from a host that is not an IP address. They run on the thread running the request, which waits for them; one that raises is logged as a warning and the URL is not upgraded, and an `async def` raises `TypeError`. Added `Hsts.should_upgrade(host)`, which answers as a session would ask the policy — for a dynamic one, after what it has learned.
53
+ - **Requests Straight From A Client** — `Client` and `BlockingClient` gained `get()`, `post()`, `put()`, `delete()`, `head()`, `patch()` and `options()`, each `session().get(url, **kwargs)` and so on: the request goes out from a session made for the call and dropped after it, as the Rust library's `Client::get()` does, and takes what the session's method of the same name takes. No connection or cookie is kept from one call to the next, so for more than one request a session is still the way.
54
+ - **`H2Profile.validate()`** — Raises `ValueError` if an HTTP/2 profile breaks a rule a usable fingerprint keeps: the four pseudo-headers once each, no SETTINGS id twice, `ENABLE_PUSH` 0 or 1, `INITIAL_WINDOW_SIZE` at most 2\*\*31 − 1, `MAX_FRAME_SIZE` from 16384 to 16777215, and a `window_update` that keeps the connection window at most 2\*\*31 − 1. The message says which. Nothing else checks, as before: a client sends a profile that breaks one, made by hand or read from JSON, as it is.
55
+ - **TLS Secrets To A Function** — `Client(keylog=...)` and `BlockingClient(keylog=...)` take a function besides a file path, called with each line of the NSS key log format Wireshark reads, without its newline, several times per handshake. It runs on the thread doing the handshake, which waits for it, so every line of a request's handshake is in by the time the request returns; one that raises is logged as a warning and the handshake goes on. An `async def` raises `TypeError`. A file path may now also be an `os.PathLike`, such as a `pathlib.Path`.
56
+ - **A DER Certificate For The MASQUE Proxy** — `MasqueConfig(ca_cert_der=...)` trusts one DER certificate for the hop to a MASQUE proxy, as `Client(ca_cert_der=...)` does for the origin, alongside any from `ca_cert` and `ca_cert_pem`, and counts in `ca_cert_count`. Bytes that are not a usable certificate raise `ValueError` when the config is built, where the Rust library would only refuse them when the first tunnel is set up.
57
+ - **What A Build Offers** — Added `lkrequest.features()`, which says which optional features this build has, as a dict of `quic-h3`, `masque`, `doh`, `telemetry` and `synthetic-fp` to booleans, so that a build from source can be told apart without trying `hasattr(lkrequest, "QuicProfile")`; the wheels on PyPI have them all. Added `lkrequest.list_presets()`, the browser presets in a stable order, each a dict of `name`, `browser` and `http3`, whether the preset's client speaks HTTP/3 in this build; a name is what `Client.<name>()`, `tls_profile=` and `h2_profile=` take.
58
+ - **What A Client Was Built With** — `Client` and `BlockingClient` gained read-only `tls_profile`, `h2_profile`, `tcp_fingerprint`, `default_headers`, `header_order`, `h3_header_order`, `cookie_order`, `timeouts`, `resource_limits`, `protocol_policy`, `verify`, `ip_family`, `h2_fallback_h1`, `proxy_fallback_direct`, `max_pending_h2_requests` and `ech_config`: the settings in effect once the client is built, a preset applied and the defaults included, mostly under the name of the argument that sets each. `timeouts` and `resource_limits` are dicts keyed by those argument names, durations in seconds, and no cap on connections reads as `None`. `default_headers` gives `(name, value)` pairs in the order they go out, names lowercased.
59
+ - **ECH And The ClientHello Style On A Hand-Built `TlsProfile`** — `TlsProfile(...)` takes `tls_client_hello_style`, the TLS stack whose rules the ClientHello follows where no list says them (`"generic"`, the default, `"chromium_boringssl"`, `"firefox_nss"`, `"safari_cfnetwork"` or `"java_jsse"`), `ech`, the ECH extension to send, and `ech_outer_extensions`, the extensions an inner ClientHello compresses under real ECH. Only `TlsProfile.from_json()` could set them before. The new `EchMode` gives the extension: `EchMode.grease(...)`, with Chrome's HPKE suite unless told otherwise, `EchMode.real(ech_config_list)` and `EchMode.disabled()`. An unknown style, and a GREASE payload minimum above its maximum, raise `ValueError`.
60
+
61
+ ### 🔧 Changes
62
+ - **The Sessions Given One `Hsts` Share What It Learns** — `Hsts.dynamic()` made a new store for every session it was given to, so a host learned from a `Strict-Transport-Security` header in one session was not upgraded in another, even one given the same object. An `Hsts` object now holds one policy, shared by every session it is given to, as one browser profile shares what it learns. Code that gives one `Hsts.dynamic()` to several sessions now has each of them upgrade what any of them learned; to keep them apart, give each session an `Hsts.dynamic()` of its own. `Hsts.static_()`, `Hsts.none()` and the seed of `Hsts.dynamic()` behave as before.
63
+ - **`TimeoutConfig` And `ResourceLimits` Removed** — Both classes could be built, and the README showed them beside the client's timeout options, but nothing ever accepted one, so building one changed nothing. They are gone, from `lkrequest` and `lkrequest.blocking` alike. The `Client` / `BlockingClient` arguments they mirrored — `dns_timeout=`, `tcp_connect_timeout=`, `tls_handshake_timeout=`, `ttfb_timeout=`, `total_timeout=`, `max_response_body_size=`, `max_connections_per_session=`, `min_transfer_rate=` and the rest — are unchanged, and remain the way to set these limits.
64
+ - **PyO3 0.30** — The bindings now build on PyO3 0.30 (previously 0.23) and pyo3-async-runtimes 0.30, and the Rust dependencies are refreshed. The wheels still use the stable ABI of Python 3.9 and later; building from source now needs Rust 1.89 or later. What this changes in Python:
65
+ - **Argument errors no longer name the argument in their message.** `get(url, timeout="x")` used to raise `TypeError: argument 'timeout': must be real number, not str` and now raises `TypeError: must be real number, not str`. On Python 3.11 and later the argument is named in a note (`while processing 'timeout'`), which tracebacks print; Python 3.9 and 3.10 have no exception notes, so there the argument is not named at all. The exception types are unchanged, and so are the messages lkrequest raises itself, such as `tls_profile must be str (preset name) or TlsProfile object`. A value of the wrong class is now reported as `'str' object is not an instance of 'ProtocolPolicy'` rather than `'str' object cannot be converted to 'ProtocolPolicy'`.
66
+ - **Enum values returned by lkrequest are the class attributes themselves**, so `response.version is HttpVersion.H2` now holds; `==` and hashing behave as before.
67
+ - **The async client hands each result to the event loop from tokio's blocking thread pool** rather than from the thread that ran the request, so a result arriving while another thread holds the GIL no longer holds up the requests still in flight. Under load the process may show a few dozen more threads; they exit after ten idle seconds.
68
+ - Built from source for a free-threaded Python (3.13t, 3.14t), the module still turns the GIL back on when it is imported, as before: it has not been reviewed for running without the GIL.
69
+ - **Dependencies Without Known Advisories** — The wheels now build with rustls 0.23.46 (0.2.3: 0.23.36), rustls-webpki 0.103.16 (0.103.9), webpki-roots 1.0.10 (1.0.6), aws-lc-sys 0.46.0 (0.37.1), Tokio 1.54.1 (1.49.0) and zstd 0.13.4 (0.13.3) with zstd-safe 7.4.0 (7.2.4); the Rust library now requires rustls-webpki, webpki-roots, Tokio and zstd at 0.103.16, 1.0.10, 1.53.3 and 0.13.4 or later. When this was written, `cargo audit` found no advisory against the `Cargo.lock` the wheels build from, and 23 vulnerabilities against 0.2.3's. One fix shows from Python: certificate verification now rejects a chain through one of emSign's eight distrusted G3 intermediates, whatever root it ends in, and the bundled Mozilla roots no longer include emSign ECC Root CA - G3, so a server whose certificate was issued under them fails verification.
70
+
71
+ ### 🐞 Fixes
72
+ - **Chrome 155 Sends Its Trust-Anchor IDs In Chrome's Order** — inherited from upstream, and it changes the default fingerprint on the wire. `Client.chrome_155()`, the default, shuffled the 28 IDs of its `trust_anchors` extension (`0xca34`) on every handshake, where Chrome 155 sorts them before sending them. It now sends them in that one fixed, sorted order, over TCP and QUIC alike, and still shuffles the order of the extensions on every handshake; `TlsProfile.chrome_155()` lists the IDs sorted, with `shuffle` now `False`. The Chrome 152 to 154 presets keep shuffling the IDs.
73
+ - **`ProxyPool.acquire()` Returns A Lease With A Usable Proxy** — `acquire()` returned the proxy's pool key, `ProxyConfig.identity()`, which is not a URL: lately a JSON array that leaves the password out. Passed to `session(proxy=...)`, as it was meant to be, it built a session without a proxy (see the next entry), so the requests went out direct. And because the pool's share was handed back the moment `acquire()` returned, `max_proxies` capped nothing. `acquire()` now returns a `ProxyLease`, an incompatible change. Its `proxy` is the `ProxyConfig` to pass to `session(proxy=...)`, credentials included, or `None` from a pool created with an empty list, which used to return `""`. The lease holds one of the pool's `max_proxies` shares (default 100) until `release()`, the end of a `with` or `async with` block, or garbage collection. Added `ProxyLease.mark_bad()`; `ProxyPool.acquire_fresh(lease)`, which records a failure of the lease's proxy, releases the lease and leases another proxy; and `ProxyPool.max_concurrent`. `ProxyPool.mark_bad(identity)` is unchanged: pass it `lease.proxy.identity()`.
74
+ - **A Session Proxy URL That Does Not Parse Raises Instead Of Connecting Direct** — `Client.session(proxy=...)` and `BlockingClient.session(proxy=...)` handed a proxy URL to the Rust library, which logs a URL it cannot parse and builds the session without a proxy. A typo in the scheme, or a pool key such as `ProxyConfig.identity()` returns, therefore sent every request of that session straight from this machine to the target, exposing its address, and nothing was raised. Such a URL now raises `ValueError`, as it already did in `ProxyConfig(...)`, in the proxy lists of `ProxyPool` and `SessionPool`, and in a request's own `proxy=`.
75
+ - **`randomize_fingerprint()` Keeps The Rest Of The Client** — `Client.randomize_fingerprint()` and `BlockingClient.randomize_fingerprint()` built the copy from the TLS, HTTP/2, TCP and QUIC ClientHello profiles alone, so everything else the client was created with fell back to its default: default headers, header and cookie order, `http1_title_case_headers`, timeouts and limits, `verify` and the CA certificates, DNS, `ip_family`, `connect_to`, middleware, the QUIC profile and MASQUE. The copy of a browser preset also lost the preset's header-order templates and QUIC profile, so it stopped sending what that browser sends. The copy is now built again from the same options, or the same preset, with only the TLS profile replaced. A CA file named by `ca_cert` is read again when the copy is made.
76
+ - **A `Part` Can Be Added To A Form** — `Part` could be built and given a filename and a content type, but nothing accepted it, so it never reached a request. Added `Multipart.part(part)`. A `Part` says what `text()` and `file()` cannot, such as binary data without a filename, and the same `Part` can be added to any number of forms.
77
+ - **A `Multipart` Can Be Sent More Than Once** — a request emptied the `Multipart` it was given, so the same form sent again went out with no fields at all, only its closing boundary, and nothing was raised. A form now keeps its fields, and each request encodes them afresh with a boundary of its own, as a browser gives each submission of a form; a field added after a request goes out with the next one.
78
+ - **`head()`, `options()` And `send_streaming()` Take Every Request Option** — `head()` and `options()` did not accept `no_decompress`, `accept_encoding`, `priority`, `h2_priority_weight`, `preferred_http_version` or `idempotency`, and `send_streaming()` lacked all of them but `accept_encoding`, although the README listed them for every method and the Rust library honours them on these requests too. Passing one raised `TypeError`; the way around it was `request("HEAD", ...)` for the first two and there was none for a streamed request. `head()` and `options()` now take what `get()` takes, and `send_streaming()` what `request()` takes, on both session classes.
79
+ - **Default Headers And Request Cookies Keep Their Order** — `Client(default_headers=...)` and the `cookies=` / `cookie_override=` of every request method were copied into unordered maps, so each client sent its default headers, and each request its cookies, in an arbitrary order of its own: clients built from the same defaults did not agree with one another, and the same request did not produce the same `Cookie` header twice — orders no browser produces, over HTTP/1.1 and HTTP/2 alike. The Rust library keeps both in the order they are given, and they are now handed on that way. Default headers go out in the order given wherever `header_order` does not place them. Request cookies follow the session's own cookies in the order given, overrides last, unless `cookie_order` places them. All three parameters also accept a list of `(name, value)` pairs, as `headers=` does. A default header given twice is sent once, with the last value, where it first appeared; a cookie given twice in `cookies=` is sent twice, as the Rust library sends it.
80
+ - **`on_request` Hooks See The Headers In Order** — the `headers` dict an `on_request` hook receives now lists the request's headers in the order they were given, instead of in an arbitrary one.
81
+ - **A Duration Too Large To Hold Raises `ValueError`** — A number of seconds of 2\*\*64 or more, such as `total_timeout=1e30`, a request's `timeout=` or a `RacePolicy` delay, crashed with a `PanicException`, which derives from `BaseException`, so `except Exception` did not catch it. Every option that takes seconds now raises `ValueError` for it, as for a negative, NaN or infinite value. A retry callable that returns such a delay no longer crashes the request either: as with a negative delay, the request is not retried and fails with its own error.
82
+ - **A `TcpFingerprint` Shows What It Sets** — its repr was `TcpFingerprint()` whenever the four values JA4T is made of were not all set, as for a fingerprint that sets nothing, so `TcpFingerprint(ttl=128, keepalive=True)` looked like `TcpFingerprint.none()`; and when they were, it showed the JA4T alone, so `TcpFingerprint.chrome_win()` and the same JA4T without a TTL looked alike. It now names every setting that is set, the JA4T ones as one `ja4t=` when all four are: `TcpFingerprint(ja4t='64240_2-1-3-1-1-4_1460_8', ttl=128, tcp_nodelay=True)`.
83
+
84
+ ## lkrequest-py 0.2.3
85
+
86
+ Adds the Chrome 155 fingerprint and picks up upstream's switch of the default
87
+ fingerprint to it, plus upstream hardening of length fields read off the wire.
88
+ Sessions can now be closed, and a session whose event hooks refer back to its
89
+ owner is reclaimed instead of living for good. HTTP/2 requests abandoned after
90
+ they were sent are now reset instead of holding their stream open, a WebSocket
91
+ can send and close while another task is receiving, and upstream's pass over TLS,
92
+ ECH and QUIC handshake behaviour is picked up, along with fixes to what a
93
+ redirect carries along and to how a few responses are read. Several things are
94
+ worth reading before upgrading, because they change the wire or what a call
95
+ returns: a client built without a TLS or HTTP/2 profile now presents Chrome 155
96
+ instead of Chrome 144; Firefox 156 and the Chrome presets differ in their HTTP/2
97
+ stream numbering and dependencies; a redirect to another origin no longer carries
98
+ the client's default `Authorization`, `Cookie` and `Host` headers along; a
99
+ `Set-Cookie` with an empty `Max-Age` gives a session cookie instead of one that
100
+ lasts until the year 9999; a `deflate` body that ends early is now an error; and
101
+ `ProxyPool.acquire()` now waits, rather than answering "no proxy", while every
102
+ proxy is cooling down.
103
+
104
+ ### 🚀 Features
105
+ - **Closing A Session** — Added `Session.close()` and `Session.closed` on both session classes, and context-manager support: `with` on `BlockingSession`, `async with` on `Session`. Closing aborts the pooled connections, abandons the requests still in flight — they raise `RuntimeError("Session was closed while the request was in flight")` instead of carrying on — drops the event hooks and the callbacks of the session's own middleware, and makes the session refuse further requests and new hooks with `RuntimeError("Session is closed")`. Closing twice is harmless and cookies stay readable. Until now the only way to let go of a session's connections was `pool_clear()`, which empties the pool but leaves requests in flight running and the session usable; it is unchanged. `Client` has no counterpart, since it owns no connections — those belong to its sessions. The README gained a "Session lifecycle and memory" section: reuse a session rather than creating one per request, the default pool has no upper limit and drops an idle connection after 300 seconds (only when the session is next used), and the resident size of a process does not necessarily shrink when memory is freed.
106
+ - **Chrome 155 Fingerprint** — Added `Client.chrome_155()` / `BlockingClient.chrome_155()`, the `"chrome_155"` name for `tls_profile=` / `h2_profile=` / `quic_profile=`, and `TlsProfile.chrome_155()`, `TlsProfile.chrome_155_quic()`, `H2Profile.chrome_155()`, `QuicProfile.chrome_155()`. Captured from Chrome 155.0.8059.40 on Windows: the TCP and QUIC ClientHellos, H2, the QUIC transport parameters and the Initial-packet generation rules all match Chrome 154. What differs is how the QUIC profile behaves once connected: `QuicProfile.chrome_155()` switches on 16 booleans that Chrome 154's lacks — Chrome 155's HTTP/3 request handling (among them a PRIORITY_UPDATE sent for each request stream before its HEADERS) and its loss-recovery and packetization timing — and nothing else in it moves. Unlike the Chrome 154 capture, this one completed an HTTP/3 navigation, so the H3 SETTINGS were observed directly rather than carried over — and matched the inherited ones.
107
+ - **The Default Fingerprint Is Now Chrome 155** — inherited from upstream, and visible on the wire. `Client()` / `BlockingClient()` without `tls_profile=` used Chrome 144's TLS fingerprint and now uses Chrome 155's; without `h2_profile=`, the same for HTTP/2. Nothing else about the default changes: no TCP fingerprint, no QUIC profile, no default headers. The preset methods and named profiles are unaffected. Pass `tls_profile="chrome_144", h2_profile="chrome_144"` to keep the previous default.
108
+ - **Per-Request HTTP/2 Priority Weight** — Added `h2_priority_weight=` (0–255) to every request method on both session classes. It sets the weight in the legacy priority field of the request's HTTP/2 HEADERS frame — on the request that opens a connection as on any other — independently of the `priority` header, the stream dependency and the exclusive flag; the next request goes back to the weight its profile sends. A profile whose HEADERS carry no legacy priority field (Safari 26) still sends none, and HTTP/1.1 and HTTP/3 are not affected. It is what reproduces Firefox 156's favicon request: `priority=RequestPriority(6, False)` together with `h2_priority_weight=21`. A value outside 0–255 is rejected before anything is sent.
109
+ - **HTTP/2 Stream Numbering And Dependencies** — Added `H2Profile(initial_stream_id=...)` and a read-only `initial_stream_id`: the ID of the first request stream a connection opens, an odd number up to 2³¹ − 1 (`ValueError` otherwise; a profile saved without it keeps starting at 1). Added `"priority_chain"` as a `PriorityConfig(stream_dep_policy=...)` value. Two presets change on the wire as a result. Firefox 156 now opens its first request on stream 3, as its capture shows, and sends navigation HEADERS with weight 41 where it sent 42. The Chrome presets now point each stream's dependency at the newest still-active stream of equal or higher priority, dropping finished and reset streams, instead of at the stream opened just before it: `PriorityConfig.chrome().stream_dep_policy` is `"priority_chain"` where it was `"chain"`. `"chain"` itself behaves as it did.
110
+ - **HTTP/3 Protocol-Error Retries** — Added `H3RetryMode` and `session(h3_retry_mode=...)` on both clients. `H3RetryMode.Safe`, the default, keeps the behaviour of earlier releases. `H3RetryMode.Chrome155ProtocolErrors` retries HTTP/3 stream and connection protocol errors and stateless resets up to twice per redirect hop, as long as no response headers have arrived, which is what Chrome 155 does. That replays a finite buffered request, **including a POST the server may already have processed**. Streaming uploads are not retried, a request sent with `idempotency=Idempotency.NotIdempotent` opts out, and a request that is not HTTP/3-only needs a protocol policy that allows falling back to TCP. It adds no status-code retries and leaves 0-RTT eligibility alone. Without the `quic-h3` build feature only `Safe` is accepted.
111
+
112
+ ### 🐞 Fixes
113
+ - **Event Hooks No Longer Keep Their Session Alive** — a hook that referred back to the object holding the session — `session.on_response(self.log)` in a class that stores `self.session`, or a closure over the session — made a reference cycle that Python's collector could not see, because the hooks sat in a Rust `Vec` behind an `Arc`. The session, its connection pool and everything the owner held then stayed alive for the life of the process: on the order of 7 KB per session in a loop that created one per request, plus its open connection for as long as the server kept it open. The hooks are now kept in Python lists and the session classes report them to the collector, so such a cycle is reclaimed. The callbacks of **middleware** are still held where the collector cannot see them: those of a session's own middleware are released by `Session.close()`, and a callback of `Client(middleware=...)` that refers back to its own client keeps that client alive, so create such a client once rather than per request.
114
+ - **Overflow-Checked Length Fields** — inherited from upstream. Lengths declared in HPACK, QUIC transport parameters and MASQUE capsules were added to buffer offsets without overflow checks, and on a 32-bit target a declared length of 4 GiB or more was truncated outright. They are now compared against the bytes actually present. The one place this surfaces in Python is `H2Profile.from_h2_frames()`: a header block whose declared lengths run past the frame now ends the pseudo-header scan at the last field that fits, instead of relying on unchecked arithmetic.
115
+ - **Abandoned HTTP/2 Requests Are Reset** — inherited from upstream. A request the caller gave up on after it was sent — a timeout, a cancelled task, a dropped streaming response — was never reset with `RST_STREAM`, so its stream stayed open on the server for as long as the connection did. On a shared session that used up the server's concurrent-stream limit (typically 100): later requests then queued behind it without ever being sent, every one of them timed out, and the pool still counted the connection as healthy. A dropped session also kept its connections open for another 30 seconds, waiting out the abandoned streams. The client now sends `RST_STREAM(CANCEL)` for such a request and frees its slot, and a dropped session closes its connections at once. HTTP/1.1 was never affected.
116
+ - **WebSocket Send And Close No Longer Wait For `recv()`** — a connection held one lock across every operation, so while one task was waiting in `recv()`, `send_text()`, `send_binary()` and `close()` from any other task or thread waited too. Against a server that only speaks when spoken to that is a deadlock: the message `recv()` is waiting for is the one `send_text()` cannot send. Receiving, sending and closing now proceed independently, and closing hands a `recv()` that is waiting the closing handshake. As before, once a connection is closed every operation raises `RuntimeError("WebSocket is closed")` and iterating it ends.
117
+ - **WebSocket Upgrade Request Line And Host** — inherited from upstream. The HTTP/1.1 upgrade request went out in absolute-form (`GET https://host/path`), which only a proxy is meant to receive, and its `Host` header dropped the port. A server that routes on the path never matched it, and a `wss://` endpoint on any port but 443 was addressed as if it were on 443. The request line is now the path and query, and `Host` carries the port whenever it is not the scheme's default.
118
+ - **`ProxyPool.acquire()` Does Not Answer With "No Proxy" While Proxies Cool Down** — inherited from upstream. When every proxy of a pool was cooling down, a pool could answer with no proxy at all, which a caller takes for a direct connection and so exposes its own address. A pool that has proxies now waits for one to recover; bound the wait with `asyncio.wait_for` if you need to. Only a pool created with an empty list returns `""`.
119
+ - **First Request On A New HTTP/2 Connection Gets Its Priority** — inherited from upstream. The request that opens a connection now resolves its priority from the same request and client headers, and the same fetch-destination rules, as a request sent on a reused connection, so a navigation keeps its navigation priority on a fresh connection too.
120
+ - **TLS, ECH And QUIC Handshake Corrections** — inherited from upstream: a large pass over how the handshake behaves, much of it visible on the wire. The client now refuses, with the alert Chromium or NSS sends, a server hello a browser would refuse (a version, cipher suite, compression method or ALPN protocol it never offered; a malformed or padded extension; a wrong session-ID echo; falling back to TLS 1.2 after a HelloRetryRequest), and does so before deriving any key. A TLS 1.3 handshake queues its compatibility ChangeCipherSpec until the client's final flight, as Chromium and NSS do, instead of sending it straight after the server hello. After a HelloRetryRequest the client drops pre-shared keys whose hash no longer matches, keeps the first ClientHello's trust-anchor order, and carries the cookie into the inner ClientHello of a real ECH handshake. ECH rejection and acceptance are handled across HelloRetryRequest and TLS 1.2 (an encrypted `ech_required` after Finished, and no usable connection or ticket), and over QUIC as `CRYPTO_ERROR(ech_required)`. ECH GREASE is generated the way each browser does it: an X25519 key pair for the `enc` field on Chromium, and Firefox's per-handshake choice between AES-128-GCM and ChaCha20-Poly1305.
121
+ - **More Requests Count As Safe To Replay** — inherited from upstream. A request that an HTTP/2 GOAWAY cut off before any response headers arrived, or that HTTP/3 rejected (`H3_REQUEST_REJECTED`) before them, was never processed by the server and is now treated as safe to replay automatically.
122
+ - **`dns_timeout` Covers Every Lookup** — inherited from upstream. `Client(dns_timeout=...)` is now a per-query deadline for each DNS query the client makes: address lookups and HTTPS-record lookups, and custom resolvers as well.
123
+ - **A Redirect No Longer Resends The Client's Default Credentials To Another Origin** — inherited from upstream. The headers of `Client(default_headers=...)` were applied again on every hop of a redirect chain, so a default `Authorization`, `Cookie` or `Host` went along to the destination of a redirect to another origin, although the same headers set on the request itself were dropped there. Defaults are now filtered hop by hop the way a request's own headers are. A redirect that leaves the origin (scheme, host or port) no longer carries a default `Authorization`, `Cookie` or `Host`; one from https to http drops a default `Referer`; one that turns a POST into a GET drops the default `Content-Type`, `Content-Encoding`, `Content-Language`, `Content-Location` and `Content-Length`. A redirect within the origin keeps them, going back to the first origin does not bring back what an earlier hop dropped, and the client's defaults are untouched for the next request.
124
+ - **A `Location` Written In Raw UTF-8 Is Followed** — inherited from upstream. A redirect whose `Location` held raw UTF-8 bytes (`/café`) was reported as a response without a `Location` and failed with "redirect response missing Location header". The value is now decoded as UTF-8, resolved against the current URL and percent-encoded on the way out, exactly as a percent-encoded `Location` is. One that is not valid UTF-8 raises `RequestError` saying so.
125
+ - **An Empty `Max-Age` In `Set-Cookie` Is Ignored** — inherited from upstream. `Set-Cookie: sid=abc; Max-Age=` was read as a `Max-Age` too large to represent, so the cookie was stored with a deadline in the year 9999 instead of as a session cookie. Because `Max-Age` outranks `Expires`, a response that tried to delete the cookie with `Max-Age=; Expires=<a date in the past>` left it in place. `Session.set_cookie_raw()` behaved the same way. An empty `Max-Age` now counts as absent; the other attributes, and the last valid `Max-Age` when several are given, are read as before.
126
+ - **`deflate` Bodies: Truncation And A Raw Stream That Looks Like zlib** — inherited from upstream. A `deflate` body that ended before its stream did came back as a shorter body without any error, for buffered and streaming responses alike; it is now a `RequestError`. RFC 9110 defines `deflate` as zlib, but some servers send a raw deflate stream, which the client used to tell apart by the first two bytes. A raw stream that begins with a stored block can start with `0x78 0x9c`, which reads as a valid zlib header, and was rejected as corrupt. Buffered and streaming responses now share one detector: zlib is tried first, and a raw stream is accepted instead only when zlib fails before producing any output and the bytes are the start of a valid stored block. A zlib stream with a bad checksum is still an error.
127
+ - **A Proxy's Refusal After The Authentication Retry Keeps Its Status** — inherited from upstream. When a proxy answered a `CONNECT` with 407 and refused the retry that carried credentials, the `ProxyError` always said the authentication had failed, even for a 503 or a 402. A second 407 is still reported as an authentication failure; any other status is now reported as the tunnel being refused, with that status in the message.
128
+
129
+ ## lkrequest-py 0.2.2
130
+
131
+ Adds streaming uploads, the Chrome 153, Chrome 154 and Firefox 156 fingerprints,
132
+ experimental MASQUE (CONNECT-UDP) proxies and address-family control with dial
133
+ target overrides, makes `text()` usable on the non-UTF-8 web, and picks up three
134
+ upstream fixes: a connection-pool bug that made every request re-dial whenever
135
+ HTTP/3 fell back to HTTP/2, a QPACK bug that made HTTP/3 connections built from
136
+ the Chrome presets unusable after their first request against servers such as
137
+ LiteSpeed, and HTTP/3 never reaching an IPv6 address. Six behaviour changes are
138
+ worth reading before upgrading: what `text()` does with bytes it cannot decode,
139
+ the exception `json()` raises, the order of the `Cookie` header, the QUIC GREASE
140
+ fields the Chromium presets now randomize per connection, the default port for a
141
+ portless HTTP proxy URL, and the encoding of `ProxyConfig.identity()`.
142
+
143
+ ### 🚀 Features
144
+ - **Streaming Uploads** — Added `body_stream=` and `content_length=` to `post`, `put`, `patch`, `delete`, `request` and `send_streaming` on both clients, uploading without holding the body in memory. `body_stream` accepts a file-like object (`read(n)`), any iterable of `bytes`, or — on the async client — an async iterable; the blocking client rejects an async iterable up front, since it has no event loop to drive it. Omit `content_length` for an unknown-length body: HTTP/1.1 then uses chunked framing, HTTP/2 and HTTP/3 send DATA frames with no `Content-Length`. A supplied length is exact, so a source that ends short or runs long fails the request. Chunk boundaries are passed through untouched rather than re-chunked, because they are visible on the wire as DATA frame sizes. The source is single-use: neither an automatic retry, a protocol fallback, nor a redirect that must preserve the body can replay it.
145
+ - **HTTP/2 Dispatch Batching** — Added `Client(h2_dispatch_batch_size=...)` and a matching read-only property, capping how many ready HTTP/2 requests are coalesced into one driver write turn. The default of 1 keeps each request in its own write, which is the shape the browser presets were captured with; batching is faster but observable, so it is opt-in. `0` is rejected with `ValueError`.
146
+ - **Readable Cookie Attributes** — Added `session.get_cookies_with_attrs(url)` and `session.get_all_cookies()` on both clients, returning `Cookie` objects carrying `name`, `value`, `domain`, `host_only`, `path`, `secure`, `http_only`, `same_site`, `expires` and `is_persistent`. `get_cookies()` returns `(name, value)` pairs, which cannot tell apart two cookies sharing a name at different paths — the jar holds both, and only the attributes say which is which. These are what a caller needs to hand a jar to a browser automation context, to persist a login and know which cookies are session-only, and to answer why a cookie the jar holds was not sent. `get_cookies_with_attrs` is scoped to a URL like `get_cookies`; `get_all_cookies` spans every domain, which is the view to persist. `Cookie` is hashable, so two jars can be diffed with set operations. `get_all_cookies` lists cookies oldest first, which is the order to write them back in: combined with the `Cookie` ordering fix below, a jar saved and restored reproduces the same headers on the wire.
147
+ - **Address Family Selection** — Added `Client(ip_family="any" | "ipv4" | "ipv6")` on both clients, backed by upstream's address-family policy. On a dual-stack host the OS resolver orders IPv6 ahead of IPv4 (RFC 6724), so a target publishing an AAAA record is reached over IPv6 whenever IPv6 happens to work; `"ipv4"` or `"ipv6"` keeps every address this process dials — the target, the proxy's own address, a SOCKS5 UDP relay, QUIC — within one family, which is what keeps a session from leaving over two families and presenting two source addresses to the target. `"ipv6"` also refuses IPv4-mapped addresses (`::ffff:a.b.c.d`). The default `"any"` is the previous behaviour. An address outside the family raises an error that names the `ip_family` setting (`LkConnectionError`, or `ProxyError` for the proxy's own address) instead of falling back — not even to a direct connection under `proxy_fallback_direct=True` — and an unknown value raises `ValueError`. Composes with `dns=` and with `system_dns_cache_ttl=`. A proxy that resolves the target itself — HTTP `CONNECT`, `socks5h` or MASQUE handed a hostname — still picks the family it dials; `connect_to` hands it an address instead.
148
+ - **Dial Target Overrides** — Added `Client(connect_to={"host:port": "ip:port"})` on both clients: requests to that origin dial the given address, while the URL, TLS SNI and certificate name, `Host`, cookies and connection pooling keep the original host. It applies to direct, proxied and HTTP/3 connections, ahead of Alt-Svc / SVCB endpoints, and never to the proxy's own address; through an HTTP `CONNECT` proxy the `CONNECT` line carries the address, which is how to decide the family a proxy dials. An IPv6 host or address goes in brackets (`"[::1]:8443"`, `"[2001:db8::1]:443"`). The mapping is fixed — nothing re-resolves or refreshes it. A malformed entry, a zero port, one origin listed twice (`Example.com` and `example.com` count as the same), or an address `ip_family` excludes raises `ValueError` when the client is built.
149
+ - **Chrome 153 Fingerprint** — Added `Client.chrome_153()` / `BlockingClient.chrome_153()`, the `"chrome_153"` name for `tls_profile=` / `h2_profile=` / `quic_profile=`, and `TlsProfile.chrome_153()`, `TlsProfile.chrome_153_quic()`, `H2Profile.chrome_153()`, `QuicProfile.chrome_153()`. Captured from Chrome 153.0.8010.37 on Windows. Against Chrome 152 the TLS shape is the same except for the shuffled trust-anchor list (extension `0xca34`), which narrows from 32 identifiers to 28; H2 is unchanged; the QUIC ClientHello keeps the nine classic signature algorithms and sends no TLS GREASE slots. Nothing switches over on its own — an existing client keeps the preset it names.
150
+ - **Chrome 154 And Firefox 156 Fingerprints** — Added `Client.chrome_154()` / `BlockingClient.chrome_154()`, the `"chrome_154"` name for `tls_profile=` / `h2_profile=` / `quic_profile=`, and `TlsProfile.chrome_154()`, `TlsProfile.chrome_154_quic()`, `H2Profile.chrome_154()`, `QuicProfile.chrome_154()`. Captured from Chrome 154.0.8037.58 on Windows: the TCP and QUIC ClientHellos, H2, the QUIC transport parameters and the Initial-packet generation rules all match Chrome 153, so only the profile name differs. The capture never received a QUIC response, so the H3 SETTINGS and request priority carry over from Chrome 153's completed navigation instead of being observed anew. Added `Client.firefox_156()` / `BlockingClient.firefox_156()`, the `"firefox_156"` name for `tls_profile=` / `h2_profile=`, and `TlsProfile.firefox_156()`, `H2Profile.firefox_156()`. Captured from Firefox 156.0.1 on Windows: against Firefox 147 the TLS profile drops two ECDSA CBC suites (`0xc009`, `0xc00a`) and both FFDHE groups (256, 257), and its ECH GREASE switches to AES-128-GCM with a fixed 240-byte payload; H2 is unchanged. Like the other Firefox presets it carries no QUIC profile and turns HTTP/3 off — upstream captured Firefox 156's QUIC Initial but cannot yet reproduce every field of it — so there is no `"firefox_156"` QUIC name. As before, an existing client keeps the preset it names.
151
+ - **Chromium GREASE Generation For The QUIC Presets** — inherited from upstream, and it changes the wire for existing presets, not just Chrome 153. Three fields are now generated per connection the way Chromium generates them, instead of being fixed by the profile: the reserved HTTP/3 SETTINGS id and value plus the control-stream GREASE frame (every Chrome `QuicProfile`, the generic `chrome` included); the reserved QUIC transport parameter ids, now spanning the full 62-bit range with 0–15 byte payloads and a randomized GREASE version position; and the Initial packet layout, with CRYPTO data split and shuffled and PING/PADDING interleaved (both on Chrome 146 and 150–154). Two connections from one client therefore no longer look byte-identical in those fields — which is what real Chrome does, and the reason a fixed value was a fingerprint of its own. A `QuicProfile` loaded from JSON is unaffected: the three flags default to off, so a serialized or hand-built profile keeps its previous behaviour.
152
+ - **MASQUE Proxies (Experimental)** — **experimental upstream and here**: `MasqueConfig`, the `masque=` / `tunnel_probe=` parameters and the behaviour of `masque://` routes may change in any release without a deprecation period, and interoperability has been checked only against test proxies, not against independent production MASQUE deployments. Behind the new `masque` build feature, which implies `quic-h3` and is compiled into the published wheels; it does nothing unless a `masque://` proxy is used. A `masque://` proxy (port 443 by default) carries the QUIC connection to the target inside an RFC 9298 CONNECT-UDP tunnel over an HTTP/3 connection to the proxy; the target is named in the tunnel request, so DNS resolves proxy-side as with `socks5h`. Added `MasqueConfig` for the hop to the proxy — `server_name`, `ca_cert` (PEM file) / `ca_cert_pem`, `use_native_certs`, `verify`, and the two tunnel-lifecycle knobs `tunnel_idle_timeout` (default 120 s, `None` never retires an idle tunnel) and `max_idle_tunnels` (default 256, `0` removes the cap) — passed as `Client(masque=...)` on both clients. Its trust settings are deliberately independent of the client's `verify` / `ca_cert*`, which govern the origin: relaxing one hop for debugging never relaxes the other. A PEM bundle that is malformed or holds no certificate raises `ValueError` instead of leaving the trust store unchanged. `HealthCheckConfig` gained `tunnel_probe` and `masque`: a TCP connect proves nothing about a MASQUE proxy, so by default the health check skips those proxies — neither credited nor blacklisted — and with `tunnel_probe=True` it opens a real CONNECT-UDP tunnel to the check's target instead. Upstream skips the probe with only a log line when no outer configuration is given; here both arrive in one call, so `tunnel_probe=True` without `masque=` raises `ValueError` rather than silently never probing. MASQUE carries UDP only: HTTP/1.1 and HTTP/2 requests through it raise `ProxyError`, a failed tunnel never falls back to a direct connection even with `proxy_fallback_direct=True`, and a MASQUE proxy cannot be a chain hop. The connection to the proxy is not a browser fingerprint (the tunneled one to the origin is), and DNS-discovered ECH configs and H3 hints are unavailable on this route.
153
+ - **Proxy Credentials Beyond Username/Password** — Added `ProxyConfig.with_http_auth(scheme, credentials)` for a static HTTP credential sent as-is (`Bearer`, `Preshared`, …), `ProxyConfig.with_user_pass(username, password)`, and `ProxyConfig.with_auth_header("proxy-authorization" | "authorization")` to choose the header it travels in: RFC 9298 leaves proxy authentication unspecified, and MASQUE deployments disagree on the field. Each returns a new config and replaces any credential already set. An HTTP credential on a SOCKS5 proxy, which has nowhere to carry it, raises `ValueError`. Works for HTTP `CONNECT` proxies as well as MASQUE.
154
+
155
+ ### 🐞 Fixes
156
+ - **The `Cookie` Header Follows Chrome's Order** — inherited from upstream, and visible on the wire. The jar iterated a hash map, so the `Cookie` header order was arbitrary and differed between two sessions holding the same cookies; `cookie_order` pinned only the names it listed, and the relative order of same-name cookies at different paths stayed random. Cookies are now sorted as Chrome's `CookieMonster` sorts them: longer paths first, then older cookies first. Re-setting a cookie to the same value keeps its age, a new value makes it a new cookie, and removed or expired cookies do not disturb the order. This also makes the documented order of `get_cookie()`, `get_cookies()` and `get_cookie_values()` (longest path first) actually true — it was not. Upstream verified the result against Chrome 153.0.8010.47 on the wire: the same `Set-Cookie` sequence produced an identical `Cookie` header from both.
157
+ - **Enums Are Hashable Again** — `HttpVersion`, `HttpIntent`, `PreferredHttpVersion`, `Idempotency`, `BrokenQuicPolicy` and `WsMessage` raised `TypeError: unhashable type` when used as a dict key or put in a set, which is the obvious way to map a member onto something. They declared equality without a matching hash, and CPython answers that by setting `__hash__ = None`. Equality is unchanged, including the integer comparisons (`HttpVersion.H2 == 2`). Still unhashable, pending a `Hash` derive upstream: `Layers`, `NegotiabilityFloor`, `H2DataFramePolicy`, `TlsSessionResumptionPolicy`, `TlsSessionCachePartitionPolicy` and `NetworkPartitionContext`.
158
+ - **`set_cookie_raw` On The Blocking Session** — the blocking `Session` was missing it, although the async one had it and upstream's blocking session supported it all along. Since `set_cookie_with_attrs` accepts neither `Expires` nor `SameSite`, blocking callers had no way to set those two attributes at all.
159
+ - **`text()` Decodes The Declared Charset** — `Response.text()` decoded UTF-8 unconditionally, so a `charset=gbk` body — or Big5, Shift_JIS, EUC-KR, any of the legacy web — was unreachable through it even though `encoding` reported the charset correctly. The failure was also misleading: it surfaced as `TypeError: function takes exactly 5 arguments (1 given)`, because the error path constructed a `UnicodeDecodeError` from a single string and that exception requires five. `text()` now decodes with the charset from `Content-Type`, resolved against the WHATWG Encoding Standard as a browser resolves it, falling back to UTF-8 when none is declared or the label is unusable. `text(encoding=...)` overrides it for a charset the server omitted or got wrong; that argument is looked up in Python's own codec registry, so Python spellings the web standard does not list (`latin-1`, `utf-8-sig`, `cp936`) work as written, and an unknown codec raises `LookupError` just as `bytes.decode` would. Bytes that cannot be decoded become U+FFFD instead of raising, matching `requests` and `httpx` — reach for `content` when the exact bytes matter. A BOM is stripped and, where it contradicts the declared charset, believed over it. That is the browser rule and it goes further than `requests` and `httpx`, which strip nothing and always let the header win: stripping keeps an invisible U+FEFF from silently defeating `json.loads` and `startswith` (`requests.json()` fails outright on a BOM-prefixed UTF-8 body), and believing the BOM is almost always right, since a UTF-8 BOM read as the declared charset is garbage no real document opens with. The visible consequence is that a `charset=gbk` response whose body starts with a UTF-8 BOM decodes as UTF-8. The streaming responses' `text()` (`StreamingResponse` and `BlockingStreamingResponse`) now decodes the same way and takes the same `encoding=` argument; it used to decode strict UTF-8 whatever the header said, and raised `RequestError` on any other body.
160
+ - **Exceptions Can Cross A Process Boundary** — every exception class failed to pickle, so a worker in a `ProcessPoolExecutor`, `multiprocessing` pool or task queue could not report one to its parent: the real error was replaced by `PicklingError: Can't pickle <class '_lkrequest.TimeoutError'>: import of module '_lkrequest' failed`, losing the message and no longer matching `except lkrequest.RequestError`. `pickle` stores a class by name — it imports `__module__` and looks up `__qualname__` there — and the classes named `_lkrequest`, which is not importable on its own, since the extension lives inside the `lkrequest` package. They now report `__module__ = "lkrequest"`, which also makes `repr()` show the path callers actually import from. One class had a second, independent problem: `ProxyError` reported itself as `LkProxyError`, a name the package does not export, so the lookup failed even once the module resolved. Names are now pinned to the exported ones by a test.
161
+ - **`json()` Failures Are Now `RequestError`s** — a body that is not JSON raised the stdlib `json.JSONDecodeError`, which is not a `RequestError`, so `except lkrequest.RequestError` missed it and retry logic needed a separate case for it. It now raises `lkrequest.JsonDecodeError`, which subclasses **both** `RequestError` and `json.JSONDecodeError`, so either `except` catches it and existing code written against the stdlib exception keeps working. It carries the stdlib exception's `msg`, `doc`, `pos`, `lineno` and `colno`.
162
+ - **HTTP/3 Fallback No Longer Loses The Connection Pool** — with HTTP/3 enabled (the default), an origin whose QUIC was unreachable re-established TCP and TLS for *every* request: the fallback pooled its HTTP/2 connection, but the pool lookup for `Http3WithFallback` only ever searched for an HTTP/3 connection and never saw it. There was no error, only latency — roughly a 3× slowdown against affected origins, which include any host advertising Alt-Svc `h3` from a network where UDP is blocked.
163
+ - **HTTP/3 Connections Survive The QPACK Dynamic Table** — inherited from upstream. Every Chrome `QuicProfile`, the generic `chrome` included, advertises a 64 KiB QPACK dynamic table as Chrome does, but the client could decode only static-table references. Against a server whose encoder takes up that offer — LiteSpeed does, and so does anything built on ls-qpack — the first request on an HTTP/3 connection succeeded and every later one failed with `QPACK_DECOMPRESSION_FAILED`, taking the connection down each time, so HTTP/3 connections were never reused. The client now builds the dynamic table from the server's encoder stream, holds a response until the entries it references have arrived, and acknowledges on the QPACK decoder stream as Chrome does. The SETTINGS on the wire are unchanged.
164
+ - **HTTP/3 Reaches IPv6 Addresses** — inherited from upstream. The QUIC endpoint was bound to the IPv4 wildcard only, so every HTTP/3 connection to an IPv6 address failed before a packet was sent: with `http3_only=True` the request raised, and under the default policy it fell back to HTTP/2 and marked the origin's QUIC broken. On a network with IPv6, a dual-stack site whose AAAA record sorted first therefore never got HTTP/3. The client now keeps a separate IPv6 endpoint.
165
+ - **Portless HTTP Proxy URLs Default To Port 80** — `proxy="http://proxy.example.com"` previously defaulted to **8080**, which is a common proxy port but not the scheme's default. It now resolves to 80, as the URL says. Spell the port out (`http://proxy.example.com:8080`) if 8080 was what you relied on. `https://` proxies are unaffected (443 either way).
166
+ - **`ProxyConfig.identity()` Encoding Changed** — the pooling and failure-tracking key is now a JSON array of ordered hops, each carrying protocol, host, port and a credential fragment, replacing the `user@host:port` / `a>b>c` form. The old encoding could not tell `socks5` from `socks5h`, so two chains that resolve hostnames at different hops shared one cooldown entry. The fragment is `null` without credentials, `"user:<name>"` for a username/password (never the password), and an opaque fingerprint for an HTTP credential, so two tokens on one gateway do not share a key. Treat the value as an opaque key; use `str(config)` for something human-readable.
167
+ - **Direct TCP Connections Disable Nagle By Default** — interactive HTTP benefits from `TCP_NODELAY`, so it is now on unless a fingerprint sets it off explicitly (`TcpFingerprint(tcp_nodelay=False)`).
168
+ - **Streaming Responses Honour Request Middleware And HSTS** — `send_streaming` skipped both. Request middleware now runs in the same order as on the buffered path, followed by the session's HSTS URL upgrade, and the response updates HSTS state. Full-body response middleware still does not run, since there is no full body to hand it.
169
+ - **Replay Safety Across A Protocol Switch** — a request that changed protocol mid-flight could be retried even though the earlier attempt may already have reached the server. Retry eligibility is now tracked through the switch.
170
+ - **Faster HTTP/2 Request Processing** — upstream reworked the HTTP/2 driver's write path and HPACK encoder. No API change, and the default dispatch batch size keeps the wire shape identical.
171
+
172
+ ## lkrequest-py 0.2.1
173
+
174
+ Adds the Chrome 152 preset and browser network session policies, and picks up a
175
+ large upstream RFC-compliance pass. Several fixes below change observable
176
+ behaviour — the ECH, retry, redirect, cookie and connection-limit entries are
177
+ the ones most likely to affect existing code.
178
+
179
+ ### 🚀 Features
180
+ - **Chrome 152 Preset** — Added `Client.chrome_152()`, `BlockingClient.chrome_152()`, `TlsProfile.chrome_152()`, `TlsProfile.chrome_152_quic()`, `H2Profile.chrome_152()`, `QuicProfile.chrome_152()`, and the `"chrome_152"` TLS/H2/QUIC profile names. Chrome 152 is currently the only preset that applies GREASE to `signature_algorithms`, and it carries the new trust anchor IDs extension. HTTP/2 is unchanged from Chrome 151; the QUIC capture drops the `google_initial_rtt` transport parameter (`0x3127`).
181
+ - **Browser Network Session Policies** — Added `TlsSessionResumptionPolicy` (`BROWSER_DEFAULT` / `DISABLED` / `enabled(max_tickets_per_server)`), `TlsSessionCachePartitionPolicy` (`UNPARTITIONED` / `TOP_LEVEL_SITE` / `TOP_LEVEL_AND_FRAME_SITE` / `CHROMIUM` / `FIREFOX`), and `NetworkPartitionContext(top_level_site, frame_site)`. Set the first two via `Client(tls_session_resumption_policy=..., tls_session_cache_partition_policy=...)` and pass the third to `session(network_partition_context=...)` to reproduce a browser's per-site ticket isolation. These control whether tickets are *stored* and how the cache is keyed; `SessionResumptionConfig` continues to control which resumption modes the ClientHello *advertises*.
182
+ - **HTTP/2 DATA Frame Policy** — Added `Client(h2_data_frame_policy=...)` with `H2DataFramePolicy.BROWSER_DEFAULT`, `.PEER_MAX_FRAME_SIZE`, `.fixed_payload(n)`, and `.socket_write_aligned(n)`, choosing how request bodies are split into DATA frames. The default keeps the browser preset's native framing, since frame sizes are themselves an observable fingerprint trait.
183
+ - **Strict TLS close_notify** — Added `Client(require_close_notify=True)` and a matching read-only property. When enabled, a response body truncated by a bare socket EOF — with no TLS `close_notify` alert — is reported as an error instead of being accepted as a complete body. One part applies regardless of the flag: the read loop now stops at `close_notify` instead of reading on, because anything arriving after it did not come from the peer — those bytes were previously appended to the response body.
184
+ - **Trust Anchor IDs Extension** — Added `ExtType.TRUST_ANCHOR_IDS` and a new `ExtensionSpec` source: `ExtensionSpec(ExtType.TRUST_ANCHOR_IDS, source="trust_anchor_ids", trust_anchor_ids=[...], shuffle=False)`, with `trust_anchor_ids` and `shuffle` readable back off the spec.
185
+ - **DNS-over-HTTPS Feature** — Upstream moved the DoH resolvers behind a `doh` Cargo feature. The published wheels enable it, so `dns="google_https"`, `"cloudflare_https"` and `"quad9_https"` keep working exactly as before; a reduced-feature source build now rejects those three names with an actionable error instead of a generic "unknown DNS config".
186
+
187
+ ### 🐞 Fixes
188
+ - **A Rejected ECH No Longer Falls Through In The Clear** — when a server did not accept Encrypted ClientHello, the handshake previously carried on and handed back a connection whose real server name had never been protected, so the request ECH existed to hide went out in the open. The handshake now aborts before any traffic keys exist, and the session redials once with the `retry_configs` the server supplied — the same thing a browser does, so routine ECH key rotation stays transparent. Retrying *without* ECH is deliberately not a fallback. This affects clients that never set `ech_config=`, because ECH is enabled automatically from DNS HTTPS records; a rejection that cannot be retried now surfaces as `TlsError`. GREASE ECH is unaffected.
189
+ - **Status-Code Retries Are Gated On Replay Safety** — a non-idempotent request answered with a status in the retry set was replayed even though the answer proves the server received it, so a `POST` answered `502` was sent twice. Such a method is now retried only on a status that says it was *not* applied (`429`, `503`); `502`, `504` and the Cloudflare `520`–`530` range are returned to the caller instead. Safe methods are unaffected, and passing `idempotency=Idempotency.Idempotent` on the request restores the old behaviour per call.
190
+ - **Only Real Redirect Statuses Are Followed** — `300`, `304`, `305` and `306` are no longer treated as redirects. Previously a `304 Not Modified` or a `305 Use Proxy` could send the client chasing a `Location` header it should have ignored. `301`, `302`, `303`, `307` and `308` are unaffected.
191
+ - **Cookie Name Prefixes Enforced** — Cookies named with the `__Secure-` or `__Host-` prefix are now rejected unless they satisfy the prefix rules (`Secure`, and for `__Host-` also a `/` path with no `Domain`). A server can no longer set a lookalike cookie that client code would reasonably trust.
192
+ - **Secure Cookies Ignored Over Plain HTTP** — A `Set-Cookie` carrying `Secure` received over a plain-HTTP channel is now discarded rather than stored.
193
+ - **Sessions Are No Longer Capped At 64 Connections** — The implicit per-session ceiling made unrelated high-concurrency workloads fail with `session connection limit reached`. The default is now unbounded; set `max_connections_per_session` explicitly to impose a budget.
194
+ - **Multi-Layer Content-Encoding Is Decoded** — A response carrying stacked encodings (for example `Content-Encoding: gzip, br`) is now fully decoded instead of being returned partly compressed. The zlib-wrapped form of `deflate` is also accepted, matching what browsers tolerate.
195
+ - **WebSocket Handshakes And Close Codes Are Validated** — the upgrade response was checked only for a `101`, which on its own proves nothing; a wrong or missing `Sec-WebSocket-Accept` merely logged a warning. RFC 6455 §4.1 makes that, a missing or wrong `Upgrade`/`Connection`, and any unoffered extension or subprotocol grounds for failing the connection, and all are enforced now. Enforcing it also uncovered that `Sec-WebSocket-Accept` was being computed with a GUID that is not the one §1.3 specifies, so the token never matched a conforming server — harmless while the check only warned. On the sending side, `1005`, `1006` and `1015` may never appear on the wire (§7.4.1) and are now rejected locally, along with over-long close reasons, instead of producing an invalid frame.
196
+ - **HSTS Is Learned Only From Secure Transport** — `Strict-Transport-Security` is no longer recorded from a plain-HTTP response, and never for IP-literal hosts.
197
+ - **Multipart Bodies Match Chrome** — Boundary generation and parameter escaping now follow Chrome's format. A `Content-Type` containing CR or LF is rejected outright, since — unlike the quoted `Content-Disposition` parameters, which are escaped — it cannot be encoded safely and would be a live header-injection vector.
198
+ - **Redirect Method Rewriting Follows The Spec** — The request method is now rewritten only in the cases the specification calls for, so `307` and `308` correctly preserve the original method and body.
199
+ - **Graceful HTTP/2 GOAWAY Is Honoured** — a graceful shutdown carries `NO_ERROR` and therefore set no connection error, while the draining check keyed off "has an error". The driver kept opening streams on a connection the peer had already promised to refuse. Google and Cloudflare send these routinely for connection turnover, so this showed up as sporadic failures under sustained load; queued requests now fail over to a fresh connection.
200
+ - **Alternative Services No Longer Supply The TLS Identity** — an Alt-Svc entry or DNS HTTPS record says *where* to send packets, but the endpoint must still prove it may serve the origin. The advertised host was used as the SNI and certificate name, so the alternative authenticated as itself. Besides the trust problem this broke conforming deployments outright, since an HTTPS RR `TargetName` is typically a CDN-internal name serving the origin's certificate.
201
+ - **Certificates Compressed With zstd Decompress** — `compress_certificate` advertises zlib, brotli and zstd (the Firefox 147 profile offers all three), but only zlib and brotli could be decoded, so a server that picked zstd failed the handshake. All three are handled now (RFC 8879).
202
+ - **Coalesced TLS Post-Handshake Messages Are Reassembled** — post-handshake messages follow the same framing as the handshake: several may share one record, and one may span several. Each record was treated as exactly one message, so a `KeyUpdate` sharing a record with a `NewSessionTicket` was silently dropped — read keys were never rotated and every record after it failed to decrypt — and coalesced tickets past the first were lost.
203
+ - **Protocol Hardening Across TLS, HTTP/2, QUIC And Alt-Svc** — Picks up a broad upstream compliance pass: TLS 1.2 renegotiation is refused with a fatal alert, the record layer and session-ticket trust are bounded, servers can no longer select parameters the client never offered, HTTP/2 enforces response pseudo-header rules and closes connections on peer protocol errors, `record_size_limit` is honoured when sending (RFC 8449), draft HTTP/3 Alt-Svc advertisements are ignored, and SOCKS5 authentication replies are version-checked.
204
+
205
+ ## lkrequest-py 0.2.0
206
+
207
+ ### 🚀 Features
208
+ - **System DNS Positive Cache** — Added `system_dns_cache_ttl` and `system_dns_cache_max_entries` to `Client` and `BlockingClient`. The OS resolver already coalesces concurrent identical lookups process-wide; these cache successful, non-empty results for `ttl` seconds (default capacity 1024 entries). A `0` TTL keeps the coalescing while disabling the cache. It selects the system resolver, so it cannot be combined with `dns`; errors and empty results are never cached.
209
+ - **Shutdown Draining** — Added `drain_pending(timeout=None)`, its blocking counterpart `blocking_drain_pending(...)`, and `pending_requests()`. Waiting for in-flight work before tearing down an event loop means results are still delivered rather than dropped on arrival. Counts are process-wide and cover every async call, not only requests.
210
+ - **HTTP/2 Pending-Request Bound** — Added `max_pending_h2_requests` to `Client` and `BlockingClient`. HTTP/2 requests that exceed the peer's concurrent-stream limit now wait for a stream slot instead of failing, and this option caps how many may queue. Omit it (the default) for an unbounded queue; `0` is rejected with `ValueError`.
211
+ - **Synthetic Fingerprint Negotiability (`synthetic-fp`)** — Added `NegotiabilityFloor.UNIVERSAL`, `NegotiabilityFloor.PRESET_FAMILY`, `NegotiabilityFloor.custom(...)`, and `Randomize.negotiability(...)` so synthesized ClientHello profiles retain a configurable signature-algorithm compatibility floor.
212
+ - **Multi-hop Proxy Chains** — Added `ProxyConfig.parse_chain(...)` and `.through(...)`, with chain metadata via `hop_count()` and `identity()`. Client sessions, proxy pools, and session pools now accept either proxy URLs or `ProxyConfig` objects.
213
+ - **Mixed Proxy Transports** — HTTP/1.1 and HTTP/2 can traverse ordered combinations of HTTP CONNECT, SOCKS5, and SOCKS5H hops. QUIC/HTTP3 supports all-SOCKS5 chains when the optional `quic-h3` feature is enabled.
214
+ - **QUIC-TLS Profile Presets** — Added `TlsProfile.chrome_146_quic()`, `TlsProfile.chrome_150_quic()`, and `TlsProfile.chrome_151_quic()`, exposing the ClientHello Chrome sends inside QUIC (previously reachable only through the bundled `Client.chrome_1xx()` presets). Pass one to `Client(quic_fingerprint=...)` when building a client manually; HTTP/3 otherwise reuses the main TLS profile.
215
+ - **Chrome 151 Preset** — Added `Client.chrome_151()`, `BlockingClient.chrome_151()`, `TlsProfile.chrome_151()`, `H2Profile.chrome_151()`, `QuicProfile.chrome_151()`, and the `"chrome_151"` TLS/H2/QUIC profile names. Captured from Chrome 151.0.7922.72: TCP TLS and HTTP/2 keep Chrome 150's stable fields, while the QUIC-TLS capture omits the three ML-DSA signature algorithms; QUIC transport parameters and HTTP/3 SETTINGS stay aligned with Chrome 150.
216
+ - **Chrome 150 QUIC/H3 (`quic-h3`)** — `Client.chrome_150()` now uses dedicated Chrome 150 QUIC TLS and HTTP/3 profiles instead of reusing Chrome 146. The preset covers captured signature algorithms, QUIC transport parameters, QPACK limits, navigation `PRIORITY_UPDATE`, connection-ID lengths, and Initial datagram sizing. Added `QuicProfile.chrome_150()` and the `"chrome_150"` QUIC profile name.
217
+
218
+ ### 🐞 Fixes
219
+ - **No More Crashes From Late-Completing Requests** — A request that finished after its event loop had closed used to print a `RuntimeError: Event loop is closed` traceback from whichever worker thread it landed on. Enough of them at once turned into `Fatal Python error: _enter_buffered_busy` and killed the process with SIGABRT — uncatchable, and it took the whole batch down. Results arriving after the loop closes are now dropped silently, since nothing is left to receive them.
220
+ - **Multi-Address Connect Fallback** — Connecting now tries every address a hostname resolves to, racing them Happy Eyeballs style (RFC 8305) with interleaved address families, rather than giving up when the first candidate is unreachable. Applies to direct TCP and to SOCKS5 proxies.
221
+ - **HTTP/2 Stream-Limit Backpressure** — Requests beyond the peer's advertised `MAX_CONCURRENT_STREAMS` now wait asynchronously for a stream slot instead of failing with a local capacity error.
222
+ - **Redirects Over Reused HTTP/1.1 Connections** — Following a redirect no longer fails when the pooled HTTP/1.1 connection was torn down in the meantime. The reuse attempt surfaces as hyper's `operation was canceled`, which is now recognised as a closed connection, so the request is retried on a fresh connection instead of erroring out. Affects any server that hangs up after answering a `3xx` — a common pattern after `POST`.
223
+ - **HTTP/2 `1xx` Interim Responses** — The HTTP/2 driver now consumes `1xx` informational responses — such as the `103 Early Hints` Cloudflare sends ahead of its `200` — and returns the final response that follows, instead of reporting the interim status as the final one. A plain `GET https://www.cloudflare.com/` previously came back as `103`.
224
+ - **Connection Limit Enforcement** — `max_connections` now bounds *physical* connections, counting those still being dialled and those checked out by an in-flight request rather than only what sits idle in the pool. Exceeding the limit raises instead of quietly opening another connection, and an idle HTTP/1.1 connection is evicted first so one origin's idle connections cannot starve another. `PoolStats.total` correspondingly reports physical connections in use rather than pooled entries.
225
+ - **Optional Client Certificate Requests** — A server that asks for a client certificate no longer breaks the handshake. TLS 1.2 and TLS 1.3 now answer a `CertificateRequest` with an empty client `Certificate` message — echoing the request context on TLS 1.3, as RFC 8446 §4.4.2 requires — so endpoints with optional mutual TLS complete the handshake instead of failing.
226
+ - **TLS Handshake Reassembly** — TLS 1.2 and TLS 1.3 handshake messages fragmented across multiple records are now buffered and reassembled correctly, preventing failures such as `truncated handshake message in record` without changing ClientHello or fingerprint output.
227
+ - **SOCKS5 UDP Relay Compatibility** — QUIC-over-SOCKS5 accepts relay replies from the advertised source IP even when the relay uses a different egress port, preventing affected HTTP/3 requests from hanging.
228
+ - **Package Version Reporting** — `lkrequest.__version__` is now read from installed distribution metadata instead of being maintained as a separate hard-coded value.
229
+ - **TLS 1.3 ECH + HelloRetryRequest** — Real ECH CH2 now reuses the ECH configuration, HPKE sender context, Inner random, GREASE values, extension permutation, and fake ECH bytes. HRR confirmation, selected groups, cookie-only retries, invalid extensions, dummy CCS, and local fatal alerts now follow the browser/RFC flow.
230
+ - **ECH + PSK + HRR Binders** — `pre_shared_key` remains exclusively in `ClientHelloInner`; CH1 and CH2 binders now use the correct truncated Inner transcripts, including `message_hash(ClientHelloInner1) + HRR` for CH2.
231
+ - **QUIC/H3 HRR State Transitions** — QUIC revokes 0-RTT stream and packet state after HRR and maps local TLS alerts to QUIC `CRYPTO_ERROR` codes.
232
+
233
+ ## lkrequest-py 0.1.1
234
+
235
+ ### 🐞 Fixes
236
+ - **Project URLs** — `Homepage`, `Repository`, and `Issues` in the package metadata pointed at a repository path that does not exist, so every link on the PyPI project page returned 404. They now point at `github.com/EZ-XLabs/lkrequest-py`.
237
+ - **README Links on PyPI** — the language switcher at the top of both READMEs used relative paths (`README.md` / `README-zh.md`), which PyPI cannot resolve when it renders the long description. Both now use absolute URLs, so switching between English and 简体中文 works from the project page.
238
+
239
+ ### 📝 Documentation
240
+ - **Development Setup** — the setup section now walks through the full local workflow on Linux/macOS and Windows: creating and activating a virtualenv, installing maturin, `pip install -e ".[test,lint]"`, and running the suite via `just test`. An equivalent `uv sync --extra test --extra lint` path is documented alongside it.
241
+
242
+ ## lkrequest-py 0.1.0
243
+
244
+ ### 🚀 Features
245
+ - **TLS Fingerprint Control** — Byte-level ClientHello generation that matches real browsers (Chrome, Firefox, Safari)
246
+ - **HTTP/2 Fingerprint Control** — SETTINGS frame order, pseudo-header order, WINDOW_UPDATE, PRIORITY frames, and per-request priority weights
247
+ - **HTTP/3 + QUIC** — Optional `quic-h3` feature for full HTTP/3 over QUIC with fingerprint-aware Transport Parameters, Alt-Svc auto-discovery, and H2→H3 seamless upgrade
248
+ - **Built-in Browser Presets** — Chrome 131/144/145/146/147/148/149/150, Firefox 133/147, Safari 18/26 (TLS + H2, plus QUIC when `quic-h3` is enabled)
249
+ - **Session Management** — Cookie jars, connection pooling, HTTP/2 multiplexing, and optional QUIC 0-RTT session resumption
250
+ - **Connection Prewarming** — `session.preconnect()` pre-establishes DNS + TCP + TLS + H2 (or, with `quic-h3`, QUIC + H3) connections
251
+ - **Custom DNS Resolver** — Pluggable DNS with DoH support, auto ECH config and H3 hints via HTTPS/SVCB records
252
+ - **Alt-Svc Discovery** — Automatic HTTP/2 → HTTP/3 protocol upgrade via Alt-Svc headers with broken-QUIC fallback
253
+ - **SessionPool** — High-concurrency pool with proxy rotation (round-robin / random)
254
+ - **Proxy Support** — SOCKS5 and HTTP CONNECT with authentication
255
+ - **WebSocket** — HTTP/1.1 Upgrade (RFC 6455) and H2 Extended CONNECT (RFC 8441)
256
+ - **Middleware** — Interceptor chain for request/response modification
257
+ - **Retry Policies** — Exponential backoff, fixed interval, custom strategies; idempotency-aware (non-idempotent methods are not silently replayed after a possibly-already-sent failure unless marked `Idempotency::Idempotent`)
258
+ - **Redirect Control** — `RedirectPolicy::Follow(n)` or `RedirectPolicy::None` for manual handling
259
+ - **Auto-Decompression** — Brotli, gzip, deflate, zstd
260
+ - **Blocking API** — Synchronous wrapper for non-async contexts
261
+ - **Multipart/Form-Data** — File uploads with streaming support
262
+ - **TCP Fingerprint** — JA4T-style TCP SYN fingerprinting
@@ -7,7 +7,7 @@ binding over the `lkrequest` Rust workspace, built with
7
7
  ## Development setup
8
8
 
9
9
  ```bash
10
- git clone https://github.com/EZ-XLabs/lkrequest-py
10
+ git clone https://github.com/EZXLabs/lkrequest-py
11
11
  cd lkrequest-py
12
12
  python3 -m venv .venv # Linux/macOS
13
13
  # py -m venv .venv # Windows