Skip to content

Commit d36f164

Browse files
committed
Add tests for span fail_open behavior
Tests cover both sync and async variants: setup failures, teardown failures, body exception propagation, and the critical property that fail_open never swallows exceptions from the caller's code.
1 parent e680039 commit d36f164

File tree

3 files changed

+192
-0
lines changed

3 files changed

+192
-0
lines changed

tests/lib/core/__init__.py

Whitespace-only changes.

tests/lib/core/tracing/__init__.py

Whitespace-only changes.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
from unittest.mock import Mock, AsyncMock
5+
6+
import pytest
7+
8+
from agentex.lib.core.tracing.trace import Trace, AsyncTrace
9+
10+
11+
class FakeSyncProcessor:
12+
def __init__(self, *, fail_on_start: bool = False, fail_on_end: bool = False):
13+
self.fail_on_start = fail_on_start
14+
self.fail_on_end = fail_on_end
15+
self.started: list[Any] = []
16+
self.ended: list[Any] = []
17+
18+
def on_span_start(self, span: Any) -> None:
19+
self.started.append(span)
20+
if self.fail_on_start:
21+
raise ConnectionError("tracing backend unavailable")
22+
23+
def on_span_end(self, span: Any) -> None:
24+
self.ended.append(span)
25+
if self.fail_on_end:
26+
raise ConnectionError("tracing backend unavailable")
27+
28+
29+
class FakeAsyncProcessor:
30+
def __init__(self, *, fail_on_start: bool = False, fail_on_end: bool = False):
31+
self.fail_on_start = fail_on_start
32+
self.fail_on_end = fail_on_end
33+
self.started: list[Any] = []
34+
self.ended: list[Any] = []
35+
36+
async def on_span_start(self, span: Any) -> None:
37+
self.started.append(span)
38+
if self.fail_on_start:
39+
raise ConnectionError("tracing backend unavailable")
40+
41+
async def on_span_end(self, span: Any) -> None:
42+
self.ended.append(span)
43+
if self.fail_on_end:
44+
raise ConnectionError("tracing backend unavailable")
45+
46+
47+
# ---------------------------------------------------------------------------
48+
# Sync Trace tests
49+
# ---------------------------------------------------------------------------
50+
51+
52+
class TestSyncSpanFailOpen:
53+
def _make_trace(self, processor: FakeSyncProcessor) -> Trace:
54+
return Trace(
55+
processors=[processor], # type: ignore[list-item]
56+
client=Mock(),
57+
trace_id="test-trace",
58+
)
59+
60+
def test_default_propagates_start_error(self) -> None:
61+
proc = FakeSyncProcessor(fail_on_start=True)
62+
trace = self._make_trace(proc)
63+
with pytest.raises(ConnectionError):
64+
with trace.span(name="test"):
65+
pass
66+
67+
def test_default_propagates_end_error(self) -> None:
68+
proc = FakeSyncProcessor(fail_on_end=True)
69+
trace = self._make_trace(proc)
70+
with pytest.raises(ConnectionError):
71+
with trace.span(name="test"):
72+
pass
73+
74+
def test_fail_open_suppresses_start_error(self) -> None:
75+
proc = FakeSyncProcessor(fail_on_start=True)
76+
trace = self._make_trace(proc)
77+
with trace.span(name="test", fail_open=True) as span:
78+
assert span is None
79+
80+
def test_fail_open_suppresses_end_error(self) -> None:
81+
proc = FakeSyncProcessor(fail_on_end=True)
82+
trace = self._make_trace(proc)
83+
with trace.span(name="test", fail_open=True) as span:
84+
assert span is not None
85+
86+
def test_fail_open_does_not_swallow_body_exception(self) -> None:
87+
"""The critical property: exceptions from the caller's code must propagate."""
88+
proc = FakeSyncProcessor()
89+
trace = self._make_trace(proc)
90+
with pytest.raises(ValueError, match="business logic error"):
91+
with trace.span(name="test", fail_open=True):
92+
raise ValueError("business logic error")
93+
94+
def test_fail_open_body_exception_with_end_error(self) -> None:
95+
"""Body exception propagates even when end_span also fails."""
96+
proc = FakeSyncProcessor(fail_on_end=True)
97+
trace = self._make_trace(proc)
98+
with pytest.raises(ValueError, match="business logic error"):
99+
with trace.span(name="test", fail_open=True):
100+
raise ValueError("business logic error")
101+
102+
def test_happy_path_unchanged(self) -> None:
103+
proc = FakeSyncProcessor()
104+
trace = self._make_trace(proc)
105+
with trace.span(name="test") as span:
106+
assert span is not None
107+
assert span.name == "test"
108+
assert len(proc.started) == 1
109+
assert len(proc.ended) == 1
110+
111+
def test_no_trace_id_yields_none(self) -> None:
112+
trace = Trace(processors=[], client=Mock(), trace_id=None)
113+
with trace.span(name="test", fail_open=True) as span:
114+
assert span is None
115+
116+
117+
# ---------------------------------------------------------------------------
118+
# Async Trace tests
119+
# ---------------------------------------------------------------------------
120+
121+
122+
class TestAsyncSpanFailOpen:
123+
def _make_trace(self, processor: FakeAsyncProcessor) -> AsyncTrace:
124+
return AsyncTrace(
125+
processors=[processor], # type: ignore[list-item]
126+
client=AsyncMock(),
127+
trace_id="test-trace",
128+
)
129+
130+
@pytest.mark.asyncio
131+
async def test_default_propagates_start_error(self) -> None:
132+
proc = FakeAsyncProcessor(fail_on_start=True)
133+
trace = self._make_trace(proc)
134+
with pytest.raises(ConnectionError):
135+
async with trace.span(name="test"):
136+
pass
137+
138+
@pytest.mark.asyncio
139+
async def test_default_propagates_end_error(self) -> None:
140+
proc = FakeAsyncProcessor(fail_on_end=True)
141+
trace = self._make_trace(proc)
142+
with pytest.raises(ConnectionError):
143+
async with trace.span(name="test"):
144+
pass
145+
146+
@pytest.mark.asyncio
147+
async def test_fail_open_suppresses_start_error(self) -> None:
148+
proc = FakeAsyncProcessor(fail_on_start=True)
149+
trace = self._make_trace(proc)
150+
async with trace.span(name="test", fail_open=True) as span:
151+
assert span is None
152+
153+
@pytest.mark.asyncio
154+
async def test_fail_open_suppresses_end_error(self) -> None:
155+
proc = FakeAsyncProcessor(fail_on_end=True)
156+
trace = self._make_trace(proc)
157+
async with trace.span(name="test", fail_open=True) as span:
158+
assert span is not None
159+
160+
@pytest.mark.asyncio
161+
async def test_fail_open_does_not_swallow_body_exception(self) -> None:
162+
"""The critical property: exceptions from the caller's code must propagate."""
163+
proc = FakeAsyncProcessor()
164+
trace = self._make_trace(proc)
165+
with pytest.raises(ValueError, match="business logic error"):
166+
async with trace.span(name="test", fail_open=True):
167+
raise ValueError("business logic error")
168+
169+
@pytest.mark.asyncio
170+
async def test_fail_open_body_exception_with_end_error(self) -> None:
171+
"""Body exception propagates even when end_span also fails."""
172+
proc = FakeAsyncProcessor(fail_on_end=True)
173+
trace = self._make_trace(proc)
174+
with pytest.raises(ValueError, match="business logic error"):
175+
async with trace.span(name="test", fail_open=True):
176+
raise ValueError("business logic error")
177+
178+
@pytest.mark.asyncio
179+
async def test_happy_path_unchanged(self) -> None:
180+
proc = FakeAsyncProcessor()
181+
trace = self._make_trace(proc)
182+
async with trace.span(name="test") as span:
183+
assert span is not None
184+
assert span.name == "test"
185+
assert len(proc.started) == 1
186+
assert len(proc.ended) == 1
187+
188+
@pytest.mark.asyncio
189+
async def test_no_trace_id_yields_none(self) -> None:
190+
trace = AsyncTrace(processors=[], client=AsyncMock(), trace_id=None)
191+
async with trace.span(name="test", fail_open=True) as span:
192+
assert span is None

0 commit comments

Comments
 (0)