fix(health): HEAD на /health в обоих бэкендах — аптайм проверял то, что всегда отвечает 405 #2893
4 changed files with 95 additions and 1 deletions
|
|
@ -508,3 +508,24 @@ async def health() -> dict[str, str]:
|
||||||
"environment": settings.environment,
|
"environment": settings.environment,
|
||||||
"version": app.version,
|
"version": app.version,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||||||
|
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||||||
|
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
|
||||||
|
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
|
||||||
|
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
|
||||||
|
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
|
||||||
|
# статусу. media_type="application/json" — Content-Type совпадает с GET;
|
||||||
|
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
|
||||||
|
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
|
||||||
|
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
|
||||||
|
# представления (Content-Type).
|
||||||
|
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
|
||||||
|
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
|
||||||
|
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
|
||||||
|
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
|
||||||
|
# который фронт никогда не вызывает.
|
||||||
|
@app.head("/health", include_in_schema=False)
|
||||||
|
async def health_head() -> Response:
|
||||||
|
return Response(status_code=200, media_type="application/json")
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,21 @@ def test_health() -> None:
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["status"] == "ok"
|
assert body["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_head_ok_no_body() -> None:
|
||||||
|
"""HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy
|
||||||
|
(`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET.
|
||||||
|
|
||||||
|
Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от
|
||||||
|
низкоуровневого `Route(methods=["GET"])`) — без явного `@app.head()`
|
||||||
|
прод-эндпоинт отдаёт 405 на HEAD.
|
||||||
|
"""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.head("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == b""
|
||||||
|
# RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать
|
||||||
|
# с GET; Content-Length допустимо не совпадать (payload header field, MAY
|
||||||
|
# быть опущен для HEAD).
|
||||||
|
assert response.headers["content-type"] == "application/json"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
|
|
@ -210,6 +210,26 @@ def health() -> dict[str, str]:
|
||||||
return {"status": "ok", "environment": settings.environment}
|
return {"status": "ok", "environment": settings.environment}
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||||||
|
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||||||
|
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
|
||||||
|
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
|
||||||
|
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
|
||||||
|
# только pg_isready для postgres) — маршрут пока используется лишь тестами.
|
||||||
|
# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder
|
||||||
|
# (backend/app/main.py, за Caddyfile `handle /health`), не здесь.
|
||||||
|
# media_type="application/json" — Content-Type совпадает с GET; Content-Length
|
||||||
|
# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload)
|
||||||
|
# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для
|
||||||
|
# HEAD, требует совпадения только заголовков представления (Content-Type).
|
||||||
|
# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это
|
||||||
|
# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим
|
||||||
|
# симметрично, чтобы схема двух бэкендов не разъезжалась.
|
||||||
|
@app.head("/health", include_in_schema=False)
|
||||||
|
def health_head() -> Response:
|
||||||
|
return Response(status_code=200, media_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
|
|
||||||
35
tradein-mvp/backend/tests/test_health_endpoint.py
Normal file
35
tradein-mvp/backend/tests/test_health_endpoint.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""GET/HEAD /health — uptime-monitor honesty (#uptime-honest-green).
|
||||||
|
|
||||||
|
GlitchTip PING-мониторы шлют HEAD (или GET без чтения тела). Голый
|
||||||
|
`@app.get("/health")` без явного HEAD-хендлера отдаёт 405 на HEAD — Starlette
|
||||||
|
НЕ добавляет HEAD автоматически к FastAPI `@app.get()` роуту (в отличие от
|
||||||
|
низкоуровневого `Route(methods=["GET"])`). Прод-симптом: `HEAD /health` → 405,
|
||||||
|
монитор либо красный по конструкции, либо (при PING без сверки статуса)
|
||||||
|
зелёный вне зависимости от факта. Тест фиксирует оба метода.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_get_ok() -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_head_ok_no_body() -> None:
|
||||||
|
"""HEAD /health — то, что реально шлёт uptime-monitor. Должен быть 200, без тела."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.head("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == b""
|
||||||
|
# RFC 9110 §9.3.2 — HEAD должен вернуть те же заголовки представления
|
||||||
|
# (Content-Type), что и GET; Content-Length допустимо не совпадать (payload
|
||||||
|
# header field, MAY быть опущен для HEAD).
|
||||||
|
assert resp.headers["content-type"] == "application/json"
|
||||||
Loading…
Add table
Reference in a new issue