Skip to content

Commit 3ea45ad

Browse files
committed
feat: Provide HTTP headers via Fault event
1 parent 23942dd commit 3ea45ad

File tree

5 files changed

+184
-5
lines changed

5 files changed

+184
-5
lines changed

ld_eventsource/actions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22
from typing import Any, Dict, Optional
33

4+
from ld_eventsource.errors import ExceptionWithHeaders
5+
46

57
class Action:
68
"""
@@ -144,6 +146,9 @@ class Fault(Action):
144146
connection attempt has failed or an existing connection has been closed. The SSEClient
145147
will attempt to reconnect if you either call :meth:`.SSEClient.start()`
146148
or simply continue reading events after this point.
149+
150+
When the error includes HTTP response headers (such as for :class:`.HTTPStatusError`
151+
or :class:`.HTTPContentTypeError`), they are accessible via the :attr:`headers` property.
147152
"""
148153

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

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: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,19 @@ 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)
112-
response_headers = cast(Dict[str, Any], resp.headers)
113116

114117
def close():
115118
try:

ld_eventsource/testing/test_headers.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,68 @@ def test_connection_result_no_headers():
148148
"""Test that ConnectionResult returns None when no headers provided"""
149149
result = ConnectionResult(stream=iter([b'data']), closer=None)
150150
assert result.headers is None
151+
152+
153+
def test_http_status_error_includes_headers():
154+
"""Test that HTTPStatusError can store and expose headers"""
155+
headers = {'Retry-After': '120', 'X-RateLimit-Remaining': '0'}
156+
error = HTTPStatusError(429, headers)
157+
assert error.status == 429
158+
assert error.headers == headers
159+
assert error.headers.get('Retry-After') == '120'
160+
161+
162+
def test_http_content_type_error_includes_headers():
163+
"""Test that HTTPContentTypeError can store and expose headers"""
164+
headers = {'Content-Type': 'text/plain', 'X-Custom': 'value'}
165+
error = HTTPContentTypeError('text/plain', headers)
166+
assert error.content_type == 'text/plain'
167+
assert error.headers == headers
168+
assert error.headers.get('X-Custom') == 'value'
169+
170+
171+
def test_fault_exposes_headers_from_http_status_error():
172+
"""Test that Fault.headers delegates to HTTPStatusError.headers"""
173+
headers = {'Retry-After': '60'}
174+
error = HTTPStatusError(503, headers)
175+
fault = Fault(error)
176+
177+
assert fault.error == error
178+
assert fault.headers == headers
179+
assert fault.headers.get('Retry-After') == '60'
180+
181+
182+
def test_fault_exposes_headers_from_http_content_type_error():
183+
"""Test that Fault.headers delegates to HTTPContentTypeError.headers"""
184+
headers = {'Content-Type': 'application/json'}
185+
error = HTTPContentTypeError('application/json', headers)
186+
fault = Fault(error)
187+
188+
assert fault.error == error
189+
assert fault.headers == headers
190+
191+
192+
def test_fault_headers_none_for_non_http_errors():
193+
"""Test that Fault.headers returns None for errors without headers"""
194+
error = RuntimeError("some error")
195+
fault = Fault(error)
196+
197+
assert fault.error == error
198+
assert fault.headers is None
199+
200+
201+
def test_fault_headers_none_when_no_error():
202+
"""Test that Fault.headers returns None when there's no error"""
203+
fault = Fault(None)
204+
205+
assert fault.error is None
206+
assert fault.headers is None
207+
208+
209+
def test_fault_headers_none_when_exception_has_no_headers():
210+
"""Test that Fault.headers returns None when exception doesn't provide headers"""
211+
error = HTTPStatusError(500) # No headers provided
212+
fault = Fault(error)
213+
214+
assert fault.error == error
215+
assert fault.headers is None

ld_eventsource/testing/test_http_connect_strategy.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,60 @@ def test_http_response_headers_in_sse_client():
182182

183183
# Second item should be the event
184184
assert isinstance(all_items[1], Event)
185+
186+
187+
def test_http_status_error_includes_headers():
188+
"""Test that HTTPStatusError captures response headers"""
189+
with start_server() as server:
190+
server.for_path('/', BasicResponse(429, None, {
191+
'Retry-After': '120',
192+
'X-RateLimit-Remaining': '0',
193+
'X-RateLimit-Reset': '1234567890'
194+
}))
195+
try:
196+
with ConnectStrategy.http(server.uri).create_client(logger()) as client:
197+
client.connect(None)
198+
raise Exception("expected exception, did not get one")
199+
except HTTPStatusError as e:
200+
assert e.status == 429
201+
assert e.headers is not None
202+
assert e.headers.get('Retry-After') == '120'
203+
assert e.headers.get('X-RateLimit-Remaining') == '0'
204+
assert e.headers.get('X-RateLimit-Reset') == '1234567890'
205+
206+
207+
def test_http_content_type_error_includes_headers():
208+
"""Test that HTTPContentTypeError captures response headers"""
209+
with start_server() as server:
210+
with ChunkedResponse({'Content-Type': 'application/json', 'X-Custom': 'value'}) as stream:
211+
server.for_path('/', stream)
212+
try:
213+
with ConnectStrategy.http(server.uri).create_client(logger()) as client:
214+
client.connect(None)
215+
raise Exception("expected exception, did not get one")
216+
except HTTPContentTypeError as e:
217+
assert e.content_type == "application/json"
218+
assert e.headers is not None
219+
assert e.headers.get('Content-Type') == 'application/json'
220+
assert e.headers.get('X-Custom') == 'value'
221+
222+
223+
def test_fault_exposes_headers_from_http_error():
224+
"""Test that Fault.headers exposes headers from HTTP errors"""
225+
with start_server() as server:
226+
server.for_path('/', BasicResponse(503, None, {
227+
'Retry-After': '60',
228+
'X-Error-Code': 'SERVICE_UNAVAILABLE'
229+
}))
230+
with SSEClient(
231+
connect=ConnectStrategy.http(server.uri),
232+
error_strategy=ErrorStrategy.always_continue(),
233+
retry_delay_strategy=no_delay()
234+
) as client:
235+
# Read first item which should be a Fault with the error
236+
fault = next(client.all)
237+
assert isinstance(fault, Fault)
238+
assert isinstance(fault.error, HTTPStatusError)
239+
assert fault.headers is not None
240+
assert fault.headers.get('Retry-After') == '60'
241+
assert fault.headers.get('X-Error-Code') == 'SERVICE_UNAVAILABLE'

0 commit comments

Comments
 (0)