Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Release History

## 1.0.0b2 (Unreleased)

### Breaking Changes

- Error responses for deleted resources now return HTTP 404 (was 400). Affects `GET /responses/{id}`, `GET /responses/{id}/input_items`, and `DELETE /responses/{id}` (second delete) on previously deleted responses.
- Cancel on incomplete responses now returns message `"Cannot cancel a response in terminal state."` (was `"Cannot cancel an incomplete response."`).
- SSE replay rejection messages now use spec-compliant wording:
- Non-background responses: `"This response cannot be streamed because it was not created with background=true."`
- Background non-stream responses: `"This response cannot be streamed because it was not created with stream=true."`

### Features Added

- `FoundryStorageLoggingPolicy` — Azure Core per-retry pipeline policy that logs Foundry storage HTTP calls (method, URI, status code, duration, correlation headers) at the `azure.ai.agentserver` logger. Replaces the built-in `HttpLoggingPolicy` in the Foundry pipeline to provide single-line summaries with duration timing and error-level escalation.

Comment thread
RaviPidaparthi marked this conversation as resolved.
Outdated
### Bugs Fixed

- Error `code` field now uses spec-compliant values: `"invalid_request_error"` for 400/404 errors (was `"invalid_request"`, `"not_found"`, or `"invalid_mode"`), `"server_error"` for 500 errors (was `"internal_error"`).
- `RequestValidationError` default code updated from `"invalid_request"` to `"invalid_request_error"`.
- Foundry storage errors (`FoundryResourceNotFoundError`, `FoundryBadRequestError`, `FoundryApiError`) are now explicitly caught in endpoint handlers and mapped to appropriate HTTP status codes instead of being swallowed by broad exception handlers.

## 1.0.0b1 (2026-04-14)

### Features Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------

VERSION = "1.0.0b1"
VERSION = "1.0.0b2"
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from ..models.errors import RequestValidationError
from ..models.runtime import ResponseExecution, ResponseModeFlags, build_cancelled_response, build_failed_response
from ..store._base import ResponseProviderProtocol, ResponseStreamProviderProtocol
from ..store._foundry_errors import FoundryApiError, FoundryBadRequestError, FoundryResourceNotFoundError, FoundryStorageError
Comment thread
RaviPidaparthi marked this conversation as resolved.
Outdated
from ..streaming._helpers import _encode_sse
from ..streaming._sse import encode_sse_any_event
from ..streaming._state_machine import _normalize_lifecycle_events
Expand Down Expand Up @@ -575,7 +576,7 @@ async def _iter_with_cleanup(): # type: ignore[return]
"error": {
"message": "internal server error",
"type": "server_error",
"code": "internal_error",
"code": "server_error",
"param": None,
}
}
Expand All @@ -601,7 +602,7 @@ async def _iter_with_cleanup(): # type: ignore[return]
"error": {
"message": "internal server error",
"type": "server_error",
"code": "internal_error",
"code": "server_error",
"param": None,
}
}
Expand Down Expand Up @@ -661,6 +662,13 @@ async def handle_get(self, request: Request) -> Response:
response_obj = await self._provider.get_response(response_id, isolation=_isolation)
snapshot = response_obj.as_dict()
return JSONResponse(snapshot, status_code=200)
except FoundryResourceNotFoundError:
pass # Fall through to 404 below
except FoundryBadRequestError as exc:
return _invalid_request(str(exc), {}, param="response_id")
except FoundryApiError as exc:
logger.error("Storage API error for GET response_id=%s: %s", response_id, exc, exc_info=True)
return _error_response(exc, {})
except Exception: # pylint: disable=broad-exception-caught
logger.warning("Provider fallback failed for GET response_id=%s", response_id, exc_info=True)
else:
Expand All @@ -674,11 +682,15 @@ async def handle_get(self, request: Request) -> Response:
try:
await self._provider.get_response(response_id, isolation=_isolation)
return _invalid_mode(
"stream replay is not available for this response; to enable SSE replay, "
+ "create the response with background=true, stream=true, and store=true",
"This response cannot be streamed because it was not created with background=true.",
{},
param="stream",
)
except FoundryResourceNotFoundError:
pass # Response doesn't exist in provider either — fall through to 404
Comment thread
RaviPidaparthi marked this conversation as resolved.
except FoundryApiError as exc:
logger.error("Storage API error for GET SSE replay response_id=%s: %s", response_id, exc, exc_info=True)
return _error_response(exc, {})
except Exception: # pylint: disable=broad-exception-caught
pass # Response doesn't exist in provider either — fall through to 404

Expand All @@ -692,9 +704,14 @@ async def handle_get(self, request: Request) -> Response:
if not record.mode_flags.store:
return _not_found(response_id, {})
if not record.replay_enabled:
if not record.mode_flags.background:
return _invalid_mode(
"This response cannot be streamed because it was not created with background=true.",
{},
param="stream",
)
return _invalid_mode(
"stream replay is not available for this response; to enable SSE replay, "
+ "create the response with background=true, stream=true, and store=true",
"This response cannot be streamed because it was not created with stream=true.",
{},
param="stream",
)
Expand Down Expand Up @@ -874,16 +891,21 @@ async def handle_cancel(self, request: Request) -> Response:
)
if stored_status == "cancelled":
return _invalid_request(
"Cannot cancel an already cancelled response.",
"Cannot cancel a response in terminal state.",
{},
param="response_id",
)
if stored_status == "incomplete":
return _invalid_request(
"Cannot cancel an incomplete response.",
"Cannot cancel a response in terminal state.",
{},
param="response_id",
)
except FoundryResourceNotFoundError:
pass # Fall through to 404 below
Comment thread
RaviPidaparthi marked this conversation as resolved.
except FoundryApiError as exc:
logger.error("Storage API error for cancel response_id=%s: %s", response_id, exc, exc_info=True)
return _error_response(exc, {})
except Exception: # pylint: disable=broad-exception-caught
logger.debug(
"Provider fallback failed for cancel response_id=%s",
Expand Down Expand Up @@ -924,7 +946,7 @@ async def handle_cancel(self, request: Request) -> Response:

if record.status == "incomplete":
return _invalid_request(
"Cannot cancel an incomplete response.",
"Cannot cancel a response in terminal state.",
{},
param="response_id",
)
Expand Down Expand Up @@ -988,6 +1010,13 @@ async def handle_input_items(self, request: Request) -> Response:
)
except ValueError:
return _deleted_response(response_id, {})
except FoundryResourceNotFoundError:
return _not_found(response_id, {})
except FoundryBadRequestError as exc:
return _invalid_request(str(exc), {}, param="response_id")
except FoundryApiError as exc:
logger.error("Storage API error for input_items response_id=%s: %s", response_id, exc, exc_info=True)
return _error_response(exc, {})
except KeyError:
# Fall back to runtime_state for in-flight responses not yet persisted to provider
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ def build_not_found_error_response(
"""
return build_api_error_response(
message=f"{resource_name} '{resource_id}' was not found",
code="not_found",
code="invalid_request_error",
param=param,
error_type="invalid_request_error",
)
Expand All @@ -221,7 +221,7 @@ def build_invalid_mode_error_response(
"""
return build_api_error_response(
message=message,
code="invalid_mode",
code="invalid_request_error",
param=param,
error_type="invalid_request_error",
)
Expand All @@ -241,13 +241,13 @@ def to_api_error_response(error: Exception) -> ApiErrorResponse:
if isinstance(error, ValueError):
return build_api_error_response(
message=str(error) or "invalid request",
code="invalid_request",
code="invalid_request_error",
error_type="invalid_request_error",
)

return build_api_error_response(
message="internal server error",
code="internal_error",
code="server_error",
error_type="server_error",
)

Expand Down Expand Up @@ -327,7 +327,7 @@ def not_found_response(response_id: str, headers: dict[str, str]) -> JSONRespons
"""
return _api_error(
message=f"Response with id '{response_id}' not found.",
code="invalid_request",
code="invalid_request_error",
param="response_id",
error_type="invalid_request_error",
status_code=404,
Expand All @@ -348,7 +348,7 @@ def invalid_request_response(message: str, headers: dict[str, str], *, param: st
"""
return _api_error(
message=message,
code="invalid_request",
code="invalid_request_error",
param=param,
error_type="invalid_request_error",
status_code=400,
Expand Down Expand Up @@ -392,17 +392,15 @@ def service_unavailable_response(message: str, headers: dict[str, str]) -> JSONR


def deleted_response(response_id: str, headers: dict[str, str]) -> JSONResponse:
"""Build a 400 error response indicating the response has been deleted.
"""Build a 404 error response indicating the response has been deleted.

Per spec, all endpoints treat deleted responses as not-found (HTTP 404).

:param response_id: The ID of the deleted response.
:type response_id: str
:param headers: HTTP headers to include in the response.
:type headers: dict[str, str]
:return: A 400 JSONResponse.
:return: A 404 JSONResponse.
:rtype: JSONResponse
"""
return invalid_request_response(
f"Response with id '{response_id}' has been deleted.",
headers,
param="response_id",
)
return not_found_response(response_id, headers)
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(
self,
message: str,
*,
code: str = "invalid_request",
code: str = "invalid_request_error",
param: str | None = None,
error_type: str = "invalid_request_error",
debug_info: dict[str, Any] | None = None,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
"""Logging policy for Foundry storage HTTP calls.

Logs method, URI, status code, duration, and correlation headers for
each outbound storage request at the ``azure.ai.agentserver`` logger.

This mirrors the .NET ``FoundryStorageLoggingPolicy`` and provides
consistent observability for storage operations.
"""

from __future__ import annotations

import logging
import time
from typing import TYPE_CHECKING, TypeVar

from azure.core.pipeline import PipelineRequest, PipelineResponse
from azure.core.pipeline.policies import AsyncHTTPPolicy

if TYPE_CHECKING:
pass

Comment thread
RaviPidaparthi marked this conversation as resolved.
Outdated
logger = logging.getLogger("azure.ai.agentserver")

T = TypeVar("T")
Comment thread
RaviPidaparthi marked this conversation as resolved.
Outdated

# Correlation headers to extract and log
_CLIENT_REQUEST_ID_HEADER = "x-ms-client-request-id"
_SERVER_REQUEST_ID_HEADER = "x-ms-request-id"


class FoundryStorageLoggingPolicy(AsyncHTTPPolicy[PipelineRequest, PipelineResponse]):
"""Azure Core per-retry pipeline policy that logs Foundry storage calls.

Logs the HTTP method, URI, response status code, duration in milliseconds,
and correlation headers (``x-ms-client-request-id``, ``x-ms-request-id``)
for observability of storage operations.
"""

async def send(self, request: PipelineRequest) -> PipelineResponse: # type: ignore[override]
"""Send the request and log the operation details.

:param request: The pipeline request.
:type request: ~azure.core.pipeline.PipelineRequest
:return: The pipeline response.
:rtype: ~azure.core.pipeline.PipelineResponse
"""
http_request = request.http_request
method = http_request.method
url = http_request.url

client_request_id = http_request.headers.get(_CLIENT_REQUEST_ID_HEADER, "")

start = time.monotonic()
try:
response = await self.next.send(request)
except Exception:
elapsed_ms = (time.monotonic() - start) * 1000
logger.warning(
"Foundry storage %s %s failed after %.1fms (client-request-id=%s)",
method,
url,
elapsed_ms,
client_request_id,
)
raise

elapsed_ms = (time.monotonic() - start) * 1000
status_code = response.http_response.status_code
server_request_id = response.http_response.headers.get(_SERVER_REQUEST_ID_HEADER, "")

log_level = logging.INFO if 200 <= status_code < 400 else logging.WARNING
logger.log(
log_level,
"Foundry storage %s %s -> %d (%.1fms, client-request-id=%s, request-id=%s)",
method,
url,
status_code,
elapsed_ms,
client_request_id,
server_request_id,
)

return response
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from ..models._generated import OutputItem, ResponseObject # type: ignore[attr-defined]
from ._foundry_errors import raise_for_storage_error
from ._foundry_logging_policy import FoundryStorageLoggingPolicy
from ._foundry_serializer import (
deserialize_history_ids,
deserialize_items_array,
Expand Down Expand Up @@ -96,9 +97,9 @@ def __init__(
credential,
_FOUNDRY_TOKEN_SCOPE,
),
FoundryStorageLoggingPolicy(),
policies.ContentDecodePolicy(),
policies.DistributedTracingPolicy(),
policies.HttpLoggingPolicy(),
],
)

Expand Down
Loading
Loading