diff --git a/5_django_backend/apps/gateway/views.py b/5_django_backend/apps/gateway/views.py index 673aa2e..dbd2b58 100644 --- a/5_django_backend/apps/gateway/views.py +++ b/5_django_backend/apps/gateway/views.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio import json import logging from typing import AsyncIterator @@ -26,6 +27,7 @@ log = logging.getLogger(__name__) TRANSPORT: httpx.AsyncBaseTransport | None = None # 마지막에 통과한 인증 형식 — 프로세스당 하나. 다음 요청은 이것부터 시도. _last_ok: dict[str, Variant | None] = {"variant": None} +RETRY_PAUSE_S = 1.0 # 마지막 재시도 전 쉬는 시간 def _cfg() -> FabrixConfig: @@ -90,6 +92,9 @@ async def chat_completions(request: HttpRequest) -> HttpResponse: kinds = sorted({p.get("type", "?") for m in payload.get("messages") or [] for p in (m.get("content") if isinstance(m.get("content"), list) else [])}) log.info("ito → model=%s parts=%s stream=%s", headers.get("x-llm-model-id"), kinds or ["text"], bool(body.get("stream"))) variants = auth_variants(headers, _last_ok["variant"]) + # FabriX 가 맞는 조합에도 가끔 401 을 뱉음(2026-09-21 고객사 실측: curl 10번 중 1~2번). + # 4개 조합 다 돌고 나서 제일 유력한 조합(맨 앞)을 잠깐 쉬고 한 번 더 — 그래도 401 이면 진짜 인증 문제 + variants = variants + variants[:1] if body.get("stream"): @@ -103,6 +108,8 @@ async def chat_completions(request: HttpRequest) -> HttpResponse: log.info("ito ← %s (auth %s/%s: %s)", resp.status_code, i + 1, len(variants), vkey) if resp.status_code == 401 and i + 1 < len(variants): await resp.aclose() # 인증 형식이 안 맞은 것 — 다른 조합으로 한 번 더 + if i + 2 == len(variants): + await asyncio.sleep(RETRY_PAUSE_S) continue break assert resp is not None @@ -126,6 +133,8 @@ async def chat_completions(request: HttpRequest) -> HttpResponse: for i, (vkey, vh) in enumerate(variants): resp = await client.post(cfg.url, headers=vh, json=body) if resp.status_code == 401 and i + 1 < len(variants): + if i + 2 == len(variants): + await asyncio.sleep(RETRY_PAUSE_S) continue if resp.status_code < 400: _last_ok["variant"] = vkey diff --git a/5_django_backend/tests/test_gateway.py b/5_django_backend/tests/test_gateway.py index a1904c3..a39bff1 100644 --- a/5_django_backend/tests/test_gateway.py +++ b/5_django_backend/tests/test_gateway.py @@ -126,6 +126,26 @@ async def test_401_retries_other_auth_shape_and_remembers(upstream): assert len(calls) == n + 1 and calls[-1].headers["x-openapi-token"] == "Bearer tok" +@override_settings(FABRIX_ENV=FULL_ENV) +async def test_flaky_401_on_right_auth_is_retried_once(upstream, monkeypatch): + """FabriX 가 맞는 조합에도 가끔 401 — 4개 다 돈 뒤 맨 앞 조합 한 번 더.""" + from apps.gateway import views + + monkeypatch.setattr(views, "RETRY_PAUSE_S", 0) + hits = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + hits["n"] += 1 + if hits["n"] <= 4: + return httpx.Response(401, content=b"flaky") + return httpx.Response(200, json={"choices": []}) + + calls = upstream(handler) + resp = await _post({"messages": []}) + assert resp.status_code == 200 and len(calls) == 5 + assert calls[0].headers.get("x-openapi-token") == calls[4].headers.get("x-openapi-token") + + @override_settings(FABRIX_ENV=FULL_ENV) async def test_stream_upstream_error_becomes_sse_error(upstream): upstream(lambda r: _sse(b"boom", 500))