Skip to content

Commit 8bd701b

Browse files
Merge pull request #98 from randileeharper/fix/historian-sink-exceptions-break-operations
Catch all Historian sink exceptions in EventEmitter.emit (#90)
2 parents 2fd37b4 + 0eb80bb commit 8bd701b

2 files changed

Lines changed: 86 additions & 15 deletions

File tree

tests/test_historian.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import logging
45
from contextlib import contextmanager
56
from pathlib import Path
67
from typing import Any
@@ -297,15 +298,24 @@ def close(self):
297298
assert "Historian delivery failed" in caplog.text
298299

299300

300-
def test_unexpected_sink_error_propagates_instead_of_being_swallowed(
301-
settings: Settings, tmp_path: Path
301+
def test_unexpected_sink_error_is_logged_and_does_not_break_operations(
302+
settings: Settings, tmp_path: Path, caplog
302303
) -> None:
303304
"""An unexpected (non-HistorianDeliveryError) failure from the historian
304-
sink must propagate rather than be swallowed by the best-effort handler.
305-
306-
Regression guard for issue #47: the bare ``except Exception`` in
307-
``CiderAgentService._emit`` used to mask any sink failure as a routine
308-
delivery problem. It now narrows to ``HistorianDeliveryError``.
305+
sink must not fail the surrounding operation.
306+
307+
Historian delivery is best-effort (issue #90): any exception from the sink
308+
-- whether ``HistorianDeliveryError``, an ``httpx.HTTPStatusError`` on a 4xx
309+
response, or a genuinely unexpected error from a corrupted sink -- is caught
310+
in ``EventEmitter.emit``, logged, and never allowed to propagate out of the
311+
call path. ``HistorianDeliveryError`` is logged at warning level; anything
312+
else is logged at error level with a traceback so genuine sink bugs remain
313+
visible and actionable.
314+
315+
This supersedes the issue #47 regression guard, which asserted that
316+
unexpected sink errors propagated. #90 makes the best-effort contract a hard
317+
boundary so a Historian outage or bug can never break a user-facing music
318+
operation.
309319
"""
310320

311321
class PoisonedSink:
@@ -320,8 +330,55 @@ def close(self):
320330

321331
service = _service(settings, PoisonedSink(), tmp_path)
322332

323-
with pytest.raises(RuntimeError, match="sink is corrupted"):
324-
service.pause()
333+
with caplog.at_level(logging.ERROR, logger="vesper.events"):
334+
result = service.pause()
335+
336+
# The user-facing operation succeeds despite the sink failure.
337+
assert result["status"] == "ok"
338+
# The unexpected sink error was logged at error level with a traceback.
339+
error_records = [
340+
record for record in caplog.records
341+
if record.levelno == logging.ERROR and "Unexpected Historian sink error" in record.message
342+
]
343+
assert error_records, "expected an error-level log for the unexpected sink failure"
344+
assert "sink is corrupted" in error_records[0].message
345+
assert error_records[0].exc_info is not None
346+
assert error_records[0].exc_info[0] is RuntimeError
347+
348+
349+
def test_http_status_error_from_sink_does_not_break_operations(
350+
settings: Settings, tmp_path: Path, caplog
351+
) -> None:
352+
"""A non-delivery exception the real HttpHistorianSink can raise -- here an
353+
``httpx.HTTPStatusError`` from a 4xx response -- must not escape
354+
``EventEmitter.emit`` and fail the user-facing operation (issue #90).
355+
356+
``HttpHistorianSink._request`` re-raises ``httpx.HTTPStatusError`` for 4xx
357+
responses rather than wrapping it in ``HistorianDeliveryError``. Before #90
358+
that propagated out of ``emit``; it is now caught and logged at error level.
359+
"""
360+
361+
class HttpStatusErrorSink:
362+
def emit(self, event):
363+
raise httpx.HTTPStatusError(
364+
"Historian returned HTTP 403",
365+
request=httpx.Request("POST", "https://historian.test/v1/events"),
366+
response=httpx.Response(403, request=httpx.Request("POST", "https://historian.test/v1/events")),
367+
)
368+
369+
def emit_batch(self, events):
370+
self.emit(events[0] if events else {"id": "noop"})
371+
372+
def close(self):
373+
return None
374+
375+
service = _service(settings, HttpStatusErrorSink(), tmp_path)
376+
377+
with caplog.at_level(logging.ERROR, logger="vesper.events"):
378+
result = service.pause()
379+
380+
assert result["status"] == "ok"
381+
assert "Unexpected Historian sink error" in caplog.text
325382

326383

327384
def test_rpc_failures_emit_sanitized_event(settings: Settings, tmp_path: Path) -> None:

vesper/events.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
import logging
1415
from typing import Any, Protocol
1516

1617
from .historian import (
@@ -22,6 +23,8 @@
2223
operation_context,
2324
)
2425

26+
_log = logging.getLogger(__name__)
27+
2528

2629
class EventHost(Protocol):
2730
"""Structural interface for the cross-cutting capabilities
@@ -88,17 +91,28 @@ def emit(
8891
self._historian.emit(event)
8992
except HistorianDeliveryError as exc:
9093
# Historian delivery is best-effort: a failed delivery must never
91-
# fail the surrounding operation. Only the documented delivery
92-
# failure is swallowed (with a warning); unexpected sink failures
93-
# propagate so they remain visible and actionable.
94-
import logging
95-
96-
logging.getLogger(__name__).warning(
94+
# fail the surrounding operation. The documented delivery failure
95+
# is swallowed with a warning.
96+
_log.warning(
9797
"Historian delivery failed for event_id=%s type=%s: %s",
9898
event["id"],
9999
event_type,
100100
exc,
101101
)
102+
except Exception as exc:
103+
# Any other exception from the sink (e.g. httpx.HTTPStatusError on a
104+
# 4xx response, httpx.RequestError on a network failure) is also
105+
# best-effort and must not fail the surrounding operation (issue
106+
# #90). It is logged at error level with a traceback so genuine sink
107+
# bugs remain visible and actionable rather than propagating out of
108+
# the call path and breaking a user-facing music operation.
109+
_log.error(
110+
"Unexpected Historian sink error for event_id=%s type=%s: %s",
111+
event["id"],
112+
event_type,
113+
exc,
114+
exc_info=True,
115+
)
102116
return str(event["id"])
103117

104118
def _sanitize_event_data(self, value: Any) -> Any:

0 commit comments

Comments
 (0)