Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
647 changes: 647 additions & 0 deletions PRPs/PRP-reliability-E2-surface-fallback-failures.md

Large diffs are not rendered by default.

46 changes: 45 additions & 1 deletion app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from app.core.logging import get_logger
from app.core.problem_details import (
AGENT_FALLBACK_EXHAUSTED_CODE,
EMBEDDING_AUTH_CODE,
ERROR_TYPES,
ProblemDetailResponse,
Expand Down Expand Up @@ -40,20 +41,27 @@ def __init__(
code: str = "INTERNAL_ERROR",
status_code: int = 500,
details: dict[str, Any] | None = None,
extensions: dict[str, Any] | None = None,
) -> None:
"""Initialize application error.

Args:
message: Human-readable error message.
code: Machine-readable error code.
status_code: HTTP status code.
details: Additional error context.
details: Additional error context. LOG-ONLY — the exception
handler never copies it into the response body (it may carry
internals).
extensions: RFC 7807 extension members the handler DOES merge
into the problem+json response body (#335). Only put
client-safe, already-sanitized data here.
"""
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
self.details = details or {}
self.extensions = extensions or {}

@property
def title(self) -> str:
Expand Down Expand Up @@ -254,6 +262,41 @@ def __init__(
)


class AgentFallbackExhaustedError(ForecastLabError):
"""502 — every model in the agent's fallback chain failed (issue #335).

Raised when the PydanticAI ``FallbackModel`` chain (or a single configured
model) fails with provider-API errors on every leg. Mirrors
:class:`EmbeddingProviderAuthError`: keeps the public status at 502 (an
upstream failure from the caller's perspective) and emits a
*machine-readable* ``AGENT_FALLBACK_EXHAUSTED`` problem ``type``/``code``
so clients can classify it. The per-model classified failures ride the
response-visible ``extensions`` channel as a ``failures`` member —
``details`` stays log-only by design.
"""

error_type_uri: str = ERROR_TYPES[AGENT_FALLBACK_EXHAUSTED_CODE]

def __init__(
self,
message: str,
failures: list[dict[str, Any]],
) -> None:
"""Initialize with the human summary and classified per-model legs.

Args:
message: Human-actionable summary (already secret-safe).
failures: Serialized ``ModelFailureDetail`` dicts — sanitized
upstream by the classifier; surfaced verbatim to the client.
"""
super().__init__(
message=message,
code=AGENT_FALLBACK_EXHAUSTED_CODE,
status_code=502,
extensions={"failures": failures},
)


# =============================================================================
# Exception Handlers (RFC 7807)
# =============================================================================
Expand Down Expand Up @@ -287,6 +330,7 @@ async def forecastlab_exception_handler(
title=exc.title,
detail=exc.message,
error_code=exc.code,
extensions=exc.extensions or None,
)


Expand Down
27 changes: 26 additions & 1 deletion app/core/problem_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
# demo pipeline's classifier) so the marker never drifts between the two.
EMBEDDING_AUTH_CODE = "EMBEDDING_AUTH"

# Machine-readable code for an exhausted agent model fallback chain (#335).
# Single source of truth shared by the producer (AgentFallbackExhaustedError)
# and any consumer classifying the 502 — mirrors EMBEDDING_AUTH_CODE.
AGENT_FALLBACK_EXHAUSTED_CODE = "AGENT_FALLBACK_EXHAUSTED"

ERROR_TYPES = {
"NOT_FOUND": f"{ERROR_TYPE_BASE}/not-found",
"VALIDATION_ERROR": f"{ERROR_TYPE_BASE}/validation",
Expand All @@ -43,8 +48,16 @@
"SERVICE_UNAVAILABLE": f"{ERROR_TYPE_BASE}/service-unavailable",
"GATEWAY_TIMEOUT": f"{ERROR_TYPE_BASE}/gateway-timeout",
EMBEDDING_AUTH_CODE: f"{ERROR_TYPE_BASE}/embedding-auth",
AGENT_FALLBACK_EXHAUSTED_CODE: f"{ERROR_TYPE_BASE}/agent-fallback-exhausted",
}

# RFC 7807 extension members may never shadow the spec/base fields the
# ProblemDetail schema already owns — reserved keys are dropped from any
# `extensions` merge in problem_response (#335).
_RESERVED_PROBLEM_KEYS = frozenset(
{"type", "title", "status", "detail", "instance", "errors", "code", "request_id"}
)


# =============================================================================
# Problem Detail Schema
Expand Down Expand Up @@ -172,6 +185,7 @@ def problem_response(
detail: str | None = None,
error_code: str = "INTERNAL_ERROR",
errors: list[dict[str, Any]] | None = None,
extensions: dict[str, Any] | None = None,
) -> ProblemDetailResponse:
"""Create a ProblemDetailResponse with proper content type.

Expand All @@ -181,6 +195,9 @@ def problem_response(
detail: Detailed explanation (optional).
error_code: Internal error code for type URI lookup.
errors: Field-level validation errors (optional).
extensions: Additional RFC 7807 extension members merged into the
response body (optional, #335). Reserved base-field keys are
silently dropped — extensions can never shadow type/status/etc.
Returns:
JSONResponse with problem+json content type.
"""
Expand All @@ -192,9 +209,17 @@ def problem_response(
errors=errors,
)

# Merge on the serialized dict (not ProblemDetail(**extensions)) so
# arbitrary extension payloads never fight the pydantic constructor.
content = problem.model_dump(exclude_none=True)
if extensions:
content.update(
{key: value for key, value in extensions.items() if key not in _RESERVED_PROBLEM_KEYS}
)

return ProblemDetailResponse(
status_code=status,
content=problem.model_dump(exclude_none=True),
content=content,
)


Expand Down
110 changes: 110 additions & 0 deletions app/core/tests/test_problem_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Unit tests for RFC 7807 problem_response extension members (issue #335).

The `extensions` channel lets a ForecastLabError surface client-safe data
(e.g. classified per-model failures) in the problem+json body without going
through the log-only `details` attribute.
"""

import json
from typing import Any

import pytest
from fastapi import Request

from app.core.exceptions import (
AgentFallbackExhaustedError,
forecastlab_exception_handler,
)
from app.core.problem_details import problem_response


def _body(response: Any) -> dict[str, Any]:
"""Decode a ProblemDetailResponse body."""
decoded: dict[str, Any] = json.loads(response.body)
return decoded


def test_problem_response_without_extensions_unchanged() -> None:
"""Default (no extensions) output keeps the existing shape exactly."""
response = problem_response(
status=404,
title="Not Found",
detail="Resource not found",
error_code="NOT_FOUND",
)

body = _body(response)
assert response.status_code == 404
assert body["status"] == 404
assert body["code"] == "NOT_FOUND"
assert body["type"] == "/errors/not-found"
assert "failures" not in body


def test_problem_response_merges_extensions() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test that exercises problem_response via a ForecastLabError with extensions

Since ForecastLabError now carries an extensions dict and the handler passes it through to problem_response, consider adding a test that raises a minimal ForecastLabError (or AgentFallbackExhaustedError) with non-empty extensions, runs it through forecastlab_exception_handler, and asserts that the response body contains those extension fields and the expected type/code. This would exercise the full path from exception to problem+json and help catch future wiring regressions.

Suggested implementation:

    body = _body(response)
    assert response.status_code == 404
    assert body["status"] == 404
    assert body["code"] == "NOT_FOUND"
    assert body["type"] == "/errors/not-found"
    assert "failures" not in body



        status=404,
        title="Not Found",
        detail="Resource not found",
        error_code="NOT_FOUND",
    )

    body = _body(response)
    assert response.status_code == 404
    assert body["status"] == 404
    assert body["code"] == "NOT_FOUND"


async def test_forecastlab_exception_handler_includes_extensions() -> None:
    """ForecastLabError extensions are propagated through the exception handler."""
    # Arrange
    request = Request(scope={"type": "http"})
    exc = ForecastLabError(
        status_code=400,
        title="Bad Request",
        detail="Something went wrong",
        error_code="BAD_REQUEST",
        extensions={"foo": "bar", "trace_id": "abc123"},
    )

    # Act
    response = await forecastlab_exception_handler(request, exc)

    # Assert
    body = _body(response)
    assert response.status_code == 400
    assert body["status"] == 400
    assert body["code"] == "BAD_REQUEST"
    assert body["title"] == "Bad Request"
    assert body["detail"] == "Something went wrong"

    # Extensions should be merged into the serialized problem+json body
    assert body["foo"] == "bar"
    assert body["trace_id"] == "abc123"

To fully wire this up you will likely need to:

  1. Add the necessary imports at the top of app/core/tests/test_problem_details.py, for example:
    • from starlette.requests import Request
    • from app.core.exceptions import ForecastLabError
    • from app.core.exception_handlers import forecastlab_exception_handler
      Adjust module paths to match your actual project layout.
  2. Ensure the async test is executed correctly:
    • If you use pytest, either:
      • Mark the test (or module) with @pytest.mark.anyio / @pytest.mark.asyncio, or
      • Use your existing async test configuration.
    • If forecastlab_exception_handler is synchronous in your codebase, remove async from the test definition and the await before calling the handler.
  3. If ForecastLabError uses different parameter names (e.g. status instead of status_code, error_code vs code, or a different way to pass extensions), adjust the constructor call in the test accordingly so that:
    • The status_code of the HTTP response is 400.
    • The JSON body includes "code": "BAD_REQUEST" and the other fields.
  4. If your handler derives the "type" field (e.g. /errors/bad-request) and you want to assert it as well, add:
    • assert body["type"] == "/errors/bad-request"
      once you confirm the exact value in your implementation.

"""Extension members are merged into the serialized body."""
response = problem_response(
status=502,
title="Agent Fallback Exhausted",
detail="All configured agent models failed",
error_code="AGENT_FALLBACK_EXHAUSTED",
extensions={"failures": [{"model_name": "m1", "reason": "model_not_found"}]},
)

body = _body(response)
assert body["code"] == "AGENT_FALLBACK_EXHAUSTED"
assert body["type"] == "/errors/agent-fallback-exhausted"
assert body["failures"] == [{"model_name": "m1", "reason": "model_not_found"}]


def test_problem_response_extensions_cannot_override_reserved() -> None:
"""Reserved base-field keys in extensions are silently dropped."""
response = problem_response(
status=502,
title="Agent Fallback Exhausted",
detail="real detail",
error_code="AGENT_FALLBACK_EXHAUSTED",
extensions={
"status": 200,
"code": "HACK",
"detail": "spoofed",
"type": "about:blank",
"title": "spoofed",
"safe_key": "kept",
},
)

body = _body(response)
assert response.status_code == 502
assert body["status"] == 502
assert body["code"] == "AGENT_FALLBACK_EXHAUSTED"
assert body["detail"] == "real detail"
assert body["type"] == "/errors/agent-fallback-exhausted"
assert body["title"] == "Agent Fallback Exhausted"
assert body["safe_key"] == "kept"


@pytest.mark.asyncio
async def test_exception_handler_propagates_extensions() -> None:
"""The full exception → handler → problem+json path carries extensions.

Guards the wiring: ForecastLabError.extensions must reach the response
body via forecastlab_exception_handler's pass-through (issue #335).
"""
failures = [
{"model_name": "m1", "status_code": 404, "reason": "model_not_found", "detail": ""},
{"model_name": "m2", "status_code": 429, "reason": "quota_exhausted", "detail": ""},
]
exc = AgentFallbackExhaustedError("All configured agent models failed", failures=failures)
request = Request(scope={"type": "http", "method": "POST", "path": "/", "headers": []})

response = await forecastlab_exception_handler(request, exc)

body = _body(response)
assert response.status_code == 502
assert body["status"] == 502
assert body["code"] == "AGENT_FALLBACK_EXHAUSTED"
assert body["type"] == "/errors/agent-fallback-exhausted"
assert body["title"] == "Agent Fallback Exhausted"
assert body["detail"] == "All configured agent models failed"
assert body["failures"] == failures
Loading