fix(health): HEAD на /health в обоих бэкендах — аптайм проверял то, что всегда отвечает 405 #2893
4 changed files with 49 additions and 5 deletions
|
|
@ -508,3 +508,19 @@ 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).
|
||||||
|
@app.head("/health")
|
||||||
|
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"
|
||||||
|
|
|
||||||
|
|
@ -212,13 +212,19 @@ def health() -> dict[str, str]:
|
||||||
|
|
||||||
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||||||
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||||||
# отдаёт 405, и внешний uptime-monitor (GlitchTip PING-тип, HEAD-запрос) не
|
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
|
||||||
# может отличить "жив" от "мёртв" по статусу. Тело для HEAD не отдаём — так
|
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
|
||||||
# требует HTTP-спека (RFC 9110 §9.3.2): у ответа те же заголовки, что у GET,
|
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
|
||||||
# но без body.
|
# только 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).
|
||||||
@app.head("/health")
|
@app.head("/health")
|
||||||
def health_head() -> Response:
|
def health_head() -> Response:
|
||||||
return Response(status_code=200)
|
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"])
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,7 @@ def test_health_head_ok_no_body() -> None:
|
||||||
resp = client.head("/health")
|
resp = client.head("/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.content == b""
|
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