Skip to content

Commit 11ce211

Browse files
Prajna1999claude
andauthored
feat(retry): Retry webhooks, client LLM proxy and guardrails calls (#1275)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f657806 commit 11ce211

16 files changed

Lines changed: 442 additions & 72 deletions

File tree

‎backend/app/core/retry.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import httpx
2+
import requests
3+
4+
5+
def is_transient(error: BaseException) -> bool:
6+
"""5xx from upstream, or a connection that never reached it."""
7+
if isinstance(error, (httpx.HTTPStatusError, requests.HTTPError)):
8+
return error.response is not None and error.response.status_code >= 500
9+
return isinstance(
10+
error, (httpx.ConnectError, httpx.ConnectTimeout, requests.ConnectionError)
11+
)

‎backend/app/services/llm/guardrails.py‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import httpx
1010
from fastapi import HTTPException
1111
from opentelemetry import trace
12+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
1213

1314
from app.core.config import settings
15+
from app.core.retry import is_transient
1416
from app.models.llm.request import InlineValidator, InlineValidatorTypeEnum, Validator
1517

1618
logger = logging.getLogger(__name__)
@@ -91,7 +93,13 @@ def proxy_guardrails_request(
9193
),
9294
httpx.Client(timeout=GUARDRAILS_PROXY_TIMEOUT_SECONDS) as client,
9395
):
94-
response = client.request(
96+
# Status isn't raised, so only connect failures retry: safe for any method.
97+
response = retry(
98+
stop=stop_after_attempt(4),
99+
wait=wait_exponential(max=10),
100+
retry=retry_if_exception(is_transient),
101+
reraise=True,
102+
)(client.request)(
95103
method, url, params=query, json=json_body, headers=headers
96104
)
97105
except httpx.RequestError as e:
@@ -371,19 +379,29 @@ def run_guardrails_validation(
371379
),
372380
httpx.Client(timeout=45.0) as client,
373381
):
374-
response = client.post(
375-
url,
376-
json=payload,
377-
params={"suppress_pass_logs": str(suppress_pass_logs).lower()},
378-
headers=headers,
379-
)
380-
elapsed_ms = int((time.monotonic() - started) * 1000)
381-
logger.info(
382-
f"[run_guardrails_validation] Response received | job_id={job_id}, "
383-
f"status={response.status_code}, elapsed_ms={elapsed_ms}, "
382+
383+
@retry(
384+
stop=stop_after_attempt(4),
385+
wait=wait_exponential(max=10),
386+
retry=retry_if_exception(is_transient),
387+
reraise=True,
384388
)
385-
response.raise_for_status()
386-
return response.json()
389+
def _post() -> dict[str, Any]:
390+
response = client.post(
391+
url,
392+
json=payload,
393+
params={"suppress_pass_logs": str(suppress_pass_logs).lower()},
394+
headers=headers,
395+
)
396+
elapsed_ms = int((time.monotonic() - started) * 1000)
397+
logger.info(
398+
f"[run_guardrails_validation] Response received | job_id={job_id}, "
399+
f"status={response.status_code}, elapsed_ms={elapsed_ms}, "
400+
)
401+
response.raise_for_status()
402+
return response.json()
403+
404+
return _post()
387405
except Exception as e:
388406
elapsed_ms = int((time.monotonic() - started) * 1000)
389407
if _is_auth_error(e):
@@ -458,6 +476,12 @@ def _build_params(validator_ids: list[UUID]) -> dict[str, Any]:
458476
httpx.Client(timeout=10.0) as client,
459477
):
460478

479+
@retry(
480+
stop=stop_after_attempt(4),
481+
wait=wait_exponential(max=10),
482+
retry=retry_if_exception(is_transient),
483+
reraise=True,
484+
)
461485
def _fetch_by_ids(validator_ids: list[UUID]) -> list[dict[str, Any]]:
462486
if not validator_ids:
463487
return []

‎backend/app/services/llm/jobs.py‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
from gevent import Timeout
1717
from opentelemetry import trace
1818
from sqlmodel import Session
19+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
1920

2021
from app.celery.utils import start_llm_chain_job, start_llm_job
2122
from app.core.db import engine
22-
from app.models.provider_credentials import Provider
23+
from app.core.providers import Provider
24+
from app.core.retry import is_transient
2325
from app.core.langfuse.langfuse import observe_llm_execution
2426
from app.core.telemetry import (
2527
LogModuleEnum,
@@ -860,6 +862,28 @@ def _run_output_guardrails(
860862
return result
861863

862864

865+
# ReadTimeout isn't retried: the proxy may already have run (and billed) the call.
866+
@retry(
867+
stop=stop_after_attempt(4),
868+
wait=wait_exponential(max=10),
869+
retry=retry_if_exception(is_transient),
870+
reraise=True,
871+
)
872+
def _post_to_proxy(url: str, api_key: str, input_text: str) -> dict:
873+
with httpx.Client(timeout=60.0) as client:
874+
resp = client.post(
875+
url,
876+
headers={
877+
"Authorization": f"Bearer {api_key}",
878+
"Content-Type": "application/json",
879+
"Accept": "application/json",
880+
},
881+
json={"input": input_text},
882+
)
883+
resp.raise_for_status()
884+
return resp.json()
885+
886+
863887
def _execute_proxy_call(
864888
session: Session,
865889
*,
@@ -919,6 +943,10 @@ def _execute_proxy_call(
919943
)
920944
return BlockResult(error=f"Failed to create LLM call record: {str(e)}")
921945

946+
# End the read transaction so no pooled connection sits idle during the proxy
947+
# request and its retry backoff; nothing is pending, so this only releases it.
948+
session.commit()
949+
922950
ctx.metrics_started(provider=Provider.PROXY.value, model="")
923951
proxy_data: dict | None = None
924952

@@ -928,18 +956,9 @@ def _execute_proxy_call(
928956

929957
try:
930958
with suppress_http_instrumentation():
931-
with httpx.Client(timeout=60.0) as client:
932-
resp = client.post(
933-
client_llm_url,
934-
headers={
935-
"Authorization": f"Bearer {creds['api_key']}",
936-
"Content-Type": "application/json",
937-
"Accept": "application/json",
938-
},
939-
json={"input": query.input.content.value},
940-
)
941-
resp.raise_for_status()
942-
proxy_data = resp.json()
959+
proxy_data = _post_to_proxy(
960+
client_llm_url, creds["api_key"], query.input.content.value
961+
)
943962
except httpx.HTTPError as e:
944963
proxy_span.record_exception(e)
945964
proxy_span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
@@ -951,7 +970,8 @@ def _execute_proxy_call(
951970
return BlockResult(
952971
error=f"Proxy call failed: {str(e)}",
953972
llm_call_id=ctx.llm_call_id,
954-
retryable=True,
973+
# Transient failures were already retried; don't stack fast-eval retries.
974+
retryable=not is_transient(e),
955975
)
956976

957977
try:

‎backend/app/tests/conftest.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ def aws_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
8888
monkeypatch.setenv("AWS_DEFAULT_REGION", settings.AWS_DEFAULT_REGION)
8989

9090

91+
@pytest.fixture
92+
def no_retry_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
93+
"""Tenacity binds nap.sleep at import (incl. nested decorators), so patch time.sleep."""
94+
monkeypatch.setattr("tenacity.nap.time.sleep", lambda _seconds: None)
95+
96+
9197
@pytest.fixture(scope="function")
9298
def client(db: Session) -> Generator[TestClient, None, None]:
9399
app.dependency_overrides[get_db] = lambda: db

‎backend/app/tests/core/test_callback_ssrf.py‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import itertools
12
import json
23
import socket
34
from typing import Any
@@ -16,6 +17,8 @@
1617
sign_webhook_payload,
1718
)
1819

20+
pytestmark = pytest.mark.usefixtures("no_retry_backoff")
21+
1922

2023
class TestIsPrivateIP:
2124
"""Test suite for _is_private_ip function."""
@@ -237,7 +240,7 @@ def test_successful_callback(
237240
"""Test successful callback execution."""
238241
mock_session = MagicMock()
239242
mock_response = MagicMock()
240-
mock_response.raise_for_status.return_value = None
243+
mock_response.status_code = 200
241244
mock_response.iter_content.return_value = [b"test"]
242245
mock_session.post.return_value = mock_response
243246
mock_session_class.return_value.__enter__.return_value = mock_session
@@ -272,7 +275,7 @@ def test_callback_http_error(
272275
"""Test that callback returns False on HTTP errors."""
273276
mock_session = MagicMock()
274277
mock_response = MagicMock()
275-
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
278+
mock_response.status_code = 404
276279
mock_session.post.return_value = mock_response
277280
mock_session_class.return_value.__enter__.return_value = mock_session
278281

@@ -288,7 +291,7 @@ def test_callback_disables_redirects(
288291
"""Test that redirects are disabled to prevent redirect-based SSRF."""
289292
mock_session = MagicMock()
290293
mock_response = MagicMock()
291-
mock_response.raise_for_status.return_value = None
294+
mock_response.status_code = 200
292295
mock_response.iter_content.return_value = [b"test"]
293296
mock_session.post.return_value = mock_response
294297
mock_session_class.return_value.__enter__.return_value = mock_session
@@ -306,7 +309,7 @@ def test_callback_uses_timeout(
306309
"""Test that callback uses configured timeouts."""
307310
mock_session = MagicMock()
308311
mock_response = MagicMock()
309-
mock_response.raise_for_status.return_value = None
312+
mock_response.status_code = 200
310313
mock_response.iter_content.return_value = [b"test"]
311314
mock_session.post.return_value = mock_response
312315
mock_session_class.return_value.__enter__.return_value = mock_session
@@ -326,7 +329,7 @@ def test_callback_sends_json_data(
326329
"""Test that callback sends data as JSON."""
327330
mock_session = MagicMock()
328331
mock_response = MagicMock()
329-
mock_response.raise_for_status.return_value = None
332+
mock_response.status_code = 200
330333
mock_response.iter_content.return_value = [b"test"]
331334
mock_session.post.return_value = mock_response
332335
mock_session_class.return_value.__enter__.return_value = mock_session
@@ -378,3 +381,57 @@ def test_signature_matches_manual_hmac(self):
378381
sig, returned_ts = sign_webhook_payload(secret, body, timestamp_ms=ts)
379382
assert sig == expected
380383
assert returned_ts == ts
384+
385+
386+
def _callback_response(status_code: int) -> requests.Response:
387+
response = requests.Response()
388+
response.status_code = status_code
389+
return response
390+
391+
392+
@patch("app.utils.validate_callback_url")
393+
class TestSendCallbackRetry:
394+
URL = "https://api.example.com/callback"
395+
396+
def test_recovers_after_transient_failures(self, _validate: Any) -> None:
397+
# 302 counts as a failure: redirects are off, so nothing was delivered.
398+
outcomes = [
399+
_callback_response(302),
400+
requests.ConnectionError("reset"),
401+
_callback_response(200),
402+
]
403+
with patch.object(requests.Session, "post", side_effect=outcomes) as post:
404+
assert send_callback(self.URL, {"status": "done"}) is True
405+
assert post.call_count == 3
406+
407+
@pytest.mark.parametrize("status_code", [503, 404])
408+
def test_gives_up_after_four_attempts(
409+
self, _validate: Any, status_code: int
410+
) -> None:
411+
with patch.object(
412+
requests.Session, "post", return_value=_callback_response(status_code)
413+
) as post:
414+
assert send_callback(self.URL, {"status": "done"}) is False
415+
assert post.call_count == 4
416+
417+
def test_each_attempt_is_signed_with_a_fresh_timestamp(
418+
self, _validate: Any
419+
) -> None:
420+
with (
421+
patch("app.utils.time.time", side_effect=itertools.count(1_700_000_000)),
422+
patch.object(
423+
requests.Session, "post", return_value=_callback_response(503)
424+
) as post,
425+
):
426+
send_callback(self.URL, {"status": "done"}, webhook_secret="s3cret")
427+
428+
timestamps = {
429+
c.kwargs["headers"]["X-Webhook-Timestamp"] for c in post.mock_calls
430+
}
431+
assert len(timestamps) == 4
432+
433+
def test_invalid_url_is_never_posted(self, validate: Any) -> None:
434+
validate.side_effect = ValueError("private IP")
435+
with patch.object(requests.Session, "post") as post:
436+
assert send_callback(self.URL, {"status": "done"}) is False
437+
post.assert_not_called()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import httpx
2+
import pytest
3+
4+
from app.core.retry import is_transient
5+
6+
_REQUEST = httpx.Request("POST", "https://upstream.example/v1")
7+
8+
9+
def _status_error(code: int) -> httpx.HTTPStatusError:
10+
return httpx.HTTPStatusError(
11+
"upstream", request=_REQUEST, response=httpx.Response(code, request=_REQUEST)
12+
)
13+
14+
15+
@pytest.mark.parametrize(
16+
"error, expected",
17+
[
18+
(_status_error(500), True),
19+
(_status_error(404), False),
20+
(httpx.ConnectError("refused"), True),
21+
(httpx.ConnectTimeout("slow handshake"), True),
22+
# The request may have reached upstream, so a retry could double-execute.
23+
(httpx.ReadTimeout("no response"), False),
24+
],
25+
)
26+
def test_is_transient(error: BaseException, expected: bool) -> None:
27+
assert is_transient(error) is expected

‎backend/app/tests/services/llm/test_execute_llm_call.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from app.services.llm.jobs import execute_llm_call
2424
from app.tests.utils.utils import get_project
2525

26+
pytestmark = pytest.mark.usefixtures("no_retry_backoff")
27+
2628
VALIDATOR_CONFIG_ID = "00000000-0000-0000-0000-000000000001"
2729
PROXY_URL = "https://api.tap.example/v1/predictions"
2830

@@ -381,13 +383,22 @@ def test_provider_failure_is_retryable(self, db: Session, provider):
381383

382384
assert result.retryable is True
383385

384-
def test_proxy_transport_failure_is_retryable(
386+
def test_proxy_connect_failure_is_not_retryable_after_inline_retries(
385387
self, db: Session, provider, proxy_http
386388
):
387389
proxy_http.post.side_effect = httpx.ConnectError("connection refused")
388390

389391
result = call(db, build_blob(PROXY_COMPLETION))
390392

393+
assert result.error.startswith("Proxy call failed:")
394+
assert result.retryable is False
395+
396+
def test_proxy_4xx_is_retryable(self, db: Session, provider, proxy_http):
397+
request = httpx.Request("POST", PROXY_URL)
398+
proxy_http.post.return_value = httpx.Response(400, request=request)
399+
400+
result = call(db, build_blob(PROXY_COMPLETION))
401+
391402
assert result.error.startswith("Proxy call failed:")
392403
assert result.retryable is True
393404

0 commit comments

Comments
 (0)