상태 endpoint가 돌려줄 JSON을
미리 못 박아 둔다
Route 함수는 dict를 그냥 반환할 수도 있다. 이 파일은 그러지 않기로 한 선택이며, 그 대가로 오타·누락·잘못된 값이 배포가 아니라 실행 즉시 드러난다.
이 파일을 만든 이유
/healthz가 {"status": "ok"}를 돌려준다고 하자. dict로 직접 만들면 "stauts"라고 오타를 내도 Python은 아무 말 없이 그 dict를 JSON으로 내보낸다. 오류는 이 API를 소비하는 쪽 — Kubernetes probe나 monitoring — 에서야 드러난다.
Pydantic model로 정의하면 필드 이름과 허용 값이 객체를 만드는 순간 검증된다. 동시에 이 class가 /openapi.json의 schema가 되어 /docs 화면과 client 코드 생성의 근거가 된다. 문서와 검증을 한 곳에서 얻는 것이 목적이다.
전체 구조에서의 위치
engines/base.py의 GenerationRequest·ModelInfo가 FastAPI와 추론 engine 사이의 계약이라면, 이 파일은 FastAPI와 바깥 client 사이의 계약이다. 방향이 반대다.
전체 코드
from typing import Literal
from pydantic import BaseModel, ConfigDict
from app.engines.base import InferenceErrorCode
class HealthResponse(BaseModel):
"""Stable JSON contract returned by the liveness endpoint."""
model_config = ConfigDict(frozen=True)
status: Literal["ok"]
service: str
version: str
environment: Literal["development", "test", "production"]
class ReadinessResponse(BaseModel):
"""Dependency reachability, not a generation or queue-capacity guarantee."""
model_config = ConfigDict(frozen=True)
status: Literal["ready", "not_ready"]
dependency: Literal["inference"] = "inference"
error_code: InferenceErrorCode | None = None
import 해설
from typing import Literal
Literal은 “이 값들 중 하나”를 뜻하는 type이다. str이라고만 쓰면 status에 어떤 문자열이든 들어간다. Literal["ok"]라고 쓰면 "ok" 외의 값은 Pydantic이 거부한다. 순수 Python 함수의 인수라면 Literal은 정적 검사기에만 힌트를 주지만, Pydantic field에서는 실행 시점 검증으로 이어진다.
from pydantic import BaseModel, ConfigDict
BaseModel을 상속하면 그 class는 필드 검증·JSON 직렬화·schema 생성 능력을 얻는다. ConfigDict는 그 동작을 조절하는 설정 객체다.
from app.engines.base import InferenceErrorCode
추론 실패 코드 목록을 다시 적지 않고 가져온다. engine 쪽에 context_too_long 같은 코드가 추가되면 이 응답 schema도 자동으로 따라간다. 두 곳에 각각 적으면 반드시 어긋난다.
HealthResponse — 프로세스가 살아 있는가
class HealthResponse(BaseModel):
"""Stable JSON contract returned by the liveness endpoint."""
model_config = ConfigDict(frozen=True)
status: Literal["ok"]
service: str
version: str
environment: Literal["development", "test", "production"]
model_config = ConfigDict(frozen=True)
frozen은 만들어진 뒤 필드 재할당을 막는다. 응답 객체는 만들어서 그대로 내보내는 값이므로 중간에 바뀔 이유가 없다. 실수로 route에서 response.status = "degraded"라고 쓰면 그 자리에서 예외가 난다. 다만 이는 깊은 동결이 아니다 — 필드가 list나 dict라면 그 내용물은 여전히 바뀔 수 있다. 이 model은 문자열만 쓰므로 문제되지 않는다.
status: Literal["ok"]
값이 하나뿐인 Literal이다. 즉 이 endpoint는 성공 응답만 이 형태로 만든다. 프로세스가 죽으면 애초에 응답 자체가 없으므로 "down" 같은 값이 필요 없다. “살아 있다고 대답할 수 있다는 사실” 자체가 답이다.
service·version: str
값의 종류를 미리 알 수 없으므로 자유 문자열이다. request.app.state.settings에서 읽어 채운다. 여러 container가 떠 있을 때 어떤 build가 응답했는지 구분하는 용도다.
environment: Literal[...]
Settings의 같은 이름 필드와 동일한 세 값으로 제한된다. production container가 실수로 development 설정을 들고 떴는지 응답만 보고 알 수 있다.
ReadinessResponse — 의존성에 닿는가
class ReadinessResponse(BaseModel):
"""Dependency reachability, not a generation or queue-capacity guarantee."""
model_config = ConfigDict(frozen=True)
status: Literal["ready", "not_ready"]
dependency: Literal["inference"] = "inference"
error_code: InferenceErrorCode | None = None
status: Literal["ready", "not_ready"]
이번에는 값이 둘이다. /readyz는 실패해도 응답을 돌려주기 때문이다. 추론 서버가 꺼져 있어도 FastAPI 자신은 살아 있으므로 503과 함께 not_ready를 답할 수 있다.
dependency: Literal["inference"] = "inference"
기본값이 있으므로 route에서 넘기지 않아도 채워진다. 지금은 확인하는 의존성이 추론 서버 하나뿐이라 값도 하나다. PostgreSQL이 추가되는 5단계에서 이 Literal에 "database"가 늘어나는 것이 예정된 확장 지점이다. 필드를 미리 둔 이유는 그때 응답 형태가 통째로 바뀌지 않게 하기 위해서다.
error_code: InferenceErrorCode | None = None
X | None은 “값이 있거나 없거나”다. 성공하면 None, 실패하면 "unavailable"·"timeout"·"model_mismatch" 같은 정해진 코드가 들어간다. 상세 메시지가 아니라 코드만 내보내는 것이 핵심이다. 추론 서버가 돌려준 원문에는 prompt 일부나 내부 경로가 섞일 수 있으므로 그대로 노출하지 않는다.
실제 값으로 따라가기
두 endpoint가 만들어내는 JSON을 실제 값으로 확인한다.
GET /healthz → 200
{"status": "ok", "service": "local-moe-backend", "version": "0.1.0",
"environment": "development"}
GET /readyz → 200
{"status": "ready", "dependency": "inference", "error_code": null}
GET /healthz → 200 ← FastAPI 자신은 여전히 살아 있다
{"status": "ok", ...}
GET /readyz → 503
{"status": "not_ready", "dependency": "inference",
"error_code": "unavailable"}
error_code가 Python의 None일 때 JSON에서는 null로 나간다. 필드가 사라지는 것이 아니라 항상 존재하고 값이 null이다. 소비하는 쪽이 키 존재 여부를 검사하지 않아도 된다.
검증이 실제로 작동하는지 관찰하기
cd be
.venv/bin/python -c "
from app.schemas.system import HealthResponse, ReadinessResponse
try:
HealthResponse(status='degraded', service='a', version='b', environment='test')
except Exception as error:
print('거부됨:', type(error).__name__)
ok = ReadinessResponse(status='ready')
print('기본값 채워짐:', ok.model_dump())
try:
ok.status = 'not_ready'
except Exception as error:
print('frozen:', type(error).__name__)
"
거부됨: ValidationError
기본값 채워짐: {'status': 'ready', 'dependency': 'inference', 'error_code': None}
frozen: ValidationError
세 가지가 한 번에 증명된다. Literal이 허용되지 않은 값을 막고, 기본값이 자동으로 채워지고, frozen이 사후 변경을 막는다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
ResponseValidationError 500 | route가 schema와 다른 값을 반환 | 응답 model과 return 값을 대조한다. 클라이언트 잘못이 아니라 서버 버그다 |
| 필드가 응답에서 사라짐 | response_model에 없는 키를 dict로 반환 | FastAPI는 model에 선언된 필드만 내보낸다. 추가하려면 schema를 먼저 고친다 |
error_code가 늘 null | route가 예외를 삼키고 ready를 반환 | routes/system.py의 except InferenceError 구간을 확인한다 |
| ImportError: InferenceErrorCode | engines/base.py에서 이름이 바뀜 | 응답 schema가 engine 계약을 참조하므로 함께 고쳐야 한다 |
설계 선택과 대안
dict를 그대로 반환하면 안 되는가?
동작은 한다. 잃는 것은 세 가지다. 첫째 오타를 잡아주지 않는다. 둘째 /openapi.json에 응답 형태가 실리지 않아 /docs가 비어 보인다. 셋째 test_health.py가 검사하는 components.schemas.HealthResponse가 존재하지 않는다.
왜 두 model을 하나로 합치지 않는가?
두 endpoint는 질문이 다르다. liveness는 “이 프로세스가 응답할 수 있는가”, readiness는 “의존성에 닿는가”이다. 합치면 /healthz 응답에도 error_code 같은 무의미한 필드가 붙고, orchestrator가 두 신호를 구분하지 못한다.
왜 실패 메시지를 그대로 전달하지 않는가?
이 응답은 인증 없이 접근 가능한 운영 endpoint다. 추론 서버의 원문 오류에는 모델 경로·prompt 조각이 섞일 수 있어 그대로 노출하면 정보가 새어 나간다. 코드만 내보내고 자세한 내용은 서버 log에 남긴다.
이전 단계와 다음 파일
이 계약을 실제로 채우는 코드는 api/routes/system.py에 있다. error_code의 값 목록이 어디서 오는지는 engines/base.py에서, 그 코드를 실제로 만들어내는 분류 로직은 turbofieldfare.py에서 읽는다. 이 schema가 응답으로 나가는지 검사하는 테스트는 test_health.py다.