Skip to content

Commit 5042310

Browse files
feat: record and replay provider exceptions in inferencing integration tests (#4880)
## Summary - Add exception serialization/deserialization to the API recording system for accurate error replay during integration tests - Exceptions are categorized as `llama_stack`, `provider_sdk`, `builtin`, or `unknown`, with provider-specific reconstruction for OpenAI and Ollama SDKs - Patch `AsyncResponses.create` for Responses API error recording - Add pluggable `testing/providers/` module for extending to new provider SDKs ## Stack This is PR 3/4 in the error message consistency series (split from #3913). Depends on #4878 (included in this branch). Incremental diff (PR 3 only): `api_recorder.py`, `exception_utils.py`, `testing/providers/` 1. Error types foundation (#4878) — merge first 2. Responses API error handling (#4879) 3. **This PR** — Record/replay error support 4. Integration tests (#error-integration-tests) ## Test plan - [x] All pre-commit hooks pass - [x] CI passes --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4234fd6 commit 5042310

16 files changed

Lines changed: 1121 additions & 19 deletions

src/llama_stack/core/exceptions/translation.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ def translate_exception(exc: Exception) -> HTTPException:
4747
status_code = getattr(exc, "status_code", httpx.codes.INTERNAL_SERVER_ERROR)
4848
detail = str(exc)
4949
return HTTPException(status_code=status_code, detail=detail)
50-
else:
51-
return HTTPException(
52-
status_code=httpx.codes.INTERNAL_SERVER_ERROR,
53-
detail="Internal server error: An unexpected error occurred.",
54-
)
50+
51+
return HTTPException(
52+
status_code=httpx.codes.INTERNAL_SERVER_ERROR,
53+
detail="Internal server error: An unexpected error occurred.",
54+
)

src/llama_stack/testing/api_recorder.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from llama_stack.core.id_generation import reset_id_override, set_id_override
2222
from llama_stack.log import get_logger
23+
from llama_stack.testing.exception_utils import deserialize_exception, serialize_exception
2324

2425
logger = get_logger(__name__, category="testing")
2526

@@ -856,9 +857,20 @@ async def _patched_inference_method(original_method, self, client_type, endpoint
856857
recording = storage.find_recording(request_hash)
857858

858859
if recording:
859-
response_body = recording["response"]["body"]
860+
response_data = recording["response"]
860861

861-
if recording["response"].get("is_streaming", False):
862+
# Handle recorded exceptions
863+
if response_data.get("is_exception", False):
864+
exc_data = response_data.get("exception_data")
865+
if exc_data:
866+
raise deserialize_exception(exc_data)
867+
else:
868+
# Legacy format or unknown exception
869+
raise Exception(response_data.get("exception_message", "Unknown error"))
870+
871+
response_body = response_data["body"]
872+
873+
if response_data.get("is_streaming", False):
862874

863875
async def replay_stream():
864876
for chunk in response_body:
@@ -889,15 +901,6 @@ async def replay_stream():
889901
)
890902

891903
if mode == APIRecordingMode.RECORD or (mode == APIRecordingMode.RECORD_IF_MISSING and not recording):
892-
if endpoint in ("/v1/models", "/v1/openai/v1/models"):
893-
response = original_method(self, *args, **kwargs)
894-
else:
895-
response = await original_method(self, *args, **kwargs)
896-
897-
# we want to store the result of the iterator, not the iterator itself
898-
if endpoint in ("/v1/models", "/v1/openai/v1/models"):
899-
response = [m async for m in response]
900-
901904
request_data = {
902905
"method": method,
903906
"url": url,
@@ -907,15 +910,49 @@ async def replay_stream():
907910
"model": body.get("model", ""),
908911
}
909912

913+
try:
914+
if endpoint in ("/v1/models", "/v1/openai/v1/models"):
915+
response = original_method(self, *args, **kwargs)
916+
else:
917+
response = await original_method(self, *args, **kwargs)
918+
919+
# we want to store the result of the iterator, not the iterator itself
920+
if endpoint in ("/v1/models", "/v1/openai/v1/models"):
921+
response = [m async for m in response]
922+
923+
except Exception as exc:
924+
# Record the exception
925+
response_data = {
926+
"body": None,
927+
"is_streaming": False,
928+
"is_exception": True,
929+
"exception_data": serialize_exception(exc),
930+
"exception_message": str(exc),
931+
}
932+
storage.store_recording(request_hash, request_data, response_data)
933+
raise # Re-raise so recording mode still fails as expected
934+
910935
# Determine if this is a streaming request based on request parameters
911936
is_streaming = body.get("stream", False)
912937

913938
if is_streaming:
914939
# For streaming responses, we need to collect all chunks immediately before yielding
915940
# This ensures the recording is saved even if the generator isn't fully consumed
916941
chunks: list[Any] = []
917-
async for chunk in response:
918-
chunks.append(chunk)
942+
try:
943+
async for chunk in response:
944+
chunks.append(chunk)
945+
except Exception as exc:
946+
# Exception during streaming - record what we got plus the exception
947+
response_data = {
948+
"body": chunks,
949+
"is_streaming": True,
950+
"is_exception": True,
951+
"exception_data": serialize_exception(exc),
952+
"exception_message": str(exc),
953+
}
954+
storage.store_recording(request_hash, request_data, response_data)
955+
raise
919956

920957
# Store the recording immediately
921958
response_data = {"body": chunks, "is_streaming": True}
@@ -946,6 +983,7 @@ def patch_inference_clients():
946983
from openai.resources.completions import AsyncCompletions
947984
from openai.resources.embeddings import AsyncEmbeddings
948985
from openai.resources.models import AsyncModels
986+
from openai.resources.responses import AsyncResponses
949987

950988
from llama_stack.providers.remote.tool_runtime.tavily_search.tavily_search import TavilySearchToolRuntimeImpl
951989

@@ -955,6 +993,7 @@ def patch_inference_clients():
955993
"completions_create": AsyncCompletions.create,
956994
"embeddings_create": AsyncEmbeddings.create,
957995
"models_list": AsyncModels.list,
996+
"responses_create": AsyncResponses.create,
958997
"ollama_generate": OllamaAsyncClient.generate,
959998
"ollama_chat": OllamaAsyncClient.chat,
960999
"ollama_embed": OllamaAsyncClient.embed,
@@ -990,11 +1029,17 @@ async def _iter():
9901029

9911030
return _iter()
9921031

1032+
async def patched_responses_create(self, *args, **kwargs):
1033+
return await _patched_inference_method(
1034+
_original_methods["responses_create"], self, "openai", "/v1/responses", *args, **kwargs
1035+
)
1036+
9931037
# Apply OpenAI patches
9941038
AsyncChatCompletions.create = patched_chat_completions_create
9951039
AsyncCompletions.create = patched_completions_create
9961040
AsyncEmbeddings.create = patched_embeddings_create
9971041
AsyncModels.list = patched_models_list
1042+
AsyncResponses.create = patched_responses_create
9981043

9991044
# Create patched methods for Ollama client
10001045
async def patched_ollama_generate(self, *args, **kwargs):
@@ -1068,6 +1113,7 @@ def unpatch_inference_clients():
10681113
from openai.resources.completions import AsyncCompletions
10691114
from openai.resources.embeddings import AsyncEmbeddings
10701115
from openai.resources.models import AsyncModels
1116+
from openai.resources.responses import AsyncResponses
10711117

10721118
from llama_stack.providers.remote.tool_runtime.tavily_search.tavily_search import TavilySearchToolRuntimeImpl
10731119

@@ -1076,6 +1122,7 @@ def unpatch_inference_clients():
10761122
AsyncCompletions.create = _original_methods["completions_create"]
10771123
AsyncEmbeddings.create = _original_methods["embeddings_create"]
10781124
AsyncModels.list = _original_methods["models_list"]
1125+
AsyncResponses.create = _original_methods["responses_create"]
10791126

10801127
# Restore Ollama client methods if they were patched
10811128
OllamaAsyncClient.generate = _original_methods["ollama_generate"]
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
"""Shared exception handling utilities for recording/replaying exceptions.
8+
9+
This module provides utilities for serializing and deserializing exceptions
10+
during API recording/replay. The exception types handled here use the shared
11+
mapping from mapping.py, ensuring consistency between runtime
12+
exception handling and test replay.
13+
"""
14+
15+
from typing import Any, Protocol, TypeGuard
16+
17+
import httpx
18+
19+
from llama_stack.core.exceptions.mapping import EXCEPTION_TYPES_BY_NAME
20+
from llama_stack.testing.providers import GenericProviderError, create_provider_error, detect_provider
21+
from llama_stack_api.common.errors import LlamaStackError
22+
23+
__all__ = [
24+
"GenericProviderError",
25+
"GenericLlamaStackError",
26+
"ProviderSDKException",
27+
"deserialize_exception",
28+
"is_provider_sdk_exception",
29+
"serialize_exception",
30+
]
31+
32+
33+
class ProviderSDKException(Protocol):
34+
"""Protocol for provider SDK exceptions with status_code attribute."""
35+
36+
status_code: int
37+
body: dict | None
38+
39+
40+
class GenericLlamaStackError(LlamaStackError):
41+
"""A generic LlamaStackError for replay when exact type can't be reconstructed."""
42+
43+
def __init__(self, status_code_value: int, message: str = ""):
44+
super().__init__(message)
45+
# Override the class variable with an instance attribute
46+
self.status_code = httpx.codes(status_code_value)
47+
48+
49+
def is_provider_sdk_exception(exc: Exception) -> TypeGuard[ProviderSDKException]:
50+
"""Check if exception is a provider SDK exception (e.g., OpenAI APIStatusError).
51+
52+
Provider SDK exceptions have a status_code attribute that indicates the HTTP
53+
status code from the upstream provider. This matches the duck-typing used
54+
in server.translate_exception().
55+
"""
56+
return hasattr(exc, "status_code") and isinstance(getattr(exc, "status_code", None), int)
57+
58+
59+
def serialize_exception(exc: Exception) -> dict[str, Any]:
60+
"""Serialize an exception for recording.
61+
62+
Categories:
63+
- llama_stack: LlamaStackError subclasses (internal errors)
64+
- provider_sdk: Exceptions with status_code attr (OpenAI, etc.)
65+
- builtin: Python built-in exceptions handled by translate_exception
66+
- unknown: Everything else (will replay as generic Exception)
67+
"""
68+
exc_type = type(exc).__name__
69+
message = str(exc)
70+
71+
# Check categories in order of specificity
72+
if isinstance(exc, LlamaStackError):
73+
return {
74+
"category": "llama_stack",
75+
"type": exc_type,
76+
"message": message,
77+
"status_code": int(exc.status_code),
78+
}
79+
elif is_provider_sdk_exception(exc):
80+
error_message = getattr(exc, "error", message)
81+
return {
82+
"category": "provider_sdk",
83+
"provider": detect_provider(exc),
84+
"type": exc_type,
85+
"message": error_message,
86+
"status_code": exc.status_code,
87+
"body": getattr(exc, "body", None),
88+
}
89+
elif exc_type in EXCEPTION_TYPES_BY_NAME:
90+
return {
91+
"category": "builtin",
92+
"type": exc_type,
93+
"message": message,
94+
}
95+
else:
96+
return {
97+
"category": "unknown",
98+
"type": exc_type,
99+
"message": message,
100+
}
101+
102+
103+
def deserialize_exception(data: dict[str, Any]) -> Exception:
104+
"""Reconstruct an exception from recorded data.
105+
106+
The reconstructed exception will have the same interface that
107+
server.translate_exception() expects, ensuring consistent behavior
108+
between live and replay modes.
109+
"""
110+
category = data.get("category", "unknown")
111+
exc_type = data.get("type", "Exception")
112+
message = data.get("message", "Unknown error")
113+
114+
if category == "llama_stack":
115+
status_code = data.get("status_code", 500)
116+
return GenericLlamaStackError(status_code, message)
117+
118+
elif category == "provider_sdk":
119+
provider = data.get("provider", "unknown")
120+
return create_provider_error(
121+
provider=provider,
122+
status_code=data.get("status_code", 500),
123+
body=data.get("body"),
124+
message=message,
125+
)
126+
127+
elif category == "builtin":
128+
if exc_type in EXCEPTION_TYPES_BY_NAME:
129+
return EXCEPTION_TYPES_BY_NAME[exc_type](message)
130+
return Exception(message)
131+
132+
else:
133+
# Unknown category - return generic exception
134+
return Exception(message)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
"""Provider-specific exception handling for test recording/replay.
8+
9+
Serializes provider SDK exceptions in record mode and reconstructs them in replay
10+
mode so integration tests see the original exception types.
11+
12+
To add a provider:
13+
1. Create providers/<name>.py
14+
2. Define PROVIDER = ProviderConfig(
15+
name="mycloud", # Registry key stored in recordings
16+
sdk_module=mycloud_sdk, # The SDK module (``import mycloud_sdk``)
17+
create_error=create_error, # (status_code, body, message) -> Exception
18+
)
19+
3. Import the module below and add its PROVIDER to the build_providers call
20+
"""
21+
22+
from . import ollama, openai
23+
from ._config import ProviderConfig, _validate_provider
24+
25+
26+
def build_providers(*configs: ProviderConfig) -> dict[str, ProviderConfig]:
27+
"""Build PROVIDERS dict from registered provider configs. Validates on load."""
28+
result: dict[str, ProviderConfig] = {}
29+
for config in configs:
30+
_validate_provider(config)
31+
if config.name in result:
32+
raise ValueError(f"Duplicate provider name: {config.name}") from None
33+
result[config.name] = config
34+
return result
35+
36+
37+
class GenericProviderError(Exception):
38+
"""Generic provider error for replay when provider-specific type can't be reconstructed."""
39+
40+
def __init__(self, status_code: int, body: dict | None = None, message: str = ""):
41+
super().__init__(message)
42+
self.status_code = status_code
43+
self.body = body
44+
45+
46+
PROVIDERS: dict[str, ProviderConfig] = build_providers(
47+
openai.PROVIDER,
48+
ollama.PROVIDER,
49+
)
50+
51+
52+
def detect_provider(exc: object) -> str:
53+
"""Detect the provider from an exception's module."""
54+
module = type(exc).__module__
55+
for config in PROVIDERS.values():
56+
if module.startswith(config._module_prefix):
57+
return config.name
58+
return "unknown"
59+
60+
61+
def create_provider_error(provider: str, status_code: int, body: dict | None, message: str) -> Exception:
62+
"""Reconstruct a provider-specific error from recorded data."""
63+
if provider in PROVIDERS:
64+
return PROVIDERS[provider].create_error(status_code, body, message)
65+
66+
return GenericProviderError(status_code, body, message)

0 commit comments

Comments
 (0)