Skip to content

Commit dccabe3

Browse files
[AgentServer] Platform headers, persistence resilience, x-request-id, logging fix (#46429)
* [AgentServer] Platform headers, x-request-id middleware, logging fixes - Added _platform_headers module centralizing all platform HTTP header name constants (x-request-id, x-platform-server, x-agent-session-id, isolation keys, traceparent, x-ms-client-request-id). - Added RequestIdMiddleware (core) that sets x-request-id response header on every HTTP response. Value resolved: OTEL trace ID > incoming header > UUID. - Error responses (4xx/5xx) with JSON error body are automatically enriched with error.additionalInfo.request_id matching the x-request-id header. - Foundry storage logging now includes traceparent in all log messages. - Fixed FoundryStorageLoggingPolicy crash on transport-level failures (DNS, connection refused) when no response is available. Transport failures logged at ERROR level; original exception propagates cleanly. - Removed x-ms-request-id from Foundry storage response logging. - Migrated all header string literals to _platform_headers constants. * feat(responses): persistence failure resilience — buffer-then-persist-then-yield Implement persistence failure resilience per spec. Terminal SSE events are now buffered, persistence attempted, and on failure the terminal is replaced with response.failed carrying error_code='storage_error'. Key changes: - _process_handler_events: buffer terminal events to state.pending_terminal - _persist_and_resolve_terminal: new method — attempts persistence, replaces terminal on failure with storage_error response.failed - _apply_storage_error_replacement: helper for storage error substitution - _finalize_stream: stripped of persistence (moved to resolve_terminal), eviction guarded by not persistence_failed - run_sync: persistence failure → _HandlerError → HTTP 500 - _run_background_non_stream: Phase 2 failure sets persistence_failed - _register_bg_execution: Phase 1 failure → standalone error event (§3.3) - ResponseExecution: added persistence_failed/persistence_exception fields - build_failed_response: added error_code parameter Tests: 8 new contract tests in test_persistence_failure.py covering all modes (streaming, sync, bg+stream, bg+non-stream) and failure scenarios. All 942 tests pass. * fix: address PR review feedback — hardening and test improvements - _request_id.py: guard scope['state'] with isinstance(MutableMapping), filter existing x-request-id header to prevent duplicates - _validation.py: fix docstrings to use camelCase 'additionalInfo', guard _enrich_error_payload against non-dict additionalInfo values - _endpoint_handler.py: wire _get_scope_request_id into handle_create error responses for consistent error body enrichment - _orchestrator.py: replace terminal event in-place (preserve seq num), evict runtime record on sync persistence failure to avoid memory leak, evict on bg+stream Phase 1 failure for §3.3 compliance, defer subject.publish for terminal events until persistence resolves - test_request_id_middleware.py: remove unused PlainTextResponse import, fix _error_plain docstring, add id1 != id2 assertion, monkeypatch _get_trace_id in fallback test for deterministic assertion - test_foundry_logging_policy.py: rename test to match actual behavior - test_persistence_failure.py: add error payload assertions (code check) * fix: resolve pylint errors — add missing docstring types and disable too-many-branches
1 parent 305c7d8 commit dccabe3

23 files changed

Lines changed: 1867 additions & 213 deletions

sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- `RequestIdMiddleware` — pure-ASGI middleware that sets an `x-request-id` response header on every response. The request ID is resolved from the OpenTelemetry trace ID, an incoming `x-request-id` header, or a generated UUID (in that priority). The resolved value is stored in ASGI scope state under the well-known key `agentserver.request_id` for use by sibling protocol packages. Automatically wired into `AgentServerHost`.
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ._config import AgentConfig
2828
from ._errors import create_error_response
2929
from ._middleware import InboundRequestLoggingMiddleware
30+
from ._request_id import RequestIdMiddleware
3031
from ._server_version import build_server_version
3132
from ._tracing import (
3233
configure_observability,
@@ -43,6 +44,7 @@
4344
"AgentConfig",
4445
"AgentServerHost",
4546
"InboundRequestLoggingMiddleware",
47+
"RequestIdMiddleware",
4648
"build_server_version",
4749
"configure_observability",
4850
"create_error_response",

sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from . import _config, _tracing
2727
from ._middleware import InboundRequestLoggingMiddleware
28+
from ._request_id import RequestIdMiddleware as _RequestIdMiddleware
2829
from ._server_version import build_server_version
2930
from ._version import VERSION as _CORE_VERSION
3031

@@ -278,6 +279,7 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF
278279
_PlatformHeaderMiddleware,
279280
get_server_version=self._build_server_version,
280281
),
282+
Middleware(_RequestIdMiddleware), # type: ignore[arg-type]
281283
],
282284
**kwargs,
283285
)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# ---------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# ---------------------------------------------------------
4+
"""Request ID middleware for Azure AI Agent Server hosts.
5+
6+
A pure-ASGI middleware that sets the ``x-request-id`` response header on
7+
every HTTP response. The value is resolved in priority order:
8+
9+
1. Current OTEL trace ID.
10+
2. Incoming ``x-request-id`` request header (client-provided correlation ID).
11+
3. A new UUID as fallback.
12+
13+
The resolved value is also stored in ``scope["state"]["agentserver.request_id"]``
14+
for use by downstream handlers (e.g., error body enrichment in protocol
15+
packages).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import uuid
21+
from typing import Any, MutableMapping
22+
23+
from starlette.types import ASGIApp, Receive, Scope, Send
24+
25+
from ._middleware import _extract_header, _get_trace_id
26+
27+
# Key used to store the resolved request ID in ASGI scope state.
28+
REQUEST_ID_STATE_KEY = "agentserver.request_id"
29+
30+
31+
class RequestIdMiddleware:
32+
"""Pure-ASGI middleware that sets ``x-request-id`` on every HTTP response.
33+
34+
The resolved request ID is stored in ``scope["state"]`` under
35+
:data:`REQUEST_ID_STATE_KEY` so that protocol-specific code (e.g. the
36+
Responses package) can read it for error body enrichment.
37+
38+
Unlike ``BaseHTTPMiddleware``, this passes the ``receive`` callable
39+
through to the inner application untouched, preserving
40+
``request.is_disconnected()`` behaviour.
41+
42+
:param app: The inner ASGI application.
43+
:type app: ASGIApp
44+
"""
45+
46+
def __init__(self, app: ASGIApp) -> None:
47+
self.app = app
48+
49+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
50+
if scope["type"] != "http":
51+
await self.app(scope, receive, send)
52+
return
53+
54+
raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
55+
request_id = _resolve_request_id(raw_headers)
56+
57+
# Store in scope state for downstream access.
58+
state = scope.get("state")
59+
if not isinstance(state, MutableMapping):
60+
state = {}
61+
scope["state"] = state
62+
state[REQUEST_ID_STATE_KEY] = request_id
63+
64+
async def _send_with_request_id(message: MutableMapping[str, Any]) -> None:
65+
if message["type"] == "http.response.start":
66+
# Filter any existing x-request-id to avoid duplicates, then add ours.
67+
headers = [
68+
(name, value)
69+
for name, value in message.get("headers", [])
70+
if name.lower() != b"x-request-id"
71+
]
72+
headers.append((b"x-request-id", request_id.encode()))
73+
message = {**message, "headers": headers}
74+
await send(message)
75+
76+
await self.app(scope, receive, _send_with_request_id)
77+
78+
79+
def _resolve_request_id(raw_headers: list[tuple[bytes, bytes]]) -> str:
80+
"""Resolve the request ID from available sources.
81+
82+
Priority:
83+
1. OTEL trace ID from current activity.
84+
2. Incoming ``x-request-id`` request header.
85+
3. New UUID fallback.
86+
87+
:param raw_headers: Raw ASGI header tuples.
88+
:type raw_headers: list[tuple[bytes, bytes]]
89+
:return: The resolved request ID string.
90+
:rtype: str
91+
"""
92+
# Priority 1: OTEL trace ID
93+
trace_id = _get_trace_id(raw_headers)
94+
if trace_id and trace_id != "0" * 32:
95+
return trace_id
96+
97+
# Priority 2: Incoming x-request-id header
98+
incoming = _extract_header(raw_headers, b"x-request-id")
99+
if incoming:
100+
return incoming
101+
102+
# Priority 3: New UUID
103+
return uuid.uuid4().hex

sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44

55
### Features Added
66

7+
- All HTTP responses now include an `x-request-id` header for request correlation, inherited from `RequestIdMiddleware` in `azure-ai-agentserver-core>=2.0.0b3`. The value is resolved from the OpenTelemetry trace ID, an incoming `x-request-id` header, or a generated UUID.
8+
79
### Breaking Changes
810

911
### Bugs Fixed
1012

1113
### Other Changes
1214

15+
- Bumped minimum `azure-ai-agentserver-core` dependency to `>=2.0.0b3`.
16+
1317
## 1.0.0b2 (2026-04-17)
1418

1519
### Features Added

sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ classifiers = [
2121
keywords = ["azure", "azure sdk", "agent", "agentserver", "invocations"]
2222

2323
dependencies = [
24-
"azure-ai-agentserver-core>=2.0.0b2",
24+
"azure-ai-agentserver-core>=2.0.0b3",
2525
]
2626

2727
[dependency-groups]
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# ---------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# ---------------------------------------------------------
4+
"""Tests for x-request-id header on invocations responses.
5+
6+
The ``RequestIdMiddleware`` is wired in ``AgentServerHost`` (core) and
7+
inherited by ``InvocationAgentServerHost``. These tests verify the
8+
header appears on invocations endpoints.
9+
10+
Note: Error body enrichment (``additionalInfo.request_id``) is a
11+
Responses-only feature and is NOT applied to invocations error responses.
12+
"""
13+
import uuid
14+
15+
import pytest
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Header presence — success responses
20+
# ---------------------------------------------------------------------------
21+
22+
@pytest.mark.asyncio
23+
async def test_invoke_returns_request_id_header(echo_client):
24+
"""POST /invocations success response includes x-request-id."""
25+
resp = await echo_client.post("/invocations", content=b"hello")
26+
assert "x-request-id" in resp.headers
27+
assert resp.headers["x-request-id"] # non-empty
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_request_id_is_stable_per_request(echo_client):
32+
"""Each request gets a unique x-request-id."""
33+
ids = set()
34+
for _ in range(5):
35+
resp = await echo_client.post("/invocations", content=b"x")
36+
ids.add(resp.headers["x-request-id"])
37+
assert len(ids) == 5
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_request_id_echoes_incoming_header(echo_client):
42+
"""When the client sends x-request-id, the same value is returned."""
43+
custom_id = uuid.uuid4().hex
44+
resp = await echo_client.post(
45+
"/invocations",
46+
content=b"test",
47+
headers={"x-request-id": custom_id},
48+
)
49+
assert resp.headers["x-request-id"] == custom_id
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_readiness_returns_request_id(echo_client):
54+
"""GET /readiness also gets x-request-id (middleware applies to all routes)."""
55+
resp = await echo_client.get("/readiness")
56+
assert resp.status_code == 200
57+
assert "x-request-id" in resp.headers
58+
59+
60+
# ---------------------------------------------------------------------------
61+
# Error responses — header present, but NO body enrichment
62+
# ---------------------------------------------------------------------------
63+
64+
@pytest.mark.asyncio
65+
async def test_error_response_has_request_id_header(failing_client):
66+
"""500 error response includes x-request-id header."""
67+
resp = await failing_client.post("/invocations", content=b"boom")
68+
assert resp.status_code == 500
69+
assert "x-request-id" in resp.headers
70+
assert resp.headers["x-request-id"]
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_error_response_no_additional_info_enrichment(failing_client):
75+
"""Invocations error bodies do NOT get additionalInfo.request_id (Responses-only)."""
76+
resp = await failing_client.post("/invocations", content=b"boom")
77+
assert resp.status_code == 500
78+
79+
body = resp.json()
80+
error = body.get("error", {})
81+
# core's create_error_response doesn't include additionalInfo
82+
assert "additionalInfo" not in error

sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,22 @@
44

55
### Features Added
66

7+
- Added `_platform_headers` module centralizing all platform HTTP header name constants (`x-request-id`, `x-platform-server`, `x-agent-session-id`, isolation keys, `traceparent`, `x-ms-client-request-id`). All header references now use shared constants instead of scattered string literals.
8+
- Added `RequestIdMiddleware` (in `azure-ai-agentserver-core`) that sets the `x-request-id` response header on every HTTP response. Value is resolved in priority order: OTEL trace ID → incoming `x-request-id` header → new UUID.
9+
- Error responses (4xx/5xx) with a JSON `error` body are automatically enriched with `error.additionalInfo.request_id` matching the `x-request-id` response header, enabling client-side error correlation.
10+
- Foundry storage logging now includes the `traceparent` header (W3C distributed trace ID) in all log messages, enabling correlation between SDK log entries and backend distributed traces.
11+
712
### Breaking Changes
813

914
### Bugs Fixed
1015

16+
- Fixed crash in `FoundryStorageLoggingPolicy` when a transport-level failure (DNS resolution, connection refused, timeout) occurs before any HTTP response is received. The policy previously attempted to access `response.headers` unconditionally, raising an unrelated exception that masked the real transport error. Transport failures are now logged at ERROR level and the original exception propagates cleanly.
17+
1118
### Other Changes
1219

20+
- Removed `x-ms-request-id` from Foundry storage response logging (unused service header).
21+
- Migrated all header string literals to use `_platform_headers` constants.
22+
1323
## 1.0.0b4 (2026-04-19)
1424

1525
### Bugs Fixed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
"""Platform HTTP header name constants used across the AgentServer packages.
4+
5+
These headers form the wire contract between the Foundry platform, agent
6+
containers, and downstream storage services. All header name constants
7+
are defined here to eliminate scattered string literals and ensure
8+
consistency across logging policies, middleware, and endpoint handlers.
9+
10+
**Response headers** (set by the server on every response):
11+
12+
- :data:`REQUEST_ID` — request correlation ID.
13+
- :data:`SERVER_VERSION` — server SDK identity.
14+
- :data:`SESSION_ID` — resolved session ID (when applicable).
15+
16+
**Request headers** (set by the platform or client):
17+
18+
- :data:`REQUEST_ID` — client-provided correlation ID (echoed back on the response).
19+
- :data:`USER_ISOLATION_KEY` / :data:`CHAT_ISOLATION_KEY` — platform isolation keys.
20+
- :data:`CLIENT_HEADER_PREFIX` — prefix for pass-through client headers.
21+
- :data:`TRACEPARENT` — W3C Trace Context propagation header.
22+
- :data:`CLIENT_REQUEST_ID` — Azure SDK client correlation header.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
# -- Response/request correlation -------------------------------------------
28+
29+
REQUEST_ID: str = "x-request-id"
30+
"""The ``x-request-id`` header — carries the request correlation ID.
31+
32+
On responses, the server always sets this header
33+
(OTEL trace ID → incoming header → UUID).
34+
On requests, clients may set it to provide their own correlation ID.
35+
"""
36+
37+
SERVER_VERSION: str = "x-platform-server"
38+
"""The ``x-platform-server`` header — identifies the server SDK stack
39+
(hosting version, protocol versions, language, and runtime).
40+
Set on every response by ``_PlatformHeaderMiddleware``.
41+
"""
42+
43+
SESSION_ID: str = "x-agent-session-id"
44+
"""The ``x-agent-session-id`` header — the resolved session ID for the request.
45+
Set on responses by protocol-specific session resolution logic.
46+
"""
47+
48+
# -- Platform isolation -----------------------------------------------------
49+
50+
USER_ISOLATION_KEY: str = "x-agent-user-isolation-key"
51+
"""The ``x-agent-user-isolation-key`` header — the platform-injected
52+
partition key for user-private state.
53+
"""
54+
55+
CHAT_ISOLATION_KEY: str = "x-agent-chat-isolation-key"
56+
"""The ``x-agent-chat-isolation-key`` header — the platform-injected
57+
partition key for conversation-scoped state.
58+
"""
59+
60+
# -- Client pass-through ---------------------------------------------------
61+
62+
CLIENT_HEADER_PREFIX: str = "x-client-"
63+
"""The prefix ``x-client-`` for pass-through client headers.
64+
65+
All request headers starting with this prefix are extracted and forwarded
66+
to the handler via the invocation context.
67+
"""
68+
69+
# -- Tracing & diagnostics -------------------------------------------------
70+
71+
TRACEPARENT: str = "traceparent"
72+
"""The ``traceparent`` header — W3C Trace Context propagation header.
73+
Used for distributed tracing correlation on outbound storage requests.
74+
"""
75+
76+
CLIENT_REQUEST_ID: str = "x-ms-client-request-id"
77+
"""The ``x-ms-client-request-id`` header — Azure SDK client correlation header.
78+
Logged for diagnostic correlation with upstream Azure SDK callers.
79+
"""
80+
81+
# -- Storage diagnostics (response headers from Foundry) --------------------
82+
83+
APIM_REQUEST_ID: str = "apim-request-id"
84+
"""The ``apim-request-id`` header — APIM gateway correlation header.
85+
Extracted from Foundry storage responses for diagnostic logging.
86+
"""
87+
88+
# -- HttpContext item key ---------------------------------------------------
89+
90+
REQUEST_ID_ITEM_KEY: str = "agentserver.request_id"
91+
"""Key used to store the resolved request ID in ASGI scope state.
92+
93+
Downstream handlers and middleware can read this value to correlate the
94+
request ID without re-resolving it.
95+
"""

0 commit comments

Comments
 (0)