From 24b70e5c58679d78ab092b2df9217ca4f51ba805 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:58:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20HEAD=20/health=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=87=D0=B0=D0=B5=D1=82=20200=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20405?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @app.get("/health") в FastAPI/Starlette не добавляет HEAD-обработчик автоматически (в отличие от низкоуровневого Route(methods=["GET"])) — внешний uptime-monитор (GlitchTip PING-тип шлёт HEAD) получал 405 и не мог отличить "жив" от "мёртв" по статусу. Добавлен явный @app.head("/health") — 200 без тела (RFC 9110 §9.3.2), GET не тронут. Тест test_health_endpoint.py фиксирует оба метода; RED до фикса (HEAD → 405), GREEN после (проверено git stash + повторный прогон). --- tradein-mvp/backend/app/main.py | 13 +++++++- .../backend/tests/test_health_endpoint.py | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_health_endpoint.py diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 347cad8c..4f21a09a 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,17 @@ 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, и внешний uptime-monitor (GlitchTip PING-тип, HEAD-запрос) не +# может отличить "жив" от "мёртв" по статусу. Тело для HEAD не отдаём — так +# требует HTTP-спека (RFC 9110 §9.3.2): у ответа те же заголовки, что у GET, +# но без body. +@app.head("/health") +def health_head() -> Response: + return Response(status_code=200) + + 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..1fa05757 --- /dev/null +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -0,0 +1,31 @@ +"""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""