fastapi-m8 3.1.0__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/CHANGELOG.md +29 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/PKG-INFO +12 -4
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/README.md +11 -3
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_app.py +7 -6
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_compat.py +6 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_version.py +1 -1
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/pyproject.toml +1 -1
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_app.py +107 -7
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.codacy.yml +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.dockerignore +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.env.example +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.gitattributes +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/FUNDING.yml +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/dependabot.yml +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/workflows/CI.yaml +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/workflows/PiPy.yml +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.github/workflows/ci.yml +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.gitignore +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/.pydocstyle +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/LICENSE +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/SECURITY.md +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/__init__.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_async_stub.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_deps.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_engine.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_events.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_health.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_internal_auth.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/_revocation.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/config.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/__init__.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/docker_start.sh +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/pre_start.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/__init__.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/conftest.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_app_extra.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_async_stub.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_compat.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_config.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_config_file_secrets.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_deps.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_engine.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_event_signing_gate.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_events.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_health.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_host_header_routing.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_internal_auth.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_meta.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_pre_start.py +0 -0
- {fastapi_m8-3.1.0 → fastapi_m8-3.2.0}/tests/test_revocation.py +0 -0
|
@@ -5,6 +5,35 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
+
## [3.2.0] — 2026-06-28 · ungated `/health` constant liveness body (item 9.4 Design B)
|
|
9
|
+
|
|
10
|
+
> **MINOR — additive, backward-compatible.** The only behaviour change is in the ungated
|
|
11
|
+
> `/health` response: callers without a valid `HEALTH_DETAIL_CREDENTIAL` now always
|
|
12
|
+
> receive `200 {"status":"ok"}` instead of the real aggregate status. Callers that
|
|
13
|
+
> already present the credential are unaffected — they still get the real status, per-check
|
|
14
|
+
> detail, and the 503 HTTP code when checks fail. `auth-sdk-m8` floor unchanged at
|
|
15
|
+
> `>=2.1.0,<3.0.0`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Ungated `/health` body is now a constant liveness response (item 9.4 Design B).**
|
|
20
|
+
Callers without a valid `HEALTH_DETAIL_CREDENTIAL` (or `detail_public=True`) always
|
|
21
|
+
receive `200 {"status":"ok"}`, regardless of the real aggregate health. Previously the
|
|
22
|
+
ungated branch returned `{"status": overall.value}`, which leaked `degraded` — a
|
|
23
|
+
public attack-timing oracle that signals when fail-open degradation is active.
|
|
24
|
+
|
|
25
|
+
Health checks are **not run** for ungated requests (pure liveness, no dependency
|
|
26
|
+
probing); caching is unaffected for credentialed callers. Credentialed callers
|
|
27
|
+
(valid `X-Internal-Token` matching `HEALTH_DETAIL_CREDENTIAL`, or `detail_public=True`)
|
|
28
|
+
continue to receive the real aggregate status, the per-check body, and the correct
|
|
29
|
+
HTTP status code (200 or 503).
|
|
30
|
+
|
|
31
|
+
- **README `Health Detail Gating` section updated** to document the constant ungated
|
|
32
|
+
body and the Design B rationale; the defaults-by-layer table row for
|
|
33
|
+
`HEALTH_DETAIL_CREDENTIAL` updated accordingly.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
8
37
|
## [3.1.0] — 2026-06-24/25 · consumer-side post-1.0 remediation (9.1 / 5.5 / 7.x.1 / 9.2 / 10.1) + event-stream 9.1 follow-on
|
|
9
38
|
|
|
10
39
|
> **MINOR — additive, backward-compatible.** Default behaviour is unchanged: a consumer
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-m8
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: FastAPI application framework for m8 consumer microservices.
|
|
5
5
|
Author-email: Eli Serra <e.serra173@gmail.com>
|
|
6
6
|
License: Apache License
|
|
@@ -724,11 +724,19 @@ do not connect to Redis directly.
|
|
|
724
724
|
| `REDIS_PASSWORD` | Redis password |
|
|
725
725
|
| `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |
|
|
726
726
|
|
|
727
|
-
### Health Detail Gating (
|
|
727
|
+
### Health Detail Gating (items 9.3 + 9.4)
|
|
728
728
|
|
|
729
729
|
| Variable | Default | Description |
|
|
730
730
|
|---|---|---|
|
|
731
|
-
| `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns
|
|
731
|
+
| `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness response). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the real aggregate status, per-check breakdown, and the correct HTTP code (200 or 503). **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
|
|
732
|
+
|
|
733
|
+
> **Constant ungated body (item 9.4 Design B):** ungated callers always receive
|
|
734
|
+
> `200 {"status":"ok"}` regardless of the real aggregate health (including `degraded`
|
|
735
|
+
> or `fail`). This prevents operational-state leakage on public-HTTPS stacks: a
|
|
736
|
+
> `degraded` response is a timing oracle that signals fail-open degradation is active.
|
|
737
|
+
> Only callers presenting a valid `HEALTH_DETAIL_CREDENTIAL` see the real status, the
|
|
738
|
+
> per-check detail, and the 503 HTTP code when checks fail. Liveness probes and
|
|
739
|
+
> `{API_PREFIX}/ping` are unaffected.
|
|
732
740
|
|
|
733
741
|
> **No-reuse enforcement (item 9.3):** at startup, `create_app` asserts that neither
|
|
734
742
|
> `HEALTH_DETAIL_CREDENTIAL` nor `METRICS_SCRAPE_CREDENTIAL` equals `PRIVATE_API_SECRET`.
|
|
@@ -827,7 +835,7 @@ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
|
|
|
827
835
|
| `ALLOWED_HOSTS` | inherited from `CommonSettings`; unset = no host check (dev) | unset under `STRICT_PRODUCTION_MODE` (strict fatal); wildcard `*` under strict |
|
|
828
836
|
| `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` | inherited secure defaults (signing on, unsigned rejected); the gate fires through the consumer's auto-run config-health (item 7.x.1) | `ENABLED=false` under strict; `ACCEPT_UNSIGNED=true` under production or strict |
|
|
829
837
|
| `METRICS_SCRAPE_CREDENTIAL` | unset = `/metrics` relies on network isolation only; set = constant-time `Authorization: Bearer` gate. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (network-isolation is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
|
|
830
|
-
| `HEALTH_DETAIL_CREDENTIAL` (
|
|
838
|
+
| `HEALTH_DETAIL_CREDENTIAL` (items 9.3 + 9.4) | unset = `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness; real status never leaked); set = `X-Internal-Token` must match to receive the real aggregate status, per-check breakdown, and correct HTTP code. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = constant liveness response, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
|
|
831
839
|
| `INTERNAL_CLIENT_ID` (item 9.1) | unset = legacy single `PRIVATE_API_SECRET`; set = per-consumer bootstrap / service-token auth on private calls | never fatal (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) |
|
|
832
840
|
| `_FILE` secret mounts | **inherited** from `CommonSettings` — every secret (`PRIVATE_API_SECRET_FILE`, `DB_PASSWORD_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`, `HEALTH_DETAIL_CREDENTIAL_FILE`, …) can be sourced from `/run/secrets/*` with no code change | a referenced `<FIELD>_FILE` path is missing (fails closed at construction) |
|
|
833
841
|
|
|
@@ -468,11 +468,19 @@ do not connect to Redis directly.
|
|
|
468
468
|
| `REDIS_PASSWORD` | Redis password |
|
|
469
469
|
| `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |
|
|
470
470
|
|
|
471
|
-
### Health Detail Gating (
|
|
471
|
+
### Health Detail Gating (items 9.3 + 9.4)
|
|
472
472
|
|
|
473
473
|
| Variable | Default | Description |
|
|
474
474
|
|---|---|---|
|
|
475
|
-
| `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns
|
|
475
|
+
| `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness response). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the real aggregate status, per-check breakdown, and the correct HTTP code (200 or 503). **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
|
|
476
|
+
|
|
477
|
+
> **Constant ungated body (item 9.4 Design B):** ungated callers always receive
|
|
478
|
+
> `200 {"status":"ok"}` regardless of the real aggregate health (including `degraded`
|
|
479
|
+
> or `fail`). This prevents operational-state leakage on public-HTTPS stacks: a
|
|
480
|
+
> `degraded` response is a timing oracle that signals fail-open degradation is active.
|
|
481
|
+
> Only callers presenting a valid `HEALTH_DETAIL_CREDENTIAL` see the real status, the
|
|
482
|
+
> per-check detail, and the 503 HTTP code when checks fail. Liveness probes and
|
|
483
|
+
> `{API_PREFIX}/ping` are unaffected.
|
|
476
484
|
|
|
477
485
|
> **No-reuse enforcement (item 9.3):** at startup, `create_app` asserts that neither
|
|
478
486
|
> `HEALTH_DETAIL_CREDENTIAL` nor `METRICS_SCRAPE_CREDENTIAL` equals `PRIVATE_API_SECRET`.
|
|
@@ -571,7 +579,7 @@ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
|
|
|
571
579
|
| `ALLOWED_HOSTS` | inherited from `CommonSettings`; unset = no host check (dev) | unset under `STRICT_PRODUCTION_MODE` (strict fatal); wildcard `*` under strict |
|
|
572
580
|
| `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` | inherited secure defaults (signing on, unsigned rejected); the gate fires through the consumer's auto-run config-health (item 7.x.1) | `ENABLED=false` under strict; `ACCEPT_UNSIGNED=true` under production or strict |
|
|
573
581
|
| `METRICS_SCRAPE_CREDENTIAL` | unset = `/metrics` relies on network isolation only; set = constant-time `Authorization: Bearer` gate. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (network-isolation is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
|
|
574
|
-
| `HEALTH_DETAIL_CREDENTIAL` (
|
|
582
|
+
| `HEALTH_DETAIL_CREDENTIAL` (items 9.3 + 9.4) | unset = `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness; real status never leaked); set = `X-Internal-Token` must match to receive the real aggregate status, per-check breakdown, and correct HTTP code. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = constant liveness response, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
|
|
575
583
|
| `INTERNAL_CLIENT_ID` (item 9.1) | unset = legacy single `PRIVATE_API_SECRET`; set = per-consumer bootstrap / service-token auth on private calls | never fatal (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) |
|
|
576
584
|
| `_FILE` secret mounts | **inherited** from `CommonSettings` — every secret (`PRIVATE_API_SECRET_FILE`, `DB_PASSWORD_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`, `HEALTH_DETAIL_CREDENTIAL_FILE`, …) can be sourced from `/run/secrets/*` with no code change | a referenced `<FIELD>_FILE` path is missing (fails closed at construction) |
|
|
577
585
|
|
|
@@ -368,14 +368,15 @@ def _register_health_route(
|
|
|
368
368
|
return JSONResponse(
|
|
369
369
|
{"status": "initializing", "ready": False}, status_code=503
|
|
370
370
|
)
|
|
371
|
-
results, overall, code = await _gather_health_results(
|
|
372
|
-
app, checks, config.timeout, config.policy, config.cache_ttl
|
|
373
|
-
)
|
|
374
|
-
logger.debug("health: %s (%d checks)", overall.value, len(results))
|
|
375
|
-
body: dict[str, Any] = {"status": overall.value}
|
|
376
371
|
if config.detail_public or await _is_authorized(request):
|
|
372
|
+
results, overall, code = await _gather_health_results(
|
|
373
|
+
app, checks, config.timeout, config.policy, config.cache_ttl
|
|
374
|
+
)
|
|
375
|
+
logger.debug("health: %s (%d checks)", overall.value, len(results))
|
|
376
|
+
body: dict[str, Any] = {"status": overall.value}
|
|
377
377
|
body |= _build_health_body(results, service_name, service_version)
|
|
378
|
-
|
|
378
|
+
return JSONResponse(body, status_code=code)
|
|
379
|
+
return JSONResponse({"status": "ok"})
|
|
379
380
|
|
|
380
381
|
|
|
381
382
|
def _register_meta_routes(app: FastAPI, settings: ConsumerServiceSettings) -> None:
|
|
@@ -68,6 +68,12 @@ COMPAT_MATRIX: dict[str, dict[str, str]] = {
|
|
|
68
68
|
# consumer-side 5.5 / 7.x.1 test+observability and 9.2/10.1 docs. The post-1.0
|
|
69
69
|
# breaking-removal majors stay deferred (see CHANGELOG). See CHANGELOG.
|
|
70
70
|
"3.1": {"auth-sdk-m8": ">=2.1.0,<3.0.0"},
|
|
71
|
+
# 3.2 (MINOR) hardens the ungated /health branch to a constant liveness response
|
|
72
|
+
# (item 9.4 Design B): ungated callers always receive 200 {"status":"ok"};
|
|
73
|
+
# only credentialed callers (HEALTH_DETAIL_CREDENTIAL) see the real aggregate
|
|
74
|
+
# status, per-check detail, and the 503 HTTP code on FAIL. No SDK change required;
|
|
75
|
+
# auth-sdk-m8 floor stays at >=2.1.0. See CHANGELOG.
|
|
76
|
+
"3.2": {"auth-sdk-m8": ">=2.1.0,<3.0.0"},
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
_EXTRAS = "[config,security,fastapi,observability]"
|
|
@@ -202,18 +202,117 @@ async def test_distinct_credentials_do_not_raise(test_router: APIRouter) -> None
|
|
|
202
202
|
assert a.state.service_ready is True
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
# ── Health Design B — ungated constant liveness (item 9.4) ───────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.anyio
|
|
209
|
+
async def test_ungated_body_constant_ok_when_degraded(test_router: APIRouter) -> None:
|
|
210
|
+
"""Ungated /health always returns 200 ok even when a check is DEGRADED (9.4)."""
|
|
211
|
+
|
|
212
|
+
async def degraded() -> HealthCheckResult:
|
|
213
|
+
return HealthCheckResult(name="cache", status=HealthStatus.DEGRADED)
|
|
214
|
+
|
|
215
|
+
a = create_app(
|
|
216
|
+
make_settings(**_BASE),
|
|
217
|
+
test_router,
|
|
218
|
+
health=HealthConfig(checks=[degraded]),
|
|
219
|
+
)
|
|
220
|
+
async with live_client(a) as client:
|
|
221
|
+
resp = await client.get("/api/health/")
|
|
222
|
+
assert resp.status_code == 200
|
|
223
|
+
assert resp.json() == {"status": "ok"}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.anyio
|
|
227
|
+
async def test_ungated_body_constant_ok_when_fail(test_router: APIRouter) -> None:
|
|
228
|
+
"""Ungated /health always returns 200 ok even when a check is FAIL — no 503 leak (9.4)."""
|
|
229
|
+
|
|
230
|
+
async def bad() -> HealthCheckResult:
|
|
231
|
+
return HealthCheckResult(name="db", status=HealthStatus.FAIL)
|
|
232
|
+
|
|
233
|
+
a = create_app(
|
|
234
|
+
make_settings(**_BASE),
|
|
235
|
+
test_router,
|
|
236
|
+
health=HealthConfig(checks=[bad]),
|
|
237
|
+
)
|
|
238
|
+
async with live_client(a) as client:
|
|
239
|
+
resp = await client.get("/api/health/")
|
|
240
|
+
assert resp.status_code == 200
|
|
241
|
+
assert resp.json() == {"status": "ok"}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.anyio
|
|
245
|
+
async def test_ungated_no_detail_keys_when_fail(test_router: APIRouter) -> None:
|
|
246
|
+
"""Ungated FAIL response: only constant body, no checks/version keys leak (9.4)."""
|
|
247
|
+
|
|
248
|
+
async def bad() -> HealthCheckResult:
|
|
249
|
+
return HealthCheckResult(name="db", status=HealthStatus.FAIL)
|
|
250
|
+
|
|
251
|
+
a = create_app(
|
|
252
|
+
make_settings(**_BASE),
|
|
253
|
+
test_router,
|
|
254
|
+
health=HealthConfig(checks=[bad]),
|
|
255
|
+
)
|
|
256
|
+
async with live_client(a) as client:
|
|
257
|
+
resp = await client.get("/api/health/")
|
|
258
|
+
body = resp.json()
|
|
259
|
+
assert "checks" not in body
|
|
260
|
+
assert "fastapi_m8" not in body
|
|
261
|
+
assert body.get("status") == "ok"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.anyio
|
|
265
|
+
async def test_gated_fail_returns_503_with_real_status(test_router: APIRouter) -> None:
|
|
266
|
+
"""Credentialed /health with a FAIL check returns 503 and status=fail (9.4)."""
|
|
267
|
+
|
|
268
|
+
async def bad() -> HealthCheckResult:
|
|
269
|
+
return HealthCheckResult(name="db", status=HealthStatus.FAIL)
|
|
270
|
+
|
|
271
|
+
a = create_app(
|
|
272
|
+
make_settings(**_BASE, HEALTH_DETAIL_CREDENTIAL="health-cred"),
|
|
273
|
+
test_router,
|
|
274
|
+
health=HealthConfig(checks=[bad]),
|
|
275
|
+
)
|
|
276
|
+
async with live_client(a, headers={"X-Internal-Token": "health-cred"}) as client:
|
|
277
|
+
resp = await client.get("/api/health/")
|
|
278
|
+
assert resp.status_code == 503
|
|
279
|
+
body = resp.json()
|
|
280
|
+
assert body["status"] == "fail"
|
|
281
|
+
assert "checks" in body
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.mark.anyio
|
|
285
|
+
async def test_gated_degraded_returns_real_status(test_router: APIRouter) -> None:
|
|
286
|
+
"""Credentialed /health with a DEGRADED check shows the real status (9.4)."""
|
|
287
|
+
|
|
288
|
+
async def degraded() -> HealthCheckResult:
|
|
289
|
+
return HealthCheckResult(name="cache", status=HealthStatus.DEGRADED)
|
|
290
|
+
|
|
291
|
+
a = create_app(
|
|
292
|
+
make_settings(**_BASE, HEALTH_DETAIL_CREDENTIAL="health-cred"),
|
|
293
|
+
test_router,
|
|
294
|
+
health=HealthConfig(checks=[degraded], policy=HealthAggregatePolicy.LENIENT),
|
|
295
|
+
)
|
|
296
|
+
async with live_client(a, headers={"X-Internal-Token": "health-cred"}) as client:
|
|
297
|
+
resp = await client.get("/api/health/")
|
|
298
|
+
assert resp.status_code == 200
|
|
299
|
+
assert resp.json()["status"] == "degraded"
|
|
300
|
+
|
|
301
|
+
|
|
205
302
|
# ── Health checks ─────────────────────────────────────────────────────────────
|
|
206
303
|
|
|
207
304
|
|
|
208
305
|
@pytest.mark.anyio
|
|
209
|
-
async def
|
|
210
|
-
"""A FAIL check drives the aggregate to 503."""
|
|
306
|
+
async def test_failing_check_gated_returns_503(test_router: APIRouter) -> None:
|
|
307
|
+
"""A FAIL check drives the aggregate to 503 for credentialed (detail_public) callers."""
|
|
211
308
|
|
|
212
309
|
async def bad() -> HealthCheckResult:
|
|
213
310
|
return HealthCheckResult(name="db", status=HealthStatus.FAIL)
|
|
214
311
|
|
|
215
312
|
a = create_app(
|
|
216
|
-
make_settings(**_BASE),
|
|
313
|
+
make_settings(**_BASE),
|
|
314
|
+
test_router,
|
|
315
|
+
health=HealthConfig(checks=[bad], detail_public=True),
|
|
217
316
|
)
|
|
218
317
|
async with live_client(a) as client:
|
|
219
318
|
resp = await client.get("/api/health/")
|
|
@@ -222,8 +321,8 @@ async def test_failing_check_returns_503(test_router: APIRouter) -> None:
|
|
|
222
321
|
|
|
223
322
|
|
|
224
323
|
@pytest.mark.anyio
|
|
225
|
-
async def
|
|
226
|
-
"""A DEGRADED check stays at 200 under LENIENT policy."""
|
|
324
|
+
async def test_degraded_check_lenient_gated_returns_200(test_router: APIRouter) -> None:
|
|
325
|
+
"""A DEGRADED check stays at 200 under LENIENT policy for credentialed callers."""
|
|
227
326
|
|
|
228
327
|
async def degraded() -> HealthCheckResult:
|
|
229
328
|
return HealthCheckResult(name="minio", status=HealthStatus.DEGRADED)
|
|
@@ -234,6 +333,7 @@ async def test_degraded_check_lenient_returns_200(test_router: APIRouter) -> Non
|
|
|
234
333
|
health=HealthConfig(
|
|
235
334
|
checks=[degraded],
|
|
236
335
|
policy=HealthAggregatePolicy.LENIENT,
|
|
336
|
+
detail_public=True,
|
|
237
337
|
),
|
|
238
338
|
)
|
|
239
339
|
async with live_client(a) as client:
|
|
@@ -264,7 +364,7 @@ async def test_health_detail_public_exposes_checks(test_router: APIRouter) -> No
|
|
|
264
364
|
|
|
265
365
|
@pytest.mark.anyio
|
|
266
366
|
async def test_health_cache_prevents_double_invocation(test_router: APIRouter) -> None:
|
|
267
|
-
"""Within health_cache_ttl a second probe reuses cached results."""
|
|
367
|
+
"""Within health_cache_ttl a second probe reuses cached results (detail_public path)."""
|
|
268
368
|
call_count = 0
|
|
269
369
|
|
|
270
370
|
async def counted() -> HealthCheckResult:
|
|
@@ -275,7 +375,7 @@ async def test_health_cache_prevents_double_invocation(test_router: APIRouter) -
|
|
|
275
375
|
a = create_app(
|
|
276
376
|
make_settings(**_BASE),
|
|
277
377
|
test_router,
|
|
278
|
-
health=HealthConfig(checks=[counted], cache_ttl=10.0),
|
|
378
|
+
health=HealthConfig(checks=[counted], cache_ttl=10.0, detail_public=True),
|
|
279
379
|
)
|
|
280
380
|
async with live_client(a) as client:
|
|
281
381
|
await client.get("/api/health/")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|