without-http 0.0.4__tar.gz → 0.0.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: without-http
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets.
5
5
  Author: Josh Karpel
6
6
  Author-email: Josh Karpel <josh.karpel@gmail.com>
@@ -16,8 +16,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
16
16
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
17
17
  Classifier: Topic :: Software Development :: Libraries
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: without-core==0.0.4
20
- Requires-Dist: without-asgi==0.0.4
19
+ Requires-Dist: without-async==0.0.6
20
+ Requires-Dist: without-streams==0.0.6
21
+ Requires-Dist: without-asgi==0.0.6
21
22
  Requires-Dist: h11>=0.16
22
23
  Requires-Dist: h2>=4.1
23
24
  Requires-Dist: wsproto>=1.2
@@ -29,7 +30,7 @@ Description-Content-Type: text/markdown
29
30
  # without-http
30
31
 
31
32
  A sans-IO-backed ASGI **server** and **HTTP client** for `without`. Where
32
- [`without-asgi`](../without-asgi) is the *app* side of the ASGI boundary (it turns
33
+ [`without-asgi`](../without_asgi) is the *app* side of the ASGI boundary (it turns
33
34
  a server's `receive`/`send` into typed streams), `without-http` is the *server*
34
35
  side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
35
36
  `app(scope, receive, send)`.
@@ -43,7 +44,7 @@ those state machines, and uses `without-asgi`'s server-direction codecs to
43
44
  translate between typed events and the ASGI dicts an app expects.
44
45
 
45
46
  ```python
46
- from without import sleep_forever
47
+ from without_async import sleep_forever
47
48
  from without_asgi import make_asgi_app
48
49
  from without_http import ConnectionPool, request, serving
49
50
 
@@ -1,7 +1,7 @@
1
1
  # without-http
2
2
 
3
3
  A sans-IO-backed ASGI **server** and **HTTP client** for `without`. Where
4
- [`without-asgi`](../without-asgi) is the *app* side of the ASGI boundary (it turns
4
+ [`without-asgi`](../without_asgi) is the *app* side of the ASGI boundary (it turns
5
5
  a server's `receive`/`send` into typed streams), `without-http` is the *server*
6
6
  side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
7
7
  `app(scope, receive, send)`.
@@ -15,7 +15,7 @@ those state machines, and uses `without-asgi`'s server-direction codecs to
15
15
  translate between typed events and the ASGI dicts an app expects.
16
16
 
17
17
  ```python
18
- from without import sleep_forever
18
+ from without_async import sleep_forever
19
19
  from without_asgi import make_asgi_app
20
20
  from without_http import ConnectionPool, request, serving
21
21
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-http"
7
- version = "0.0.4"
7
+ version = "0.0.6"
8
8
  description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,8 +23,9 @@ classifiers = [
23
23
  "Typing :: Typed",
24
24
  ]
25
25
  dependencies = [
26
- "without-core==0.0.4",
27
- "without-asgi==0.0.4",
26
+ "without-async==0.0.6",
27
+ "without-streams==0.0.6",
28
+ "without-asgi==0.0.6",
28
29
  "h11>=0.16",
29
30
  "h2>=4.1",
30
31
  "wsproto>=1.2",
@@ -36,7 +37,10 @@ dependencies = [
36
37
  name = "Josh Karpel"
37
38
  email = "josh.karpel@gmail.com"
38
39
 
39
- [tool.uv.sources.without-core]
40
+ [tool.uv.sources.without-async]
41
+ workspace = true
42
+
43
+ [tool.uv.sources.without-streams]
40
44
  workspace = true
41
45
 
42
46
  [tool.uv.sources.without-asgi]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-http"
7
- version = "0.0.4"
7
+ version = "0.0.6"
8
8
  description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,8 +26,9 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "without-core==0.0.4",
30
- "without-asgi==0.0.4",
29
+ "without-async==0.0.6",
30
+ "without-streams==0.0.6",
31
+ "without-asgi==0.0.6",
31
32
  "h11>=0.16",
32
33
  "h2>=4.1",
33
34
  "wsproto>=1.2",
@@ -36,5 +37,6 @@ dependencies = [
36
37
  ]
37
38
 
38
39
  [tool.uv.sources]
39
- without-core = { workspace = true }
40
+ without-async = { workspace = true }
41
+ without-streams = { workspace = true }
40
42
  without-asgi = { workspace = true }
@@ -1,4 +1,5 @@
1
1
  from without_http.client import DEFAULT_DECOMPRESSORS
2
+ from without_http.client import GZIP_CONTAINER
2
3
  from without_http.client import USER_AGENT
3
4
  from without_http.client import Client
4
5
  from without_http.client import ClientMiddleware
@@ -13,10 +14,12 @@ from without_http.client import Resolve
13
14
  from without_http.client import ResponseBody
14
15
  from without_http.client import ResponseHead
15
16
  from without_http.client import ResponseTrailers
17
+ from without_http.client import StreamingCompressor
16
18
  from without_http.client import add_headers
17
19
  from without_http.client import basic_auth
18
20
  from without_http.client import bearer_auth
19
21
  from without_http.client import brotli_compress
22
+ from without_http.client import brotli_compressor
20
23
  from without_http.client import compressing
21
24
  from without_http.client import cookies
22
25
  from without_http.client import deadline
@@ -24,12 +27,14 @@ from without_http.client import decompress
24
27
  from without_http.client import default_headers
25
28
  from without_http.client import follow_redirects
26
29
  from without_http.client import gzip_compress
30
+ from without_http.client import gzip_compressor
27
31
  from without_http.client import request
28
32
  from without_http.client import stack
29
33
  from without_http.client import tcp_connect
30
34
  from without_http.client import user_agent
31
35
  from without_http.client import wrap
32
36
  from without_http.client import zstd_compress
37
+ from without_http.client import zstd_compressor
33
38
  from without_http.h2_wire import early_hint_headers
34
39
  from without_http.h2_wire import request_headers
35
40
  from without_http.h2_wire import response_headers
@@ -46,6 +51,11 @@ from without_http.socket_options import SocketOptions
46
51
  from without_http.socket_options import receive_buffer_size
47
52
  from without_http.socket_options import send_buffer_size
48
53
  from without_http.socket_options import tcp_keepalive
54
+ from without_http.sse import DEFAULT_RECONNECT
55
+ from without_http.sse import MAXIMUM_RECONNECT
56
+ from without_http.sse import MINIMUM_RECONNECT
57
+ from without_http.sse import NotAnEventStream
58
+ from without_http.sse import subscribe
49
59
  from without_http.timeouts import ConnectTimeout
50
60
  from without_http.timeouts import HTTPTimeout
51
61
  from without_http.timeouts import PoolTimeout
@@ -64,6 +74,10 @@ from without_http.ws_wire import ws_events_from_outbound
64
74
  __all__ = [
65
75
  "ALPN_PROTOCOLS",
66
76
  "DEFAULT_DECOMPRESSORS",
77
+ "DEFAULT_RECONNECT",
78
+ "GZIP_CONTAINER",
79
+ "MAXIMUM_RECONNECT",
80
+ "MINIMUM_RECONNECT",
67
81
  "USER_AGENT",
68
82
  "Client",
69
83
  "ClientMiddleware",
@@ -77,6 +91,7 @@ __all__ = [
77
91
  "Decompressor",
78
92
  "HTTPTimeout",
79
93
  "LifespanError",
94
+ "NotAnEventStream",
80
95
  "PoolTimeout",
81
96
  "ReadTimeout",
82
97
  "Resolve",
@@ -85,12 +100,14 @@ __all__ = [
85
100
  "ResponseTrailers",
86
101
  "Server",
87
102
  "SocketOptions",
103
+ "StreamingCompressor",
88
104
  "Timeout",
89
105
  "WriteTimeout",
90
106
  "add_headers",
91
107
  "basic_auth",
92
108
  "bearer_auth",
93
109
  "brotli_compress",
110
+ "brotli_compressor",
94
111
  "compressing",
95
112
  "cookies",
96
113
  "deadline",
@@ -101,6 +118,7 @@ __all__ = [
101
118
  "extensions_with_tls",
102
119
  "follow_redirects",
103
120
  "gzip_compress",
121
+ "gzip_compressor",
104
122
  "h11_events_from_outbound",
105
123
  "inbound_from_event",
106
124
  "is_websocket_upgrade",
@@ -116,6 +134,7 @@ __all__ = [
116
134
  "server_ssl_context",
117
135
  "serving",
118
136
  "stack",
137
+ "subscribe",
119
138
  "tcp_connect",
120
139
  "tcp_keepalive",
121
140
  "tls_extension",
@@ -124,4 +143,5 @@ __all__ = [
124
143
  "wrap",
125
144
  "ws_events_from_outbound",
126
145
  "zstd_compress",
146
+ "zstd_compressor",
127
147
  ]
@@ -23,6 +23,7 @@ from datetime import datetime
23
23
  from datetime import timedelta
24
24
  from email.utils import parsedate_to_datetime
25
25
  from importlib import metadata
26
+ from types import MappingProxyType
26
27
  from typing import NamedTuple
27
28
  from typing import Protocol
28
29
  from typing import Self
@@ -38,14 +39,20 @@ import h2.exceptions
38
39
  import h11
39
40
  from aiohappyeyeballs import AddrInfoType
40
41
  from aiohappyeyeballs import start_connection
41
- from without import Endo
42
- from without import Stream
43
- from without import cancel_futures
44
- from without import stack
45
42
  from without_asgi import Content
46
43
  from without_asgi import RawHeaders
47
44
  from without_asgi import StreamingContent
45
+ from without_asgi.compression import GZIP_CONTAINER
46
+ from without_asgi.compression import Compressor
47
+ from without_asgi.compression import StreamingCompressor
48
+ from without_asgi.compression import brotli_compressor
49
+ from without_asgi.compression import gzip_compressor
50
+ from without_asgi.compression import zstd_compressor
48
51
  from without_asgi.headers import merge
52
+ from without_async import cancel_futures
53
+ from without_streams import Endo
54
+ from without_streams import Stream
55
+ from without_streams import stack
49
56
 
50
57
  from without_http.h2_wire import request_headers
51
58
  from without_http.h2_wire import response_status_and_headers
@@ -1615,26 +1622,28 @@ def follow_redirects(max_hops: int = 5) -> ClientMiddleware:
1615
1622
 
1616
1623
 
1617
1624
  _REQUEST_FRAMING_HEADERS = frozenset({b"content-length", b"transfer-encoding"})
1618
- _GZIP_CONTAINER = zlib.MAX_WBITS | 16 # the wbits offset that selects the gzip wrapper around DEFLATE
1619
-
1620
-
1621
- class Compressor(Protocol):
1622
- """
1623
- The incremental compressor shape `compressing` drives: feed chunks through
1624
- `compress`, then `flush` ends the stream. `zlib.compressobj` and
1625
- `zstd.ZstdCompressor` satisfy it as-is; a third-party codec plugs in with
1626
- whatever thin adapter its own surface needs.
1627
- """
1628
-
1629
- def compress(self, data: bytes, /) -> bytes: ...
1630
- def flush(self) -> bytes: ...
1631
1625
 
1632
1626
 
1633
1627
  async def _compressed(body: Stream[bytes], compressor: Compressor) -> AsyncIterator[bytes]:
1628
+ # A codec decides for itself what any one `compress` call returns, and fed the
1629
+ # pieces a streamed body arrives in it usually returns nothing, holding the whole
1630
+ # upload until the stream ends. Ending a block per chunk is what releases it; a
1631
+ # codec with no way to do that (a plain `Compressor`) has to hold it, since unlike
1632
+ # a negotiated response there is no unencoded answer available to a caller who
1633
+ # asked for this coding by name.
1634
+ flush_block: Callable[[], bytes] = (
1635
+ compressor.flush_block if isinstance(compressor, StreamingCompressor) else lambda: b""
1636
+ )
1637
+ # One chunk of lookahead, so the last one is known to be last and rides out on the
1638
+ # flush that ends the stream rather than paying for a block of its own. A body that
1639
+ # arrives whole is one chunk, so it still encodes to exactly the bytes it would
1640
+ # have without any of this.
1641
+ held: bytes | None = None
1634
1642
  async for chunk in body:
1635
- if compressed := compressor.compress(chunk):
1643
+ if held and (compressed := compressor.compress(held) + flush_block()):
1636
1644
  yield compressed
1637
- yield compressor.flush()
1645
+ held = chunk
1646
+ yield compressor.compress(held or b"") + compressor.flush()
1638
1647
 
1639
1648
 
1640
1649
  def compressing(coding: bytes, make_compressor: Callable[[], Compressor]) -> ClientMiddleware:
@@ -1648,11 +1657,21 @@ def compressing(coding: bytes, make_compressor: Callable[[], Compressor]) -> Cli
1648
1657
  reimplemented.
1649
1658
 
1650
1659
  That rest: the lazy body `Stream[bytes]` is wrapped in the incremental compressor,
1651
- so a streamed upload compresses chunk by chunk and nothing buffers; and the
1652
- framing follows the rewrite, `content-length` no longer describes the body, so it
1653
- is dropped and the compressed stream goes out `transfer-encoding: chunked` (over
1654
- HTTP/2 the framing header is dropped with the other hop-by-hop headers and the
1655
- body rides DATA frames as usual).
1660
+ so a streamed upload compresses chunk by chunk and holds no more than one of them;
1661
+ and the framing follows the rewrite, `content-length` no longer describes the body,
1662
+ so it is dropped and the compressed stream goes out `transfer-encoding: chunked`
1663
+ (over HTTP/2 the framing header is dropped with the other hop-by-hop headers and
1664
+ the body rides DATA frames as usual).
1665
+
1666
+ Holding no more than a chunk is a demand on the codec, not just on the loop: what
1667
+ `compress` returns is the codec's choice, and fed a chunk at a time zlib and zstd
1668
+ return almost nothing until the stream ends, which would buffer the whole upload
1669
+ inside the codec while looking like it streamed. `make_compressor` should therefore
1670
+ produce a `StreamingCompressor`, as `gzip_compressor`, `zstd_compressor`, and
1671
+ `brotli_compressor` all do; a plain `Compressor` still encodes correctly and still
1672
+ buffers, because a coding named by the caller has no unencoded answer to fall back
1673
+ on the way a negotiated response does. A body that arrives whole is one chunk
1674
+ either way, and encodes to the same bytes under both.
1656
1675
 
1657
1676
  Two kinds of request pass through untouched: one already carrying a
1658
1677
  `content-encoding` (the body is already encoded; re-compressing would corrupt it),
@@ -1694,7 +1713,7 @@ def gzip_compress(level: int = zlib.Z_DEFAULT_COMPRESSION) -> ClientMiddleware:
1694
1713
  codings, and `compressing` is the shared mechanism for any coding beyond those;
1695
1714
  the response-side counterpart to all of them is `decompress`.
1696
1715
  """
1697
- return compressing(b"gzip", lambda: zlib.compressobj(level, zlib.DEFLATED, _GZIP_CONTAINER))
1716
+ return compressing(b"gzip", lambda: gzip_compressor(level))
1698
1717
 
1699
1718
 
1700
1719
  def zstd_compress(level: int | None = None) -> ClientMiddleware:
@@ -1708,33 +1727,7 @@ def zstd_compress(level: int | None = None) -> ClientMiddleware:
1708
1727
 
1709
1728
  `level` is zstd's compression level, defaulting to the library's own default.
1710
1729
  """
1711
- return compressing(b"zstd", lambda: zstd.ZstdCompressor(level))
1712
-
1713
-
1714
- class _RawBrotliCompressor(Protocol):
1715
- """The slice of `brotli.Compressor` the adapter drives (the bindings ship no types)."""
1716
-
1717
- def process(self, data: bytes, /) -> bytes: ...
1718
- def finish(self) -> bytes: ...
1719
-
1720
-
1721
- @dataclass(slots=True, eq=False)
1722
- class _BrotliCompressor:
1723
- """
1724
- Adapt `brotli.Compressor` to the `Compressor` shape.
1725
-
1726
- Brotli's bindings spell the incremental surface `process`/`finish` (their `flush`
1727
- is a mid-stream flush that keeps the stream open), so the adapter maps `compress`
1728
- to `process` and `flush` to `finish`.
1729
- """
1730
-
1731
- _raw: _RawBrotliCompressor
1732
-
1733
- def compress(self, data: bytes, /) -> bytes:
1734
- return self._raw.process(data)
1735
-
1736
- def flush(self) -> bytes:
1737
- return self._raw.finish()
1730
+ return compressing(b"zstd", lambda: zstd_compressor(level))
1738
1731
 
1739
1732
 
1740
1733
  def brotli_compress(quality: int = 11) -> ClientMiddleware:
@@ -1744,9 +1737,12 @@ def brotli_compress(quality: int = 11) -> ClientMiddleware:
1744
1737
  `gzip_compress`'s sibling over brotli ([Google's own bindings](https://github.com/google/brotli),
1745
1738
  a bundled dependency since the stdlib has no brotli): everything there holds here,
1746
1739
  and only the coding differs. `quality` is brotli's compression quality (0-11),
1747
- defaulting to the library's own default (11, the maximum).
1740
+ defaulting to the bindings' own default of 11, the maximum, because a client
1741
+ compressing an upload it holds whole is the case that ratio is worth paying for.
1742
+ The server-side `compress` table defaults lower, since it encodes per response;
1743
+ see `without_asgi.compression.brotli_compressor`, the shared codec behind both.
1748
1744
  """
1749
- return compressing(b"br", lambda: _BrotliCompressor(brotli.Compressor(quality=quality)))
1745
+ return compressing(b"br", lambda: brotli_compressor(quality))
1750
1746
 
1751
1747
 
1752
1748
  class Decompressor(Protocol):
@@ -1767,7 +1763,7 @@ class Decompressor(Protocol):
1767
1763
 
1768
1764
 
1769
1765
  def _gzip_decompressor() -> Decompressor:
1770
- return zlib.decompressobj(wbits=_GZIP_CONTAINER)
1766
+ return zlib.decompressobj(wbits=GZIP_CONTAINER)
1771
1767
 
1772
1768
 
1773
1769
  class _RawBrotliDecompressor(Protocol):
@@ -1802,12 +1798,16 @@ def _brotli_decompressor() -> Decompressor:
1802
1798
 
1803
1799
 
1804
1800
  # The codings decoded out of the box: gzip and zstd from the stdlib, brotli from the
1805
- # bundled bindings. `decompress`'s default table.
1806
- DEFAULT_DECOMPRESSORS: Mapping[bytes, Callable[[], Decompressor]] = {
1807
- b"br": _brotli_decompressor,
1808
- b"gzip": _gzip_decompressor,
1809
- b"zstd": zstd.ZstdDecompressor,
1810
- }
1801
+ # bundled bindings. `decompress`'s default table, and the mirror of the server-side
1802
+ # `DEFAULT_COMPRESSORS`, a proxy for the same two reasons: nothing can mutate the table
1803
+ # every other caller starts from, and `|` extends it into a fresh `dict`.
1804
+ DEFAULT_DECOMPRESSORS: MappingProxyType[bytes, Callable[[], Decompressor]] = MappingProxyType(
1805
+ {
1806
+ b"br": _brotli_decompressor,
1807
+ b"gzip": _gzip_decompressor,
1808
+ b"zstd": zstd.ZstdDecompressor,
1809
+ }
1810
+ )
1811
1811
 
1812
1812
  _CONTENT_CODING_HEADERS = frozenset({b"content-encoding", b"content-length"})
1813
1813
 
@@ -1859,7 +1859,7 @@ def decompress(
1859
1859
  the bundled bindings). The offer is *derived from its keys*, so what is
1860
1860
  advertised and what is decoded cannot disagree; register a coding this package
1861
1861
  does not ship by extending the table
1862
- (`decompress({**DEFAULT_DECOMPRESSORS, b"lzma": make_lzma})`) with any factory
1862
+ (`decompress(DEFAULT_DECOMPRESSORS | {b"lzma": make_lzma})`) with any factory
1863
1863
  whose product satisfies `Decompressor`. The mapping is snapshotted, keys
1864
1864
  lowercased, when the middleware is built.
1865
1865
 
@@ -6,7 +6,6 @@ from contextlib import asynccontextmanager
6
6
  from contextlib import suppress
7
7
  from typing import assert_never
8
8
 
9
- from without import cancel_futures
10
9
  from without_asgi import Asgi
11
10
  from without_asgi import ASGIApp
12
11
  from without_asgi import LifespanScope
@@ -20,6 +19,7 @@ from without_asgi import StartupFailed
20
19
  from without_asgi import encode_lifespan_event
21
20
  from without_asgi import encode_scope
22
21
  from without_asgi import parse_lifespan_reply
22
+ from without_async import cancel_futures
23
23
 
24
24
  # Lifespan uses its own spec version, distinct from the HTTP/WebSocket scopes.
25
25
  _LIFESPAN = LifespanScope(asgi=Asgi(version="3.0", spec_version="2.0"))
@@ -22,9 +22,6 @@ import h2.events
22
22
  import h2.exceptions
23
23
  import h2.settings
24
24
  import h11
25
- from without import background_task
26
- from without import cancel_futures
27
- from without import timeout
28
25
  from without_asgi import ASGIApp
29
26
  from without_asgi import Disconnect
30
27
  from without_asgi import EarlyHint
@@ -50,6 +47,9 @@ from without_asgi import encode_websocket_inbound
50
47
  from without_asgi import encode_websocket_scope
51
48
  from without_asgi import parse_outbound
52
49
  from without_asgi import parse_websocket_outbound
50
+ from without_async import background_task
51
+ from without_async import cancel_futures
52
+ from without_async import timeout
53
53
  from wsproto import ConnectionType
54
54
  from wsproto import WSConnection
55
55
  from wsproto.events import BytesMessage
@@ -1,10 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import socket
4
- from datetime import timedelta
4
+
5
+ from without_async import Seconds
5
6
 
6
7
  type SocketOptions = tuple[tuple[int, int, int], ...]
7
8
 
9
+ # Shared immutable defaults for the keepalive probe timing: a frozen count is a value, so
10
+ # every caller can hold the same one.
11
+ _KEEPALIVE_IDLE = Seconds(60)
12
+ _KEEPALIVE_INTERVAL = Seconds(10)
13
+
8
14
 
9
15
  def apply_socket_options(sock: socket.socket | None, options: SocketOptions) -> None:
10
16
  """Apply each `(level, option, value)` triple to `sock`."""
@@ -16,8 +22,8 @@ def apply_socket_options(sock: socket.socket | None, options: SocketOptions) ->
16
22
 
17
23
  def tcp_keepalive(
18
24
  *,
19
- idle: timedelta = timedelta(seconds=60),
20
- interval: timedelta = timedelta(seconds=10),
25
+ idle: Seconds = _KEEPALIVE_IDLE,
26
+ interval: Seconds = _KEEPALIVE_INTERVAL,
21
27
  count: int = 6,
22
28
  ) -> SocketOptions:
23
29
  """
@@ -35,9 +41,10 @@ def tcp_keepalive(
35
41
  - `count`: unanswered probes before the connection is declared dead.
36
42
 
37
43
  So a broken idle connection is dropped roughly `idle + interval * count` after it goes
38
- quiet. `idle`/`interval` MUST be a whole number of seconds, since the OS options carry
39
- only integer seconds (a sub-second component is rejected here rather than silently
40
- truncated); `count` is a plain probe count, not a duration.
44
+ quiet. `idle`/`interval` are counts of `Seconds` because the OS options carry only
45
+ integer seconds: a finer duration is not something either can be built from, so
46
+ nothing is silently truncated on the way to the socket. `count` is a plain probe
47
+ count, not a duration.
41
48
 
42
49
  Enabling keepalive (`SO_KEEPALIVE`) is portable, but the per-probe tuning is not
43
50
  uniformly spelled or present, so the result includes each knob only where the running
@@ -50,16 +57,13 @@ def tcp_keepalive(
50
57
  available", Windows 10+); older Windows exposes none of them, so only `SO_KEEPALIVE`
51
58
  is enabled and the probe timing stays at the system default.
52
59
  """
53
- for name, duration in (("idle", idle), ("interval", interval)):
54
- if duration.microseconds:
55
- raise ValueError(f"{name} must be a whole number of seconds, got {duration}")
56
60
  # macOS has no TCP_KEEPIDLE and spells the idle knob TCP_KEEPALIVE; Linux and modern
57
61
  # Windows use TCP_KEEPIDLE, so prefer it and fall back to the macOS name.
58
62
  idle_option = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None)
59
63
  options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
60
64
  for option, value in (
61
- (idle_option, int(idle.total_seconds())),
62
- (getattr(socket, "TCP_KEEPINTVL", None), int(interval.total_seconds())),
65
+ (idle_option, idle.count),
66
+ (getattr(socket, "TCP_KEEPINTVL", None), interval.count),
63
67
  (getattr(socket, "TCP_KEEPCNT", None), count),
64
68
  ):
65
69
  # The skip path only fires on a platform missing a knob (e.g. older Windows),
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator
5
+ from collections.abc import Awaitable
6
+ from collections.abc import Callable
7
+ from datetime import timedelta
8
+ from typing import assert_never
9
+
10
+ from without_asgi import RawHeaders
11
+ from without_asgi.headers import first
12
+ from without_asgi.sse import EVENT_STREAM_MEDIA_TYPE
13
+ from without_asgi.sse import Checkpoint
14
+ from without_asgi.sse import ReceivedEvent
15
+ from without_asgi.sse import Retry
16
+ from without_asgi.sse import parse_events_with_directives
17
+ from without_streams import close_stream
18
+
19
+ from without_http.client import ClientResponse
20
+ from without_http.client import ResponseHead
21
+ from without_http.timeouts import HTTPTimeout
22
+
23
+ # The half of Server-Sent Events that needs a transport. The format itself is two pure
24
+ # stream transforms in `without-asgi` (`without_asgi.sse`), which is why a handler can
25
+ # emit an event stream under uvicorn and a caller can parse one out of any
26
+ # `Stream[bytes]`. What lives here is the loop those two cannot express on their own:
27
+ # reconnecting a dropped stream and resuming it from the last id, which needs a client
28
+ # exchange to reconnect *with* — the `ClientResponse` it opens each attempt against, and
29
+ # the timeouts it treats as a stream that dropped rather than a fault.
30
+
31
+ __all__ = [
32
+ "DEFAULT_RECONNECT",
33
+ "MAXIMUM_RECONNECT",
34
+ "MINIMUM_RECONNECT",
35
+ "NotAnEventStream",
36
+ "subscribe",
37
+ ]
38
+
39
+ # How long to wait before reconnecting until a producer says otherwise with `retry:`.
40
+ # The spec leaves the initial value to the implementation, "probably in the region of a
41
+ # few seconds"; browsers land between two and five.
42
+ DEFAULT_RECONNECT = timedelta(seconds=3)
43
+
44
+ # The floor a producer's `retry:` is clamped to, so a hostile or broken `retry: 0`
45
+ # cannot spin a consumer into a hot reconnect loop.
46
+ MINIMUM_RECONNECT = timedelta(milliseconds=100)
47
+
48
+ # The ceiling, for the mirrored reason: a `retry: 8640000000000` that was meant to be
49
+ # `retry: 86400000` parks a consumer for centuries, and a subscription that is silent
50
+ # forever with nothing raised is harder to notice than one that reconnects too often.
51
+ # Well past any real backoff, which browsers spell in seconds.
52
+ MAXIMUM_RECONNECT = timedelta(minutes=5)
53
+
54
+
55
+ class NotAnEventStream(Exception):
56
+ """
57
+ The endpoint did not answer with a `200 text/event-stream` response.
58
+
59
+ Terminal, never retried: the spec fails the connection on exactly these two
60
+ conditions and does not reconnect afterwards, because an endpoint answering `404`
61
+ or `text/html` is not a stream that dropped, it is one that was never there.
62
+ """
63
+
64
+
65
+ async def _sleep(duration: timedelta) -> None:
66
+ await asyncio.sleep(duration.total_seconds())
67
+
68
+
69
+ def _is_event_stream(head: ResponseHead) -> bool:
70
+ content_type = first(head.headers, b"content-type")
71
+ if content_type is None:
72
+ return False
73
+ return content_type.split(b";")[0].strip().lower() == EVENT_STREAM_MEDIA_TYPE
74
+
75
+
76
+ def subscribe(
77
+ attempt: Callable[[RawHeaders], Awaitable[ClientResponse]],
78
+ *,
79
+ reconnect: timedelta = DEFAULT_RECONNECT,
80
+ minimum_reconnect: timedelta = MINIMUM_RECONNECT,
81
+ maximum_reconnect: timedelta = MAXIMUM_RECONNECT,
82
+ max_event_size: int | None = None,
83
+ sleep: Callable[[timedelta], Awaitable[None]] = _sleep,
84
+ ) -> AsyncGenerator[ReceivedEvent]:
85
+ """
86
+ Consume an event stream, reconnecting and resuming when it drops.
87
+
88
+ The composition the two halves of Server-Sent Events exist to be assembled into: it
89
+ opens a connection, parses the response body with
90
+ `without_asgi.sse.parse_events_with_directives`, and when the stream ends it waits
91
+ and opens another one carrying `Last-Event-ID`, so the producer can resume where the
92
+ consumer stopped. What a caller sees is one uninterrupted stream of events across
93
+ however many connections it took.
94
+
95
+ ```python
96
+ async for event in subscribe(lambda headers: client(ClientRequest("GET", url, headers))):
97
+ print(event.type, event.data)
98
+ ```
99
+
100
+ `attempt` opens one connection: given the headers this loop wants on the request, it
101
+ answers with the response. A *function* rather than a `ClientRequest`, because a
102
+ request is not replayable. Its body is a `Stream[bytes]`, which the interface allows
103
+ to be iterated exactly once, and the bodies this package builds are one-shot async
104
+ generators, so re-sending one request value would put a full body on the wire for
105
+ the first attempt and an empty one on every attempt after it. Building the request
106
+ inside `attempt` makes that unrepresentable rather than documented, and it is what
107
+ lets an event stream ride a `POST` (the shape MCP's Streamable HTTP uses) instead of
108
+ only the bodyless `GET` a reused request survives.
109
+
110
+ The headers handed to `attempt` are `accept: text/event-stream` and, once the stream
111
+ has a resumption point, `last-event-id`. Pass them through as above, or `merge` them
112
+ with your own to decide which side wins on a name you also set.
113
+
114
+ Descending a layer is the whole point of the split, and costs one line. A caller
115
+ that wants exactly one connection, or its own reconnection policy, skips this and
116
+ parses the body directly:
117
+
118
+ ```python
119
+ head, body = await client(request)
120
+ async for event in parse_events(body):
121
+ ...
122
+ ```
123
+
124
+ ## What it retries, and what it does not
125
+
126
+ This is the only retry loop `without-http` ships, and the register's position
127
+ against a `retry()` middleware is why it can be: that position rejects *policy*
128
+ the library would have to invent (how many attempts, which statuses, what backoff),
129
+ and here there is none to invent. The backoff arrives on the wire as `retry:`, the
130
+ resumption token arrives as `id:`, and the terminal condition is written into the
131
+ protocol. What the settings below decide is how far to trust the peer that supplies
132
+ them.
133
+
134
+ - A **non-`200` status or a content type other than `text/event-stream`** raises
135
+ `NotAnEventStream` and never reconnects, per the spec's terminal failure.
136
+ - The **first connection's errors propagate**. A caller that cannot reach the
137
+ endpoint at all learns so immediately instead of watching a silent loop.
138
+ - Once a stream has been established, a **connection error or timeout, on the
139
+ stream or on any later attempt, reconnects** after the current wait. A stream
140
+ that a proxy reaps every 60 seconds is the ordinary case, not the exceptional
141
+ one, which is why the protocol has a resumption token at all.
142
+
143
+ `reconnect` is the wait until the producer names one with `retry:`, after which its
144
+ value is used, clamped to between `minimum_reconnect` (100ms) and
145
+ `maximum_reconnect` (five minutes). Both ends guard the same thing, a `retry:` that
146
+ is hostile or merely wrong: at zero it would spin a consumer into a hot reconnect
147
+ loop, and a few orders of magnitude too large it would park one on a subscription
148
+ that goes silent forever with nothing raised to notice. Widen either end for a
149
+ producer you trust to name its own backoff, or narrow them to hold a peer to a
150
+ window you chose. `max_event_size` is passed through to the parser. `sleep` is the
151
+ delay, injected so a test drives the loop without waiting (and so a caller can add
152
+ jitter).
153
+
154
+ Reflecting a producer's value into a request header is safe here because the parser
155
+ only ever hands on an id a header can carry unchanged: a carriage return or line
156
+ feed is what ended the field, and an `id:` a field value could not spell, or would
157
+ silently alter, is ignored rather than resumed from.
158
+
159
+ An `AsyncGenerator` rather than a bare `AsyncIterator`, because this holds a live
160
+ connection and a caller that stops early should be able to say so: `aclose()`
161
+ releases it there and then, rather than at whenever the collector gets to it.
162
+ """
163
+ # An ordinary function wrapping the generator, so a contradictory window is refused
164
+ # where the caller wrote it. The same guards inside the generator body would not run
165
+ # until the first `anext`, which is after the first request is on the wire and may be
166
+ # arbitrarily long after the mistake was made.
167
+ if not timedelta() <= minimum_reconnect <= maximum_reconnect:
168
+ raise ValueError(
169
+ f"the reconnection window runs from zero upwards, not from {minimum_reconnect!r} to {maximum_reconnect!r}"
170
+ )
171
+ if reconnect < timedelta():
172
+ raise ValueError(f"an initial reconnection wait cannot be negative: {reconnect!r}")
173
+ return _subscribed(
174
+ attempt,
175
+ reconnect=reconnect,
176
+ minimum_reconnect=minimum_reconnect,
177
+ maximum_reconnect=maximum_reconnect,
178
+ max_event_size=max_event_size,
179
+ sleep=sleep,
180
+ )
181
+
182
+
183
+ async def _subscribed(
184
+ attempt: Callable[[RawHeaders], Awaitable[ClientResponse]],
185
+ *,
186
+ reconnect: timedelta,
187
+ minimum_reconnect: timedelta,
188
+ maximum_reconnect: timedelta,
189
+ max_event_size: int | None,
190
+ sleep: Callable[[timedelta], Awaitable[None]],
191
+ ) -> AsyncGenerator[ReceivedEvent]:
192
+ last_id = ""
193
+ wait = reconnect
194
+ established = False
195
+ # What every attempt offers. `last-event-id` joins it once the stream has a
196
+ # resumption point, and the caller decides how these meet the request's own headers.
197
+ offered: RawHeaders = ((b"accept", EVENT_STREAM_MEDIA_TYPE),)
198
+
199
+ while True:
200
+ headers = (*offered, (b"last-event-id", last_id.encode())) if last_id else offered
201
+ try:
202
+ head, body = await attempt(headers)
203
+ except OSError, HTTPTimeout:
204
+ if not established:
205
+ raise
206
+ await sleep(wait)
207
+ continue
208
+
209
+ if head.status != 200 or not _is_event_stream(head):
210
+ await body.aclose()
211
+ raise NotAnEventStream(
212
+ f"expected a 200 text/event-stream response, got {head.status} "
213
+ f"{(first(head.headers, b'content-type') or b'with no content-type').decode('latin-1')}"
214
+ )
215
+ established = True
216
+
217
+ # Each connection parses from the id it resumed with, so an event carrying no
218
+ # `id:` of its own reports that rather than nothing: the spec seeds a reconnected
219
+ # parser's last event ID buffer for exactly this reason, and a parser starting
220
+ # from empty would erase the resumption point the next reconnect needs.
221
+ items = parse_events_with_directives(body, last_event_id=last_id, max_event_size=max_event_size)
222
+ try:
223
+ async for item in items:
224
+ match item:
225
+ case ReceivedEvent():
226
+ last_id = item.id
227
+ yield item
228
+ case Retry(after):
229
+ wait = min(max(after.duration, minimum_reconnect), maximum_reconnect)
230
+ case Checkpoint(moved_to):
231
+ # A frame that moved the resumption point without delivering an
232
+ # event. Missing these resumes from before whatever the producer
233
+ # skipped, which is the whole reason it sent one.
234
+ last_id = moved_to
235
+ case _ as unreachable:
236
+ assert_never(unreachable)
237
+ except OSError, HTTPTimeout:
238
+ # A dropped stream is the ordinary end of a long-lived connection, not a
239
+ # fault to surface: that is what the resumption token is for.
240
+ pass
241
+ finally:
242
+ # The parser as well as the body, because a caller closing the subscription
243
+ # stops this generator at the `yield` above and leaves both of them open.
244
+ await close_stream(items)
245
+ await body.aclose()
246
+
247
+ await sleep(wait)
@@ -16,7 +16,6 @@ from typing import assert_never
16
16
  from urllib.parse import unquote
17
17
  from urllib.parse import urlsplit
18
18
 
19
- from without import cancel_futures
20
19
  from without_asgi import Asgi
21
20
  from without_asgi import ASGIApp
22
21
  from without_asgi import Disconnect
@@ -35,6 +34,7 @@ from without_asgi import encode_inbound
35
34
  from without_asgi import parse_outbound
36
35
  from without_asgi.outbound import ResponseBody as OutboundBody
37
36
  from without_asgi.outbound import ResponseTrailers as OutboundTrailers
37
+ from without_async import cancel_futures
38
38
 
39
39
  from without_http.client import _NO_TIMEOUT
40
40
  from without_http.client import Client
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
6
6
  from dataclasses import dataclass
7
7
  from datetime import timedelta
8
8
 
9
- from without import timeout
9
+ from without_async import timeout
10
10
 
11
11
 
12
12
  @dataclass(frozen=True, slots=True)