Merge pull request 'fix(health): HEAD на /health в обоих бэкендах — аптайм проверял то, что всегда отвечает 405' (#2893) from fix/tradein-uptime-honest-green into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 3m4s
Deploy Trade-In / test (push) Successful in 3m51s
Deploy / build-worker (push) Successful in 5m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / deploy (push) Successful in 8m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 3m4s
Deploy Trade-In / test (push) Successful in 3m51s
Deploy / build-worker (push) Successful in 5m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / deploy (push) Successful in 8m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
This commit is contained in:
commit
1a391caae2
4 changed files with 95 additions and 1 deletions
|
|
@ -508,3 +508,24 @@ async def health() -> dict[str, str]:
|
|||
"environment": settings.environment,
|
||||
"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
|
||||
body = response.json()
|
||||
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
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
|
|
@ -210,6 +210,26 @@ def health() -> dict[str, str]:
|
|||
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(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
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