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
12 changes: 8 additions & 4 deletions src/gateway/api/routes/_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@
T = TypeVar("T")

# Status codes returned by the platform's usage-report endpoint that the
# gateway should NOT retry. Auth / not-found / conflict / unprocessable are
# all permanent rejection signals — retrying would just hammer the platform.
_USAGE_NON_RETRYABLE_STATUS_CODES = {401, 404, 409, 422}
# gateway should NOT retry. Auth / payment-required / not-found / conflict /
# unprocessable are all permanent rejection signals — retrying would just
# hammer the platform (an overdrawn or missing wallet won't recover within the
# retry window). 402 is already excluded by the >= 500 retry predicate below;
# listing it keeps the intent explicit and robust to changes in that predicate.
_USAGE_NON_RETRYABLE_STATUS_CODES = {401, 402, 404, 409, 422}
Comment thread
khaledosman marked this conversation as resolved.

# Status codes that cause the gateway to move on to the next attempt in a
# multi-attempt route. 401/403 are included because users configure
Expand Down Expand Up @@ -584,7 +587,8 @@ async def _report_platform_usage(

Best-effort — failures are swallowed after ``max_retries`` so they don't
impact the user's response path. Non-retryable status codes (auth /
not-found / conflict / unprocessable) short-circuit the retry loop.
payment-required / not-found / conflict / unprocessable) short-circuit the
retry loop.
"""
platform_base_url = config.platform.get("base_url")
if not platform_base_url:
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/test_run_platform_attempts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@

from __future__ import annotations

from typing import Any
import asyncio
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock

import httpx
import pytest
from fastapi import HTTPException

from gateway.api.routes import _platform
from gateway.api.routes._platform import ResolvedRoute, run_platform_attempts
from gateway.core.config import GatewayConfig


@pytest.mark.asyncio
Expand Down Expand Up @@ -43,3 +49,31 @@ async def _never_called(_kwargs: dict[str, Any], _on_first_response: Any) -> Any
)
assert ei.value.status_code == 500
assert "empty attempts list" in ei.value.detail


@pytest.mark.asyncio
async def test_report_platform_usage_does_not_retry_on_402(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 402 from the usage-report endpoint is a permanent rejection (the org
wallet is overdrawn or missing and won't recover within the retry window).
The gateway must POST once and give up, never retry."""
config = cast(
GatewayConfig,
SimpleNamespace(
platform={"base_url": "http://platform", "usage_max_retries": 3},
platform_token="gw-test",
),
)

post_mock = AsyncMock(return_value=httpx.Response(402))
monkeypatch.setattr(_platform, "_post_platform", post_mock)
sleep_mock = AsyncMock()
monkeypatch.setattr(asyncio, "sleep", sleep_mock)

await _platform._report_platform_usage(config, "corr-1", "success", None)
Comment thread
khaledosman marked this conversation as resolved.

assert post_mock.call_count == 1
sleep_mock.assert_not_awaited()
# Pin the classification itself, not just the (currently equivalent) retry
# behaviour: 402 must stay in the non-retryable set even if the >= 500 retry
# predicate changes.
assert 402 in _platform._USAGE_NON_RETRYABLE_STATUS_CODES
Loading