diff --git a/backend/app/main.py b/backend/app/main.py index e0ac46cb..5f6507ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index c432abcf..a62f2567 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -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" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 347cad8c..d9b7aaff 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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"]) diff --git a/tradein-mvp/backend/tests/test_health_endpoint.py b/tradein-mvp/backend/tests/test_health_endpoint.py new file mode 100644 index 00000000..be2d7fab --- /dev/null +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -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"