생성 연결이 꽉 차도 상태를 확인할 수 있는가?
기존 문제가 생기는 조건을 그대로 만들고 /readyz의 실제 응답을 확인한다. 모델을 실행하지 않지만 TCP 연결, HTTP client 풀, application factory, lifespan, Adapter와 route는 실제 구현을 사용한다.
1. 이 파일을 만든 이유
생성 요청 네 개가 오래 걸리는 동안 FastAPI의 상태 검사가 503이 되는 문제가 있었다. 원인은 모델 장애가 아니라 같은 HTTP 연결 풀을 기다리다 생긴 PoolTimeout이었다. 단순한 MockTransport는 TCP 풀을 점유하지 않으므로 이 문제를 재현하는 증거가 되지 못한다.
2. 전체 구조에서 검증하는 경계
/readyz route를 직접 호출
모의 Adapter로 교체하지 않음
생성 4개와 probe 2개 분리
POST 대기, GET 즉시 응답
가짜 서버는 Gemma의 계산 대신 미리 정한 JSON을 반환한다. 여기서 필요한 것은 토큰 품질이 아니라 “응답을 기다리는 연결이 실제로 점유되는가”이기 때문이다.
3. 입력과 예상 출력
| 입력 조건 | 생성 상태 | /readyz 결과 |
|---|---|---|
| 설정과 같은 model ID | 4개 모두 응답 대기 중 | 200, ready, error_code=null |
| different-model | 4개 모두 응답 대기 중 | 503, not_ready, model_mismatch |
두 경우 모두 /healthz는 200이어야 하며, 생성 응답을 해제하면 네 요청이 모두 OK로 완료되어야 한다. 모델 ID가 틀린 경우도 검사해야 “연결 분리 대신 readiness를 무조건 200으로 바꾸는 잘못된 수정”을 잡을 수 있다.
4. 중요한 코드를 구간별로 읽기
HeldGenerationServer: 실제 연결을 어떻게 붙잡는가?
handle()은 HTTP header와 body 길이를 읽은 뒤 POST 요청이면 카운터를 늘리고 await self.release.wait()에서 응답을 보류한다. 네 번째 POST가 도착했을 때 all_occupied.set()을 실행한다. 임의로 몇 초 자고 “아마 점유됐겠지”라고 추측하지 않고 조건이 실제로 성립한 다음 검사를 시작한다.
GET /health에는 정상 상태, GET /v1/models에는 설정한 목록을 즉시 보낸다. probe_paths에는 실제로 받은 GET 경로를 기록하므로 readiness가 두 endpoint를 정말 확인했는지 검증할 수 있다.
running(): 포트 충돌과 남은 작업을 어떻게 막는가?
asyncio.start_server(..., "127.0.0.1", 0)은 같은 Mac 안에 임시 포트를 연다. 0번에 접속한다는 뜻이 아니라 운영체제가 빈 포트 번호를 골라 준다는 뜻이다. 실제 번호를 읽어 Settings에 주입한다.
종료 시 release를 설정하고 남은 연결 처리 Task를 취소한 다음 gather로 종료를 기다린다. 테스트가 실패하더라도 임시 서버와 작업이 남지 않도록 finally에 둔다.
scenario(): 왜 진짜 lifespan에 들어가는가?
Adapter만 수동으로 만들면 main.py에서 두 client를 잘못 조립한 버그를 놓칠 수 있다. 실제 create_app()을 호출하고 application.router.lifespan_context(application)에 진입해 production과 같은 방식으로 자원을 만든다. ASGITransport 자체는 이 준비를 자동 수행하지 않는다.
연결 대기는 테스트에서 0.25초, probe 요청 전체는 2초, 생성은 10초로 설정한다. 바깥의 3초 wait_for는 테스트 자체가 무한히 멈추는 것을 막는다. 제품 기본값을 바꾸는 설정이 아니다.
5. 화면 대신 작업 상태가 바뀌는 순서
이 파일은 UI 코드를 바꾸지 않는다. 관찰하는 state는 생성 Task의 완료 여부, 서버의 카운터, Event와 기록된 요청 경로다.
6. 실제 실행 결과와 의미
수정 전에는 정상 모델에서도 503이 나왔고, 다른 모델에서도 model_mismatch 대신 busy가 나와 두 경우 모두 실패했다. 연결 분리 후 두 테스트가 통과했다. 전체 Backend 검증은 2026-09-06 기준 47 passed다.
이는 생성용 연결 점유가 probe를 막지 않는다는 증거다. upstream 자체의 health 응답이 느리거나 probe끼리 포화되면 실패할 수 있으며, 실제 Gemma의 처리량·품질을 검증한 실험은 아니다.
7. 자주 생길 수 있는 오류
샌드박스에서 PermissionError가 나면 127.0.0.1 임시 포트 bind 권한을 확인한다. all_occupied 대기 초과는 네 요청이 서버까지 도착하지 못했다는 뜻이므로 연결 수 설정이나 앞선 Task 오류를 확인한다. HTTP 503과 busy가 재발하면 생성·probe client가 같은 풀을 공유하도록 바뀌지 않았는지 확인한다.
현재 전체 코드
"""Exercise real connection pools against a model-free loopback HTTP server."""
import asyncio
import json
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import httpx2
import pytest
from app.core.config import Settings
from app.engines.base import GenerationRequest, Message
from app.main import create_app
MODEL = "gemma-4-26b-a4b-it"
class HeldGenerationServer:
"""Keep four POST responses pending while still answering GET probes."""
def __init__(self, advertised_model: str) -> None:
self.advertised_model = advertised_model
self.release = asyncio.Event()
self.all_occupied = asyncio.Event()
self.generation_count = 0
self.probe_paths: list[str] = []
self.handlers: set[asyncio.Task] = set()
async def handle(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
) -> None:
task = asyncio.current_task()
assert task is not None
self.handlers.add(task)
try:
while True:
head = await reader.readuntil(b"\r\n\r\n")
first, *headers = head.decode("ascii").split("\r\n")
method, path, _ = first.split()
size = next((
int(header.split(":", 1)[1])
for header in headers
if header.lower().startswith("content-length:")
), 0)
await reader.readexactly(size)
if method == "POST" and path == "/v1/chat/completions":
self.generation_count += 1
if self.generation_count == 4:
self.all_occupied.set()
await self.release.wait()
body = {
"id": "chatcmpl-local-test", "object": "chat.completion",
"model": MODEL,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "OK"},
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": 1, "completion_tokens": 1,
"total_tokens": 2,
"prompt_tokens_details": {"cached_tokens": 0},
},
}
else:
self.probe_paths.append(path)
body = {"status": "ok"} if path == "/health" else {
"object": "list",
"data": [{"id": self.advertised_model, "object": "model"}],
}
payload = json.dumps(body).encode()
writer.write(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
+ f"Content-Length: {len(payload)}\r\n\r\n".encode()
+ payload
)
await writer.drain()
except (asyncio.IncompleteReadError, ConnectionError):
pass
finally:
writer.close()
await writer.wait_closed()
self.handlers.discard(task)
@asynccontextmanager
async def running(self) -> AsyncGenerator[str, None]:
server = await asyncio.start_server(self.handle, "127.0.0.1", 0)
async with server:
try:
port = server.sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
finally:
self.release.set()
tasks = tuple(self.handlers)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
@pytest.mark.parametrize(
("advertised_model", "expected_status", "expected_error"),
[(MODEL, 200, None), ("different-model", 503, "model_mismatch")],
)
def test_readiness_probes_run_while_all_generation_connections_are_held(
advertised_model: str, expected_status: int, expected_error: str | None,
) -> None:
async def scenario() -> None:
upstream = HeldGenerationServer(advertised_model)
async with upstream.running() as url:
application = create_app(Settings(
_env_file=None, environment="test", log_level="WARNING",
inference_base_url=url, inference_model=MODEL,
inference_connect_timeout=0.25,
inference_probe_timeout=2, inference_generation_timeout=10,
))
# Use the real factory, lifespan, adapter and /readyz dependency.
async with application.router.lifespan_context(application):
engine = application.state.inference_engine
request = GenerationRequest(
messages=(Message(role="user", content="Synthetic test"),),
max_output_tokens=1,
)
generations = [asyncio.create_task(engine.generate(request)) for _ in range(4)]
try:
await asyncio.wait_for(upstream.all_occupied.wait(), timeout=3)
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=application),
base_url="http://backend.test", trust_env=False,
) as browser:
assert (await browser.get("/healthz")).status_code == 200
response = await asyncio.wait_for(browser.get("/readyz"), timeout=3)
assert all(not task.done() for task in generations)
assert response.status_code == expected_status
assert response.json() == {
"status": "ready" if expected_status == 200 else "not_ready",
"dependency": "inference", "error_code": expected_error,
}
assert upstream.probe_paths == ["/health", "/v1/models"]
upstream.release.set()
results = await asyncio.wait_for(asyncio.gather(*generations), timeout=3)
assert [result.text for result in results] == ["OK"] * 4
finally:
upstream.release.set()
for task in generations:
task.cancel()
await asyncio.gather(*generations, return_exceptions=True)
asyncio.run(scenario())실제 테스트 소스와 동일한 코드다. 실행 명령: cd be 후 .venv/bin/python -m pytest -q tests/test_readiness_isolation.py.
8. 다음 파일과의 연결
실제 시간 초과·취소 검증에서 다른 경계의 보장을 확인한다. 전체 구조는 Backend 개요, client 조립은 main.py, 오류 번역은 Adapter 구현과 연결된다.