Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and this project adheres to

- 🔒️(front) disable yarn install scripts in docker build

### Added
- ✨(frontend): display specific error when LLM provider is down

## [0.0.16] - 2026-05-21

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### Added
Expand Down
31 changes: 31 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# LLM Provider Error Handling

When the LLM provider fails, the backend emits a typed error code in the Vercel AI SDK stream. The frontend reads this code and displays a specific message.

## Error mapping

| Provider failure | pydantic-ai exception | Error code | User-facing message |
|---|---|---|---|
| HTTP 5xx except 503 (500, 502, 504…) | `ModelHTTPError` (`status_code >= 500`) | `model_unavailable` | "The assistant is temporarily unavailable. Please try again later." |
| HTTP 503 (service busy) | `ModelHTTPError` (`status_code == 503`) | `model_busy` | "The assistant is too busy. Please try again later." |
| HTTP 429 (rate limit) | `ModelHTTPError` (`status_code == 429`) | `model_rate_limited` | "The assistant is overloaded. Please try again in a few minutes." |
| HTTP 404 (not found) | `ModelHTTPError` (`status_code == 404`) | `model_not_found` | "Model not found." |
| HTTP 422 (validation error) | `HTTPValidationError` (`status_code == 422`) | `model_wrong_type` | "Wrong model type." |
| No TCP connection, DNS failure, timeout | `ModelAPIError` | `model_connection_error` | "Unable to reach the assistant. Please check your connection or try again later." |
| Any other error | uncaught → generic Vercel AI SDK error | `generic` | "Sorry, an error occurred. Please try again." |

## Status page link

Set `STATUS_PAGE_URL` to display a "Check service status" link alongside any non-`generic` provider error (`model_unavailable`, `model_busy`, `model_rate_limited`, `model_not_found`, `model_wrong_type`, `model_connection_error`).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
```bash
STATUS_PAGE_URL=https://albert.sites.beta.gouv.fr/about/status/
```

The link is omitted when the variable is unset.

## Implementation

- **Backend:** `src/backend/chat/clients/pydantic_ai.py` — `_stream_content` catches provider exceptions and emits `ErrorPart` events.
- **Frontend:** `src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx` — renders the message based on `errorType` prop.
- **Config:** `src/backend/conversations/settings.py` and `src/backend/core/api/viewsets.py` — `STATUS_PAGE_URL` is exposed via the `config/` API.
53 changes: 50 additions & 3 deletions src/backend/chat/clients/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@

from asgiref.sync import sync_to_async
from langfuse import get_client
from mistralai.client.errors import HTTPValidationError, SDKError
from pydantic_ai import Agent, InstrumentationSettings, RunContext, RunUsage
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError
from pydantic_ai.messages import (
BinaryContent,
DocumentUrl,
Expand Down Expand Up @@ -177,6 +179,22 @@
CACHE_TIMEOUT = 30 * 60 # 30 minutes timeout
DOCUMENT_URL_PREFIX = "/media-key/"

_HTTP_STATUS_TO_ERROR_CODE = {
429: "model_rate_limited",
503: "model_busy",
404: "model_not_found",
422: "model_wrong_type",
}


def _resolve_http_error_code(status_code: int) -> str | None:
"""Map an HTTP status code to an error code string, or None to re-raise."""
if status_code in _HTTP_STATUS_TO_ERROR_CODE:
return _HTTP_STATUS_TO_ERROR_CODE[status_code]
if status_code >= 500:
return "model_unavailable"
return None


def get_model_configuration(model_hrid: str):
"""Get the model configuration from settings."""
Expand Down Expand Up @@ -342,9 +360,38 @@ async def _stream_content(
span = stack.enter_context(get_client().start_as_current_span(name="conversation"))
span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk))

async for event in self._run_agent(messages, force_web_search):
if stream_text := encoder_fn(event):
yield stream_text
try:
async for event in self._run_agent(messages, force_web_search):
if stream_text := encoder_fn(event):
yield stream_text
except (ModelHTTPError, HTTPValidationError, SDKError) as exc:
# HTTPValidationError and SDKError are mistral-specific exceptions not
# wrapped by pydantic_ai into ModelHTTPError.
error_code = _resolve_http_error_code(exc.status_code)
if error_code is None:
Comment on lines +367 to +371
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 28, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard status_code access for SDKError/HTTPValidationError.

Line 370 assumes every caught exception has status_code. If SDKError lacks it, this handler raises AttributeError and masks the original provider failure path.

💡 Minimal hardening patch
-            except (ModelHTTPError, HTTPValidationError, SDKError) as exc:
+            except (ModelHTTPError, HTTPValidationError, SDKError) as exc:
                 # HTTPValidationError and SDKError are mistral-specific exceptions not
                 # wrapped by pydantic_ai into ModelHTTPError.
-                error_code = _resolve_http_error_code(exc.status_code)
+                status_code = getattr(exc, "status_code", None)
+                if status_code is None:
+                    error_code = "model_connection_error"
+                else:
+                    error_code = _resolve_http_error_code(status_code)
                 if error_code is None:
                     raise
                 logger.exception(
                     "LLM provider HTTP error (status=%s) for conversation %s: %s",
-                    exc.status_code,
+                    status_code,
                     self.conversation.pk,
                     exc,
                 )
In the current mistralai Python SDK, do `SDKError` and `HTTPValidationError` always expose a `status_code` attribute? Please provide the exception class definitions or official docs link.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/chat/clients/pydantic_ai.py` around lines 367 - 371, The except
block catching (ModelHTTPError, HTTPValidationError, SDKError) assumes
exc.status_code exists and can raise AttributeError for
SDKError/HTTPValidationError; update the handler in pydantic_ai.py to guard
access by using getattr(exc, "status_code", None) (or branch by exception type)
before calling _resolve_http_error_code so error_code is computed only when a
status_code is present, and ensure downstream logic handles a None error_code
without masking the original exception.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

SDKError and HTTPValidationError inherit from MistralError

@dataclass(unsafe_hash=True)
class MistralError(Exception):
    """The base class for all HTTP error responses."""

    message: str
    status_code: int
    body: str
    headers: httpx.Headers = field(hash=False)
    raw_response: httpx.Response = field(hash=False)

    def __init__(
        self, message: str, raw_response: httpx.Response, body: Optional[str] = None
    ):
        object.__setattr__(self, "message", message)
        object.__setattr__(self, "status_code", raw_response.status_code)
        object.__setattr__(
            self, "body", body if body is not None else raw_response.text
        )
        object.__setattr__(self, "headers", raw_response.headers)
        object.__setattr__(self, "raw_response", raw_response)

    def __str__(self):
        return self.message

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@maxenceh, thanks for sharing the MistralError base class definition. Since status_code is set unconditionally from raw_response.status_code in __init__, it is always present on both SDKError and HTTPValidationError. My concern doesn't apply here — the current direct access to exc.status_code is safe.

(ᵕ—ᴗ—) 🐇


🧠 Learnings used
Learnt from: maxenceh
Repo: suitenumerique/conversations PR: 413
File: src/backend/core/models.py:197-207
Timestamp: 2026-04-16T09:18:10.063Z
Learning: In Django projects that use `django-solo` and `SingletonModelAdmin`, do not redundantly set `verbose_name_plural` on a `SingletonModel` subclass just to fix admin display. `SingletonModelAdmin` already overrides the admin rendering for the app index/breadcrumbs to use the singular `verbose_name` (and it suppresses the "+ Add" button), so `verbose_name_plural` is unnecessary for correct `django-solo` admin behavior. Only add `verbose_name_plural` if you have a non-admin use case that requires a plural label.

raise
logger.exception(
"LLM provider HTTP error (status=%s) for conversation %s: %s",
exc.status_code,
self.conversation.pk,
exc,
)
error_event = events_v4.ErrorPart(error=error_code)
if encoded := encoder_fn(error_event):
yield encoded
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
raise
except ModelAPIError as exc:
logger.exception(
"LLM provider connection error for conversation %s: %s",
self.conversation.pk,
exc,
)
error_event = events_v4.ErrorPart(error="model_connection_error")
if encoded := encoder_fn(error_event):
yield encoded
else:
raise

async def stream_text_async(self, messages: List[UIMessage], force_web_search: bool = False):
"""Return only the assistant text deltas (legacy text mode)."""
Expand Down
184 changes: 183 additions & 1 deletion src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Unit tests for AIAgentService stream methods."""

from unittest.mock import patch
from unittest.mock import MagicMock, patch

import httpx
import pytest
from asgiref.sync import sync_to_async
from mistralai.client.errors import HTTPValidationError, HTTPValidationErrorData, SDKError
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError

from chat.ai_sdk_types import UIMessage
from chat.clients.pydantic_ai import AIAgentService
Expand All @@ -13,6 +16,19 @@
pytestmark = pytest.mark.django_db()


class AsyncRaiseIterator:
"""Async iterator that raises the given exception on the first iteration."""

def __init__(self, exc):
self.exc = exc

def __aiter__(self):
return self

async def __anext__(self):
raise self.exc


@pytest.fixture(autouse=True)
def base_settings(settings):
"""Set up base settings for the tests."""
Expand Down Expand Up @@ -109,3 +125,169 @@
'd:{"finishReason":"stop","usage":{"promptTokens":120,"completionTokens":456,'
'"co2Impact":0.0}}\n',
]


@pytest.mark.asyncio
async def test_stream_data_async_emits_model_busy_on_503():
"""503 ModelHTTPError emits model_busy ErrorPart."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(ModelHTTPError(status_code=503, model_name="test-model"))

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
results = []
async for result in service.stream_data_async([]):
results.append(result)

assert results == ['3:"model_busy"\n']


@pytest.mark.asyncio
async def test_stream_data_async_emits_model_unavailable_on_5xx():
"""Generic 5xx ModelHTTPError emits model_unavailable ErrorPart."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(ModelHTTPError(status_code=500, model_name="test-model"))

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
results = []
async for result in service.stream_data_async([]):
results.append(result)

assert results == ['3:"model_unavailable"\n']


@pytest.mark.asyncio
async def test_stream_data_async_emits_model_rate_limited_on_429():
"""429 ModelHTTPError emits model_rate_limited ErrorPart."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(ModelHTTPError(status_code=429, model_name="test-model"))

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
results = []
async for result in service.stream_data_async([]):
results.append(result)

assert results == ['3:"model_rate_limited"\n']


@pytest.mark.asyncio
async def test_stream_data_async_emits_model_connection_error_on_api_error():
"""ModelAPIError (e.g. connection refused) emits model_connection_error ErrorPart."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(
ModelAPIError(model_name="test-model", message="Connection error.")
)

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
results = []
async for result in service.stream_data_async([]):
results.append(result)

assert results == ['3:"model_connection_error"\n']


@pytest.mark.asyncio
async def test_stream_data_async_emits_model_busy_on_sdk_error_503():
"""Mistral SDKError with 503 emits model_busy ErrorPart."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 503
mock_response.text = ""
mock_response.headers = httpx.Headers()

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(
SDKError(message="service unavailable", raw_response=mock_response)
)

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
results = []
async for result in service.stream_data_async([]):
results.append(result)

assert results == ['3:"model_busy"\n']


@pytest.mark.asyncio
async def test_stream_data_async_emits_model_wrong_type_on_http_validation_error_422():
"""Mistral HTTPValidationError with 422 emits model_wrong_type ErrorPart."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 422
mock_response.text = ""
mock_response.headers = httpx.Headers()

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(
HTTPValidationError(data=HTTPValidationErrorData(), raw_response=mock_response)
)

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
results = []
async for result in service.stream_data_async([]):
results.append(result)

assert results == ['3:"model_wrong_type"\n']


@pytest.mark.asyncio
async def test_stream_data_async_reraises_unknown_exceptions():
"""Unknown exceptions are not swallowed — they propagate."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(ValueError("unexpected"))

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
with pytest.raises(ValueError, match="unexpected"):
async for _ in service.stream_data_async([]):
pass

Check warning on line 260 in src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_conversations&issues=AZ5qMtZ6U1aRbXyLYcQK&open=AZ5qMtZ6U1aRbXyLYcQK&pullRequest=495


@pytest.mark.asyncio
async def test_stream_text_async_reraises_http_error():
"""ModelHTTPError is re-raised on text protocol path
(encode_text returns None for ErrorPart)."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(ModelHTTPError(status_code=503, model_name="test-model"))

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
with pytest.raises(ModelHTTPError):
async for _ in service.stream_text_async([]):
pass

Check warning on line 276 in src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_conversations&issues=AZ5ulM6NJJ9iWLnrYA6v&open=AZ5ulM6NJJ9iWLnrYA6v&pullRequest=495


@pytest.mark.asyncio
async def test_stream_text_async_reraises_connection_error():
"""ModelAPIError is re-raised on text protocol path (encode_text returns None for ErrorPart)."""
conversation = await sync_to_async(ChatConversationFactory)()
service = AIAgentService(conversation, user=conversation.owner)

def mock_run_agent(*args, **kwargs):
return AsyncRaiseIterator(
ModelAPIError(model_name="test-model", message="Connection error.")
)

with patch.object(service, "_run_agent", side_effect=mock_run_agent):
with pytest.raises(ModelAPIError):
async for _ in service.stream_text_async([]):
pass

Check warning on line 293 in src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_conversations&issues=AZ5ulM6NJJ9iWLnrYA6w&open=AZ5ulM6NJJ9iWLnrYA6w&pullRequest=495
4 changes: 4 additions & 0 deletions src/backend/chat/vercel_ai_sdk/encoder/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
Returns:
str | None: The encoded event as a string,
or None if the event type is not adapted to the SDK version.

Note: Only TextPart/TextDeltaEvent are handled. ErrorPart events are silently
dropped on the text protocol path — the `data` protocol (encode()) is the only
supported path for surfacing provider errors to the frontend.
"""
if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart):
return event.text
Expand Down
2 changes: 2 additions & 0 deletions src/backend/conversations/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ class Base(BraveSettings, Configuration):
default=False, environ_name="POSTHOG_MW_CAPTURE_EXCEPTIONS", environ_prefix=None
)

STATUS_PAGE_URL = values.Value(None, environ_name="STATUS_PAGE_URL", environ_prefix=None)

# Crisp
CRISP_WEBSITE_ID = values.Value(None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None)

Expand Down
1 change: 1 addition & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ def get(self, request):
"""
array_settings = [
"ACTIVATION_REQUIRED",
"STATUS_PAGE_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
Expand Down
Loading
Loading