From 24b70e5c58679d78ab092b2df9217ca4f51ba805 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 17:58:36 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(tradein):=20HEAD=20/health=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=87=D0=B0=D0=B5=D1=82=20200=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=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"" -- 2.45.3 From 3f5f09939237353a52fb3e8f0be74424228df7a8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:44:31 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(health):=20HEAD=20/health=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=BC=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D0=B4=D0=B5=20(Site=20Finder)=20+=20=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-разбор ветки fix/tradein-uptime-honest-green: 1. [HIGH] Прод-симптом `HEAD gendsgn.ru/health -> 405` обслуживает Site Finder (Caddyfile:60 `handle /health { reverse_proxy backend:8000 }`), а предыдущий коммит правил только tradein-mvp/backend, чей /health наружу не проксируется вообще. Добавлен @app.head("/health") в backend/app/main.py рядом с существующим @app.get — эмпирически подтверждено (uv run pytest): HEAD было 405, стало 200. tradein-mvp фикс не откачен (безвреден, годится для будущего internal-caller), но обвязан комментарием, что реальный прод-путь чинится не там. 2. [LOW] Response(status_code=200) без media_type отдавал HEAD без Content-Type, тогда как GET отдаёт application/json — расходится с заявленным в комментарии RFC 9110 §9.3.2. Добавлен media_type в обоих бэкендах; Content-Length сознательно не подгоняем под байты GET-ответа (payload header field, RFC разрешает опускать для HEAD) — не дублируем сборку payload ради байт-в-байт соответствия. Тесты: test_health_head_ok_no_body добавлен в backend/tests/test_health.py (Site Finder) — RED-check (git stash app/main.py) воспроизводит прод-баг 1:1: assert 405 == 200. tradein-mvp/backend/tests/test_health_endpoint.py дополнен проверкой Content-Type. uv run pytest — все зелёные. --- backend/app/main.py | 16 ++++++++++++++++ backend/tests/test_health.py | 18 ++++++++++++++++++ tradein-mvp/backend/app/main.py | 16 +++++++++++----- .../backend/tests/test_health_endpoint.py | 4 ++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index e0ac46cb..c779e335 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -508,3 +508,19 @@ 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). +@app.head("/health") +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 4f21a09a..8a48c7c3 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -212,13 +212,19 @@ def health() -> dict[str, str]: # 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. +# отдаёт 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). @app.head("/health") 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"]) diff --git a/tradein-mvp/backend/tests/test_health_endpoint.py b/tradein-mvp/backend/tests/test_health_endpoint.py index 1fa05757..be2d7fab 100644 --- a/tradein-mvp/backend/tests/test_health_endpoint.py +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -29,3 +29,7 @@ def test_health_head_ok_no_body() -> None: 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" -- 2.45.3 From cb0f42d1b11e50453afdcdc0e55ad178951bd3d2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 19:22:22 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(health):=20=D0=BD=D0=B5=20=D1=82=D0=B0?= =?UTF-8?q?=D1=89=D0=B8=D1=82=D1=8C=20HEAD-=D0=BF=D1=80=D0=BE=D0=B1=D1=83?= =?UTF-8?q?=20=D0=B2=20OpenAPI-=D1=81=D1=85=D0=B5=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Джоба openapi-codegen-check покраснела на этой ветке: она дампит app.openapi(), регенерирует frontend/src/types/api-types.ts и падает на расхождении. Добавленный HEAD /health попал в схему и потребовал правки сгенерированного файла. Регенерировать типы ради маршрута, который фронт никогда не вызывает, — лишний шум в generated-коде. HEAD-проба это инфраструктура для uptime-монитора, а не часть контракта, по которому фронт строит типы, поэтому include_in_schema=False здесь и по смыслу верно, а не только удобно. Флаг ставим в обоих бэкендах симметрично: у trade-in codegen-джобы пока нет, но расхождение схем между двумя бэкендами потом само станет источником вопросов. --- backend/app/main.py | 7 ++++++- tradein-mvp/backend/app/main.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index c779e335..5f6507ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -521,6 +521,11 @@ async def health() -> dict[str, str]: # дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload- # заголовки (Content-Length) для HEAD, требует совпадения только заголовков # представления (Content-Type). -@app.head("/health") +# 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/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 8a48c7c3..d9b7aaff 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -222,7 +222,10 @@ def health() -> dict[str, str]: # сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload) # — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для # HEAD, требует совпадения только заголовков представления (Content-Type). -@app.head("/health") +# 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") -- 2.45.3