Skip to content

Commit 54a485c

Browse files
authored
fix(telemetry): don't mark QueueShutDown as an error span (#1075)
# Description ## Summary `trace_function`'s async wrapper marks any exception other than `CancelledError` as `StatusCode.ERROR`. This wrongly flags `QueueShutDown`, the normal signal raised when a producer cleanly closes an event queue, as a failure. ## Change - Add `QueueShutDown` to the same graceful-exception allowlist as`CancelledError` in `src/a2a/utils/telemetry.py`. The class is resolved per Python version (stdlib on 3.13+, `culsans` back-port on <3.13), mirroring `event_queue.py`. - Add parametrized a test covering both `CancelledError` and `QueueShutDown`. Fixes #1065🦕
1 parent fe1f24b commit 54a485c

6 files changed

Lines changed: 82 additions & 32 deletions

File tree

src/a2a/server/agent_execution/active_task.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
Event,
6060
EventQueueSource,
6161
QueueShutDown,
62-
_create_async_queue,
62+
create_async_queue,
6363
)
6464
from a2a.server.tasks import PushNotificationEvent
6565
from a2a.types.a2a_pb2 import (
@@ -402,7 +402,7 @@ def __init__(
402402

403403
# Queue for incoming requests
404404
self._request_queue: AsyncQueue[tuple[RequestContext, uuid.UUID]] = (
405-
_create_async_queue()
405+
create_async_queue()
406406
)
407407

408408
@property

src/a2a/server/events/event_queue.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
import logging
3-
import sys
43
import warnings
54

65
from abc import ABC, abstractmethod
@@ -9,33 +8,17 @@
98

109
from typing_extensions import Self
1110

12-
13-
if sys.version_info >= (3, 13):
14-
from asyncio import Queue as AsyncQueue
15-
from asyncio import QueueShutDown
16-
17-
def _create_async_queue(maxsize: int = 0) -> AsyncQueue[Any]:
18-
"""Create a backwards-compatible queue object."""
19-
return AsyncQueue(maxsize=maxsize)
20-
else:
21-
import culsans
22-
23-
from culsans import AsyncQueue # type: ignore[no-redef]
24-
from culsans import (
25-
AsyncQueueShutDown as QueueShutDown, # type: ignore[no-redef]
26-
)
27-
28-
def _create_async_queue(maxsize: int = 0) -> AsyncQueue[Any]:
29-
"""Create a backwards-compatible queue object."""
30-
return culsans.Queue(maxsize=maxsize).async_q # type: ignore[no-any-return]
31-
32-
3311
from a2a.types.a2a_pb2 import (
3412
Message,
3513
Task,
3614
TaskArtifactUpdateEvent,
3715
TaskStatusUpdateEvent,
3816
)
17+
from a2a.utils._async_queue_compat import (
18+
AsyncQueue,
19+
QueueShutDown,
20+
create_async_queue,
21+
)
3922
from a2a.utils.telemetry import SpanKind, trace_class
4023

4124

@@ -101,7 +84,7 @@ def __init__(self, max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE) -> None:
10184
if max_queue_size <= 0:
10285
raise ValueError('max_queue_size must be greater than 0')
10386

104-
self._queue: AsyncQueue[Event] = _create_async_queue(
87+
self._queue: AsyncQueue[Event] = create_async_queue(
10588
maxsize=max_queue_size
10689
)
10790
self._children: list[EventQueueLegacy] = []

src/a2a/server/events/event_queue_v2.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
Event,
1313
EventQueue,
1414
QueueShutDown,
15-
_create_async_queue,
15+
create_async_queue,
1616
)
1717
from a2a.utils.telemetry import SpanKind, trace_class
1818

@@ -37,7 +37,7 @@ def __init__(
3737
if max_queue_size <= 0:
3838
raise ValueError('max_queue_size must be greater than 0')
3939

40-
self._incoming_queue: AsyncQueue[Event] = _create_async_queue(
40+
self._incoming_queue: AsyncQueue[Event] = create_async_queue(
4141
maxsize=max_queue_size
4242
)
4343
self._lock = asyncio.Lock()
@@ -293,7 +293,7 @@ def __init__(
293293
raise ValueError('max_queue_size must be greater than 0')
294294

295295
self._parent = parent
296-
self._queue: AsyncQueue[Event] = _create_async_queue(
296+
self._queue: AsyncQueue[Event] = create_async_queue(
297297
maxsize=max_queue_size
298298
)
299299
self._is_closed = False
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Cross-version aliases for async queue primitives."""
2+
3+
import sys
4+
5+
from typing import Any
6+
7+
8+
if sys.version_info >= (3, 13):
9+
from asyncio import Queue as AsyncQueue
10+
from asyncio import QueueShutDown
11+
12+
def create_async_queue(maxsize: int = 0) -> AsyncQueue[Any]:
13+
"""Create a backwards-compatible async queue object."""
14+
return AsyncQueue(maxsize=maxsize)
15+
else:
16+
import culsans
17+
18+
from culsans import AsyncQueue # type: ignore[no-redef]
19+
from culsans import (
20+
AsyncQueueShutDown as QueueShutDown, # type: ignore[no-redef]
21+
)
22+
23+
def create_async_queue(maxsize: int = 0) -> AsyncQueue[Any]:
24+
"""Create a backwards-compatible async queue object."""
25+
return culsans.Queue(maxsize=maxsize).async_q # type: ignore[no-any-return]
26+
27+
28+
__all__ = ['AsyncQueue', 'QueueShutDown', 'create_async_queue']

src/a2a/utils/telemetry.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ def internal_method(self):
7474

7575
from typing_extensions import Self
7676

77+
from a2a.utils._async_queue_compat import QueueShutDown
78+
7779

7880
if TYPE_CHECKING:
7981
from opentelemetry.trace import (
@@ -144,6 +146,12 @@ def __getattr__(self, name: str) -> Any:
144146
__all__ = ['SpanKind']
145147

146148

149+
_NON_ERROR_EXCEPTIONS: tuple[type[BaseException], ...] = (
150+
asyncio.CancelledError,
151+
QueueShutDown,
152+
)
153+
154+
147155
def trace_function( # noqa: PLR0915
148156
func: Callable | None = None,
149157
*,
@@ -233,11 +241,14 @@ async def async_wrapper(*args, **kwargs) -> Any:
233241
# Async wrapper, await for the function call to complete.
234242
result = await func(*args, **kwargs)
235243
span.set_status(StatusCode.OK)
236-
# asyncio.CancelledError extends from BaseException
237-
except asyncio.CancelledError as ce:
244+
except _NON_ERROR_EXCEPTIONS as ge:
238245
exception = None
239-
logger.debug('CancelledError in span %s', actual_span_name)
240-
span.record_exception(ce)
246+
logger.debug(
247+
'%s in span %s',
248+
type(ge).__name__,
249+
actual_span_name,
250+
)
251+
span.record_exception(ge)
241252
raise
242253
except Exception as e:
243254
exception = e

tests/utils/test_telemetry.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pytest
1010

11+
from a2a.server.events.event_queue import QueueShutDown
1112
from a2a.utils.telemetry import trace_class, trace_function
1213

1314

@@ -266,3 +267,30 @@ def test_env_var_disabled_logs_message(
266267
in caplog.text
267268
)
268269
assert 'OTEL_INSTRUMENTATION_A2A_SDK_ENABLED' in caplog.text
270+
271+
272+
@pytest.mark.asyncio
273+
@pytest.mark.parametrize('exc_cls', [asyncio.CancelledError, QueueShutDown])
274+
async def test_trace_function_async_non_error_exception_does_not_mark_span_error(
275+
mock_span: mock.MagicMock,
276+
exc_cls: type[BaseException],
277+
) -> None:
278+
"""`trace_function` records non-error exceptions but never marks span ERROR.
279+
280+
Covers `asyncio.CancelledError` and `QueueShutDown`.
281+
"""
282+
283+
@trace_function
284+
async def non_error_exception() -> NoReturn:
285+
await asyncio.sleep(0)
286+
raise exc_cls('operation ended with non-error exception')
287+
288+
with pytest.raises(exc_cls):
289+
await non_error_exception()
290+
291+
mock_span.record_exception.assert_called()
292+
# The wrapper only passes `description=` when calling
293+
# `set_status(StatusCode.ERROR, ...)`. Its absence on every call proves
294+
# the span was never marked as failed.
295+
for call in mock_span.set_status.call_args_list:
296+
assert 'description' not in call.kwargs

0 commit comments

Comments
 (0)