Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 24 additions & 3 deletions gramps_webapi/api/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
from collections.abc import Callable
from typing import Any

import httpx
from flask import current_app
from pydantic_ai import ModelMessagesTypeAdapter
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior, UsageLimitExceeded
from pydantic_ai.exceptions import (
ModelAPIError,
ModelHTTPError,
ModelRetry,
UnexpectedModelBehavior,
UsageLimitExceeded,
)
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
Expand Down Expand Up @@ -180,9 +187,23 @@ def answer_with_agent(
except UsageLimitExceeded as e:
logger.warning("Agent usage limit exceeded: %s", e)
abort_with_message(429, "The AI agent exceeded its usage limits for this request.")
except ModelHTTPError as e:
logger.error("Model provider returned an error: %s", e)
if e.status_code == 429:
abort_with_message(
429, "The AI model provider is rate limiting requests. Please try again later."
)
if e.status_code in (502, 503, 504, 529):
# 529 is Anthropic's "overloaded"
abort_with_message(503, "The AI model provider is temporarily unavailable.")
abort_with_message(502, "The AI model provider returned an error.")
Comment thread
DavidMStraub marked this conversation as resolved.
except (ModelAPIError, httpx.TransportError) as e:
# network failure or timeout talking to the provider
logger.error("Model provider request failed: %r", e)
abort_with_message(504, "The AI model did not respond. Please try again.")
except (UnexpectedModelBehavior, ModelRetry) as e:
logger.error("Pydantic AI error: %s", e)
abort_with_message(500, "Error communicating with the AI model")
except Exception as e:
logger.error("Unexpected error in agent: %s", e)
except Exception:
logger.exception("Unexpected error in agent")
abort_with_message(500, "Unexpected error.")
70 changes: 70 additions & 0 deletions tests/test_llm_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#
# Gramps Web API - A RESTful API for the Gramps genealogy program
#
# Copyright (C) 2026 David Straub
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#

"""Tests for how agent failures are mapped to HTTP status codes."""

import httpx
import pytest
from flask import Flask
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior
from werkzeug.exceptions import HTTPException

from gramps_webapi.api.llm import answer_with_agent


@pytest.fixture(name="app")
def fixture_app():
"""A minimal app providing the LLM config."""
app = Flask(__name__)
app.config.update(LLM_MODEL="test-model", LLM_BASE_URL=None, LLM_SYSTEM_PROMPT=None)
return app


@pytest.mark.parametrize(
"error,status",
[
(httpx.ReadTimeout(""), 504),
(ModelAPIError(model_name="test-model", message="connection error"), 504),
(ModelHTTPError(status_code=429, model_name="test-model"), 429),
(ModelHTTPError(status_code=503, model_name="test-model"), 503),
(ModelHTTPError(status_code=400, model_name="test-model"), 502),
(ModelHTTPError(status_code=401, model_name="test-model"), 502),
(UnexpectedModelBehavior("garbage"), 500),
(RuntimeError("boom"), 500),
],
)
def test_agent_error_status_codes(app, monkeypatch, error, status):
"""Failures from the model provider get a status code of their own."""

class _Agent:
def run_sync(self, *args, **kwargs):
raise error

monkeypatch.setattr("gramps_webapi.api.llm.create_agent", lambda **kwargs: _Agent())

with app.app_context():
with pytest.raises(HTTPException) as excinfo:
answer_with_agent(
prompt="Who was my grandmother?",
tree="tree",
include_private=False,
user_id="user",
)

assert excinfo.value.code == status