Skip to content

Commit b83f356

Browse files
authored
feat: Provide access to HTTP headers on success or error (#53)
1 parent 7f0fc62 commit b83f356

File tree

8 files changed

+438
-21
lines changed

8 files changed

+438
-21
lines changed

ld_eventsource/actions.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
2-
from typing import Optional
2+
from typing import Any, Dict, Optional
3+
4+
from ld_eventsource.errors import ExceptionWithHeaders
35

46

57
class Action:
@@ -110,9 +112,25 @@ class Start(Action):
110112
Instances of this class are only available from :attr:`.SSEClient.all`.
111113
A ``Start`` is returned for the first successful connection. If the client reconnects
112114
after a failure, there will be a :class:`.Fault` followed by a ``Start``.
115+
116+
Each ``Start`` action may include HTTP response headers from the connection. These headers
117+
are available via the :attr:`headers` property. On reconnection, a new ``Start`` will be
118+
emitted with the headers from the new connection, which may differ from the previous one.
113119
"""
114120

115-
pass
121+
def __init__(self, headers: Optional[Dict[str, Any]] = None):
122+
self._headers = headers
123+
124+
@property
125+
def headers(self) -> Optional[Dict[str, Any]]:
126+
"""
127+
The HTTP response headers from the stream connection, if available.
128+
129+
The headers dict uses case-insensitive keys (via urllib3's HTTPHeaderDict).
130+
131+
:return: the response headers, or ``None`` if not available
132+
"""
133+
return self._headers
116134

117135

118136
class Fault(Action):
@@ -125,6 +143,9 @@ class Fault(Action):
125143
connection attempt has failed or an existing connection has been closed. The SSEClient
126144
will attempt to reconnect if you either call :meth:`.SSEClient.start()`
127145
or simply continue reading events after this point.
146+
147+
When the error includes HTTP response headers (such as for :class:`.HTTPStatusError`
148+
or :class:`.HTTPContentTypeError`), they are accessible via the :attr:`headers` property.
128149
"""
129150

130151
def __init__(self, error: Optional[Exception]):
@@ -138,3 +159,18 @@ def error(self) -> Optional[Exception]:
138159
in an orderly way after sending an EOF chunk as defined by chunked transfer encoding.
139160
"""
140161
return self.__error
162+
163+
@property
164+
def headers(self) -> Optional[Dict[str, Any]]:
165+
"""
166+
The HTTP response headers from the failed connection, if available.
167+
168+
This property returns headers when the error is an exception that includes them,
169+
such as :class:`.HTTPStatusError` or :class:`.HTTPContentTypeError`. For other
170+
error types or when the stream ended normally, this returns ``None``.
171+
172+
:return: the response headers, or ``None`` if not available
173+
"""
174+
if isinstance(self.__error, ExceptionWithHeaders):
175+
return self.__error.headers
176+
return None

ld_eventsource/config/connect_strategy.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
from logging import Logger
5-
from typing import Callable, Iterator, Optional, Union
5+
from typing import Any, Callable, Dict, Iterator, Optional, Union
66

77
from urllib3 import PoolManager
88

@@ -96,9 +96,10 @@ class ConnectionResult:
9696
The return type of :meth:`ConnectionClient.connect()`.
9797
"""
9898

99-
def __init__(self, stream: Iterator[bytes], closer: Optional[Callable]):
99+
def __init__(self, stream: Iterator[bytes], closer: Optional[Callable], headers: Optional[Dict[str, Any]] = None):
100100
self.__stream = stream
101101
self.__closer = closer
102+
self.__headers = headers
102103

103104
@property
104105
def stream(self) -> Iterator[bytes]:
@@ -107,6 +108,18 @@ def stream(self) -> Iterator[bytes]:
107108
"""
108109
return self.__stream
109110

111+
@property
112+
def headers(self) -> Optional[Dict[str, Any]]:
113+
"""
114+
The HTTP response headers, if available.
115+
116+
For HTTP connections, this contains the headers from the SSE stream response.
117+
For non-HTTP connections, this will be ``None``.
118+
119+
The headers dict uses case-insensitive keys (via urllib3's HTTPHeaderDict).
120+
"""
121+
return self.__headers
122+
110123
def close(self):
111124
"""
112125
Does whatever is necessary to release the connection.
@@ -139,8 +152,8 @@ def __init__(self, params: _HttpConnectParams, logger: Logger):
139152
self.__impl = _HttpClientImpl(params, logger)
140153

141154
def connect(self, last_event_id: Optional[str]) -> ConnectionResult:
142-
stream, closer = self.__impl.connect(last_event_id)
143-
return ConnectionResult(stream, closer)
155+
stream, closer, headers = self.__impl.connect(last_event_id)
156+
return ConnectionResult(stream, closer, headers)
144157

145158
def close(self):
146159
self.__impl.close()

ld_eventsource/errors.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,62 @@
1+
from typing import Any, Dict, Optional, Protocol, runtime_checkable
2+
3+
4+
@runtime_checkable
5+
class ExceptionWithHeaders(Protocol):
6+
"""
7+
Protocol for exceptions that include HTTP response headers.
8+
9+
This allows type-safe access to headers from error responses without
10+
using hasattr checks.
11+
"""
12+
13+
@property
14+
def headers(self) -> Optional[Dict[str, Any]]:
15+
"""The HTTP response headers associated with this exception."""
16+
raise NotImplementedError
17+
18+
119
class HTTPStatusError(Exception):
220
"""
321
This exception indicates that the client was able to connect to the server, but that
422
the HTTP response had an error status.
23+
24+
When available, the response headers are accessible via the :attr:`headers` property.
525
"""
626

7-
def __init__(self, status: int):
27+
def __init__(self, status: int, headers: Optional[Dict[str, Any]] = None):
828
super().__init__("HTTP error %d" % status)
929
self._status = status
30+
self._headers = headers
1031

1132
@property
1233
def status(self) -> int:
1334
return self._status
1435

36+
@property
37+
def headers(self) -> Optional[Dict[str, Any]]:
38+
"""The HTTP response headers, if available."""
39+
return self._headers
40+
1541

1642
class HTTPContentTypeError(Exception):
1743
"""
1844
This exception indicates that the HTTP response did not have the expected content
1945
type of `"text/event-stream"`.
46+
47+
When available, the response headers are accessible via the :attr:`headers` property.
2048
"""
2149

22-
def __init__(self, content_type: str):
50+
def __init__(self, content_type: str, headers: Optional[Dict[str, Any]] = None):
2351
super().__init__("invalid content type \"%s\"" % content_type)
2452
self._content_type = content_type
53+
self._headers = headers
2554

2655
@property
2756
def content_type(self) -> str:
2857
return self._content_type
58+
59+
@property
60+
def headers(self) -> Optional[Dict[str, Any]]:
61+
"""The HTTP response headers, if available."""
62+
return self._headers

ld_eventsource/http.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from logging import Logger
2-
from typing import Callable, Iterator, Optional, Tuple
2+
from typing import Any, Callable, Dict, Iterator, Optional, Tuple, cast
33
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
44

55
from urllib3 import PoolManager
@@ -60,7 +60,7 @@ def __init__(self, params: _HttpConnectParams, logger: Logger):
6060
self.__should_close_pool = params.pool is not None
6161
self.__logger = logger
6262

63-
def connect(self, last_event_id: Optional[str]) -> Tuple[Iterator[bytes], Callable]:
63+
def connect(self, last_event_id: Optional[str]) -> Tuple[Iterator[bytes], Callable, Dict[str, Any]]:
6464
url = self.__params.url
6565
if self.__params.query_params is not None:
6666
qp = self.__params.query_params()
@@ -100,13 +100,17 @@ def connect(self, last_event_id: Optional[str]) -> Tuple[Iterator[bytes], Callab
100100
reason: Optional[Exception] = e.reason
101101
if reason is not None:
102102
raise reason # e.reason is the underlying I/O error
103+
104+
# Capture headers early so they're available for both error and success cases
105+
response_headers = cast(Dict[str, Any], resp.headers)
106+
103107
if resp.status >= 400 or resp.status == 204:
104-
raise HTTPStatusError(resp.status)
108+
raise HTTPStatusError(resp.status, response_headers)
105109
content_type = resp.headers.get('Content-Type', None)
106110
if content_type is None or not str(content_type).startswith(
107111
"text/event-stream"
108112
):
109-
raise HTTPContentTypeError(content_type or '')
113+
raise HTTPContentTypeError(content_type or '', response_headers)
110114

111115
stream = resp.stream(_CHUNK_SIZE)
112116

@@ -117,7 +121,7 @@ def close():
117121
pass
118122
resp.release_conn()
119123

120-
return stream, close
124+
return stream, close, response_headers
121125

122126
def close(self):
123127
if self.__should_close_pool:

ld_eventsource/sse_client.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ class SSEClient:
3939
:meth:`.RetryDelayStrategy.default()`, this delay will double with each subsequent retry,
4040
and will also have a pseudo-random jitter subtracted. You can customize this behavior with
4141
``retry_delay_strategy``.
42+
43+
**HTTP Response Headers:**
44+
When using HTTP-based connections, the response headers from each connection are available
45+
via the :attr:`.Start.headers` property when reading from :attr:`all`. Each time the client
46+
connects or reconnects, a :class:`.Start` action is emitted containing the headers from that
47+
specific connection. This allows you to access server metadata such as rate limits, session
48+
identifiers, or custom headers.
4249
"""
4350

4451
def __init__(
@@ -178,9 +185,10 @@ def all(self) -> Iterable[Action]:
178185
# Reading implies starting the stream if it isn't already started. We might also
179186
# be restarting since we could have been interrupted at any time.
180187
while self.__connection_result is None:
181-
fault = self._try_start(True)
188+
result = self._try_start(True)
182189
# return either a Start action or a Fault action
183-
yield Start() if fault is None else fault
190+
if result is not None:
191+
yield result
184192

185193
lines = _BufferedLineReader.lines_from(self.__connection_result.stream)
186194
reader = _SSEReader(lines, self.__last_event_id, None)
@@ -263,7 +271,7 @@ def _compute_next_retry_delay(self):
263271
self.__current_retry_delay_strategy.apply(self.__base_retry_delay)
264272
)
265273

266-
def _try_start(self, can_return_fault: bool) -> Optional[Fault]:
274+
def _try_start(self, can_return_fault: bool) -> Union[None, Start, Fault]:
267275
if self.__connection_result is not None:
268276
return None
269277
while True:
@@ -297,7 +305,7 @@ def _try_start(self, can_return_fault: bool) -> Optional[Fault]:
297305
self._retry_reset_baseline = time.time()
298306
self.__current_error_strategy = self.__base_error_strategy
299307
self.__interrupted = False
300-
return None
308+
return Start(self.__connection_result.headers)
301309

302310
@property
303311
def last_event_id(self) -> Optional[str]:

ld_eventsource/testing/helpers.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,17 @@ def apply(self) -> ConnectionResult:
6666

6767

6868
class RespondWithStream(MockConnectionHandler):
69-
def __init__(self, stream: Iterable[bytes]):
69+
def __init__(self, stream: Iterable[bytes], headers: Optional[dict] = None):
7070
self.__stream = stream
71+
self.__headers = headers
7172

7273
def apply(self) -> ConnectionResult:
73-
return ConnectionResult(stream=self.__stream.__iter__(), closer=None)
74+
return ConnectionResult(stream=self.__stream.__iter__(), closer=None, headers=self.__headers)
7475

7576

7677
class RespondWithData(RespondWithStream):
77-
def __init__(self, data: str):
78-
super().__init__([bytes(data, 'utf-8')])
78+
def __init__(self, data: str, headers: Optional[dict] = None):
79+
super().__init__([bytes(data, 'utf-8')], headers)
7980

8081

8182
class ExpectNoMoreRequests(MockConnectionHandler):

0 commit comments

Comments
 (0)