diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8a71f..6d2cc23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [dev] + branches: [main, dev] pull_request: branches: [main, dev] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3813f..db2a5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning --- +## [1.0.3] — 2026-05-15 + +### Added + +- `DRIFT` insight type: detects progressive latency degradation using ordinary least squares over the last 30 days — emitted when slope ≥ 5ms/day over 7+ data points, with a 30-day projection +- `DRIFT` filter chip added to the dashboard Insights view +- 60 unit tests covering `aggregator`, `database`, `insights` and `middleware` + +### Fixed + +- CI badge was pointing to `main` with no workflow run — CI now triggers on both `main` and `dev` + +--- + ## [1.0.0] — 2026-05-15 ### Fixed diff --git a/apiforgepy/dashboard.py b/apiforgepy/dashboard.py index 41b9f2a..a919f7d 100644 --- a/apiforgepy/dashboard.py +++ b/apiforgepy/dashboard.py @@ -1250,8 +1250,9 @@ const types = [ {id:'ALL',label:'All'},{id:'PERF',label:'Performance'}, - {id:'ANOMALY',label:'Anomaly'},{id:'DEAD',label:'Dead'}, - {id:'UNTRACKED',label:'Untracked'},{id:'OK',label:'OK'}, + {id:'DRIFT',label:'Drift'},{id:'ANOMALY',label:'Anomaly'}, + {id:'DEAD',label:'Dead'},{id:'UNTRACKED',label:'Untracked'}, + {id:'OK',label:'OK'}, ]; const filtered = INSIGHTS.filter(i => (typeFilter === 'ALL' || i.type === typeFilter) && diff --git a/apiforgepy/database.py b/apiforgepy/database.py index 6323fd7..ee3ae1e 100644 --- a/apiforgepy/database.py +++ b/apiforgepy/database.py @@ -254,6 +254,21 @@ def get_releases(self) -> list[dict]: """).fetchall() return [dict(r) for r in rows] + def get_drift_data(self) -> list[dict]: + """Returns one row per (route, method, day) over the last 30 days for drift detection.""" + since_30d = _now_sec() - 30 * 86_400 + rows = self._conn.execute(""" + SELECT + route, method, + CAST(bucket_ts / 86400 AS INTEGER) as day_bucket, + AVG(lat_p90) as p90 + FROM api_metrics + WHERE bucket_ts >= ? AND lat_p90 IS NOT NULL + GROUP BY route, method, day_bucket + ORDER BY route, method, day_bucket + """, (since_30d,)).fetchall() + return [dict(r) for r in rows] + def get_global_time_series(self, hours: int = 24) -> list[dict]: since = _now_sec() - hours * 3600 rows = self._conn.execute(""" diff --git a/apiforgepy/insights.py b/apiforgepy/insights.py index 01736dc..43b0953 100644 --- a/apiforgepy/insights.py +++ b/apiforgepy/insights.py @@ -1,9 +1,11 @@ import math import time -DEAD_ENDPOINT_DAYS = 21 -REGRESSION_THRESHOLD = 0.20 -ANOMALY_Z_THRESHOLD = 2.5 +DEAD_ENDPOINT_DAYS = 21 +REGRESSION_THRESHOLD = 0.20 +ANOMALY_Z_THRESHOLD = 2.5 +DRIFT_SLOPE_THRESHOLD = 5.0 # ms/day above which progressive drift is reported +DRIFT_MIN_DAYS = 7 # minimum number of daily data points required def get_insights(db) -> list[dict]: @@ -13,6 +15,7 @@ def get_insights(db) -> list[dict]: _detect_dead_endpoints, _detect_release_regressions, _detect_untracked_routes, + _detect_drift, ): try: insights.extend(fn(db)) @@ -160,6 +163,67 @@ def _detect_release_regressions(db) -> list[dict]: return insights +def _detect_drift(db) -> list[dict]: + rows = db.get_drift_data() + if not rows: + return [] + + # Group daily P90 samples by endpoint + by_endpoint: dict[str, dict] = {} + for row in rows: + key = f"{row['method']}|{row['route']}" + if key not in by_endpoint: + by_endpoint[key] = {"method": row["method"], "route": row["route"], "points": []} + by_endpoint[key]["points"].append({"x": row["day_bucket"], "y": row["p90"]}) + + insights = [] + for ep in by_endpoint.values(): + points = ep["points"] + if len(points) < DRIFT_MIN_DAYS: + continue + + # Ordinary least squares on (day_index, p90) pairs + x0 = points[0]["x"] + xs = [p["x"] - x0 for p in points] + ys = [p["y"] for p in points] + n = len(xs) + sum_x = sum(xs) + sum_y = sum(ys) + sum_xy = sum(xs[i] * ys[i] for i in range(n)) + sum_x2 = sum(x * x for x in xs) + denom = n * sum_x2 - sum_x ** 2 + if denom == 0: + continue + + slope = (n * sum_xy - sum_x * sum_y) / denom + if slope < DRIFT_SLOPE_THRESHOLD: + continue + + observed_days = xs[-1] + projection_30 = round(slope * 30) + method, route = ep["method"], ep["route"] + day_str = "day" if observed_days == 1 else "days" + + insights.append({ + "type": "DRIFT", + "severity": "warning", + "route": route, + "method": method, + "message": ( + f"`{method} {route}` has been progressively degrading for " + f"{observed_days} {day_str}: +{slope:.1f}ms/day. " + f"30-day projection: +{projection_30}ms." + ), + "data": { + "slope_ms_per_day": slope, + "observed_days": observed_days, + "projection_30d_ms": projection_30, + }, + }) + + return insights + + def _detect_untracked_routes(db) -> list[dict]: return [ { diff --git a/pyproject.toml b/pyproject.toml index 995f5b2..8df0126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "1.0.2" +version = "1.0.3" description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md" diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py new file mode 100644 index 0000000..fe1ca5a --- /dev/null +++ b/tests/test_aggregator.py @@ -0,0 +1,121 @@ +import pytest +from apiforgepy.aggregator import Aggregator + + +def make_transport(): + class Spy: + def __init__(self): + self.calls = [] + def write(self, rows): + self.calls.append(rows) + return Spy() + + +def base_event(**overrides): + defaults = dict( + route="/test", method="GET", status=200, + duration_ms=100.0, timestamp="2026-01-01T00:00:00Z", + env="test", release=None, service="svc", response_size=None, + ) + return {**defaults, **overrides} + + +class TestRecord: + def test_accumulates_durations(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(duration_ms=10)) + agg.record(base_event(duration_ms=20)) + key = next(iter(agg._buffer)) + assert len(agg._buffer[key]["durations"]) == 2 + + def test_increments_2xx_counter(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(status=200)) + agg.record(base_event(status=204)) + key = next(iter(agg._buffer)) + assert agg._buffer[key]["status_2xx"] == 2 + + def test_increments_4xx_counter(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(status=404)) + agg.record(base_event(status=429)) + key = next(iter(agg._buffer)) + assert agg._buffer[key]["status_4xx"] == 2 + assert agg._buffer[key]["status_2xx"] == 0 + + def test_increments_5xx_counter(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(status=500)) + key = next(iter(agg._buffer)) + assert agg._buffer[key]["status_5xx"] == 1 + + def test_separate_buckets_per_route(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(route="/a")) + agg.record(base_event(route="/b")) + assert len(agg._buffer) == 2 + + def test_release_separates_bucket_key(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(release="v1")) + agg.record(base_event(release="v2")) + assert len(agg._buffer) == 2 + + +class TestFlush: + def test_sends_rows_and_clears_buffer(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event()) + agg._flush() + assert len(t.calls) == 1 + assert len(t.calls[0]) == 1 + assert len(agg._buffer) == 0 + + def test_noop_when_buffer_empty(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg._flush() + assert len(t.calls) == 0 + + def test_computes_percentiles(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + for i in range(1, 11): + agg.record(base_event(duration_ms=float(i * 10))) + agg._flush() + row = t.calls[0][0] + assert 50 <= row["lat_p50"] <= 60 + assert 90 <= row["lat_p90"] <= 100 + assert row["lat_p99"] >= 90 + + def test_correct_lat_min_max(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(duration_ms=5.0)) + agg.record(base_event(duration_ms=95.0)) + agg._flush() + row = t.calls[0][0] + assert row["lat_min"] == pytest.approx(5.0) + assert row["lat_max"] == pytest.approx(95.0) + + def test_total_calls_matches_records(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + for _ in range(7): + agg.record(base_event()) + agg._flush() + assert t.calls[0][0]["total_calls"] == 7 + + def test_stop_flushes_buffer(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event()) + agg.stop() + assert len(t.calls) == 1, "stop() must flush remaining events" diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..b52ceed --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,176 @@ +import time +import pytest +from apiforgepy.database import ApiForgeDatabase + + +def make_db(): + return ApiForgeDatabase(":memory:") + + +def insert_row(db, **overrides): + defaults = dict( + bucket_ts=int(time.time()), + route="/test", + method="GET", + env="test", + release_tag=None, + status_2xx=1, + status_4xx=0, + status_5xx=0, + total_calls=1, + lat_p50=50.0, + lat_p90=90.0, + lat_p99=99.0, + lat_min=10.0, + lat_max=150.0, + ) + db.insert_batch([{**defaults, **overrides}]) + + +class TestInsertBatch: + def test_row_appears_in_get_routes(self): + db = make_db() + insert_row(db, route="/users", method="GET") + routes = db.get_routes(24) + assert len(routes) == 1 + assert routes[0]["route"] == "/users" + db.close() + + def test_accumulates_calls_for_same_route(self): + db = make_db() + insert_row(db, route="/items", total_calls=3) + insert_row(db, route="/items", total_calls=7) + routes = db.get_routes(24) + assert routes[0]["calls"] == 10 + db.close() + + def test_empty_batch_is_noop(self): + db = make_db() + db.insert_batch([]) + assert db.get_routes(24) == [] + db.close() + + +class TestGetSummary: + def test_returns_zero_when_empty(self): + db = make_db() + s = db.get_summary() + assert s["active_routes"] == 0 + assert s["total_routes"] == 0 + db.close() + + def test_counts_distinct_routes(self): + db = make_db() + now = int(time.time()) + insert_row(db, route="/a", bucket_ts=now) + insert_row(db, route="/b", bucket_ts=now) + s = db.get_summary() + assert s["active_routes"] == 2 + assert s["total_routes"] == 2 + db.close() + + def test_sums_5xx_errors(self): + db = make_db() + now = int(time.time()) + insert_row(db, status_2xx=0, status_5xx=3, total_calls=3, bucket_ts=now) + s = db.get_summary() + assert s["recent"]["calls_5xx"] == 3 + db.close() + + +class TestGetTimeSeries: + def test_returns_data_for_matching_route(self): + db = make_db() + ts = int(time.time()) - 60 + insert_row(db, route="/ts", method="POST", bucket_ts=ts) + rows = db.get_time_series("/ts", "POST", 24) + assert len(rows) == 1 + assert "p90" in rows[0] + db.close() + + def test_returns_empty_for_missing_route(self): + db = make_db() + insert_row(db, route="/other") + rows = db.get_time_series("/missing", "GET", 24) + assert rows == [] + db.close() + + +class TestGetDeadCandidates: + def test_flags_old_routes(self): + db = make_db() + old_ts = int(time.time()) - 25 * 86_400 + insert_row(db, route="/dead", bucket_ts=old_ts) + dead = db.get_dead_candidates(21) + assert len(dead) == 1 + assert dead[0]["route"] == "/dead" + db.close() + + def test_does_not_flag_recent_routes(self): + db = make_db() + recent_ts = int(time.time()) - 5 * 86_400 + insert_row(db, route="/alive", bucket_ts=recent_ts) + dead = db.get_dead_candidates(21) + assert dead == [] + db.close() + + +class TestGetReleaseComparison: + def test_returns_none_without_releases(self): + db = make_db() + assert db.get_release_comparison() is None + db.close() + + def test_returns_before_after_with_release(self): + db = make_db() + ts = int(time.time()) - 3600 + insert_row(db, release_tag="v1.0", bucket_ts=ts) + result = db.get_release_comparison() + assert result is not None + assert result["release_tag"] == "v1.0" + assert isinstance(result["before"], list) + assert isinstance(result["after"], list) + db.close() + + +class TestKnownRoutes: + def test_untracked_route_appears_in_results(self): + db = make_db() + db.upsert_known_routes([{"route": "/ghost", "method": "DELETE"}]) + untracked = db.get_untracked_routes() + assert len(untracked) == 1 + assert untracked[0]["route"] == "/ghost" + db.close() + + def test_route_with_traffic_is_not_untracked(self): + db = make_db() + db.upsert_known_routes([{"route": "/active", "method": "GET"}]) + insert_row(db, route="/active", method="GET") + assert db.get_untracked_routes() == [] + db.close() + + +class TestGetDriftData: + def test_returns_rows_for_recent_data(self): + db = make_db() + ts = int(time.time()) - 10 * 86_400 + insert_row(db, route="/slow", bucket_ts=ts, lat_p90=120.0) + rows = db.get_drift_data() + assert len(rows) >= 1 + assert "day_bucket" in rows[0] + assert "p90" in rows[0] + db.close() + + def test_returns_empty_when_no_data(self): + db = make_db() + assert db.get_drift_data() == [] + db.close() + + +class TestGetGlobalTimeSeries: + def test_returns_bucketed_data(self): + db = make_db() + insert_row(db, bucket_ts=int(time.time()) - 60) + rows = db.get_global_time_series(24) + assert len(rows) >= 1 + db.close() diff --git a/tests/test_insights.py b/tests/test_insights.py new file mode 100644 index 0000000..c773325 --- /dev/null +++ b/tests/test_insights.py @@ -0,0 +1,205 @@ +import math +import time +import pytest +from apiforgepy.insights import get_insights, compute_health_score + + +def make_db(**overrides): + """Build a minimal database stub.""" + base = dict( + get_latency_anomaly_data=lambda: {"recent": [], "baseline_rows": []}, + get_dead_candidates=lambda _days=21: [], + get_release_comparison=lambda: None, + get_untracked_routes=lambda: [], + get_drift_data=lambda: [], + get_summary=lambda: { + "recent": {"calls_total": 0}, + "baseline": {}, + "active_routes": 0, + "total_routes": 0, + }, + ) + + class Stub: + pass + + stub = Stub() + merged = {**base, **overrides} + for name, fn in merged.items(): + setattr(stub, name, fn) + return stub + + +class TestGetInsights: + def test_empty_when_no_data(self): + assert get_insights(make_db()) == [] + + def test_emits_anomaly_on_high_zscore(self): + baseline_rows = [ + {"method": "GET", "route": "/slow", "lat_p99": float(v)} + for v in [50, 60, 70, 80, 90, 100, 110, 120, 130, 140] + ] + db = make_db( + get_latency_anomaly_data=lambda: { + "recent": [{"method": "GET", "route": "/slow", "avg_p99": 500.0}], + "baseline_rows": baseline_rows, + } + ) + insights = get_insights(db) + anomaly = next((i for i in insights if i["type"] == "ANOMALY"), None) + assert anomaly is not None + assert "abnormally high" in anomaly["message"] + + def test_no_anomaly_when_few_samples(self): + db = make_db( + get_latency_anomaly_data=lambda: { + "recent": [{"method": "GET", "route": "/r", "avg_p99": 999.0}], + "baseline_rows": [{"method": "GET", "route": "/r", "lat_p99": 50.0}], + } + ) + insights = get_insights(db) + assert not any(i["type"] == "ANOMALY" for i in insights) + + def test_emits_dead_for_inactive_endpoint(self): + old_ts = int(time.time()) - 30 * 86_400 + db = make_db( + get_dead_candidates=lambda _days=21: [ + {"route": "/old", "method": "DELETE", "last_seen": old_ts} + ] + ) + insights = get_insights(db) + dead = next((i for i in insights if i["type"] == "DEAD"), None) + assert dead is not None + assert "no requests" in dead["message"] + + def test_emits_perf_on_regression(self): + db = make_db( + get_release_comparison=lambda: { + "release_tag": "v2.0", + "before": [{"method": "GET", "route": "/pay", "avg_p90": 100.0, "calls": 10}], + "after": [{"method": "GET", "route": "/pay", "avg_p90": 200.0, "calls": 10}], + } + ) + insights = get_insights(db) + perf = next((i for i in insights if i["type"] == "PERF"), None) + assert perf is not None + assert "v2.0" in perf["message"] + assert "Before:" in perf["message"] + + def test_emits_ok_on_improvement(self): + db = make_db( + get_release_comparison=lambda: { + "release_tag": "v3.0", + "before": [{"method": "GET", "route": "/fast", "avg_p90": 200.0, "calls": 10}], + "after": [{"method": "GET", "route": "/fast", "avg_p90": 80.0, "calls": 10}], + } + ) + insights = get_insights(db) + ok = next((i for i in insights if i["type"] == "OK"), None) + assert ok is not None + assert "improved" in ok["message"] + + def test_emits_untracked_for_declared_silent_routes(self): + now = int(time.time()) + db = make_db( + get_untracked_routes=lambda: [ + {"route": "/ghost", "method": "GET", "first_seen": now} + ] + ) + insights = get_insights(db) + untracked = next((i for i in insights if i["type"] == "UNTRACKED"), None) + assert untracked is not None + assert "no requests since monitoring started" in untracked["message"] + + def test_emits_drift_on_steep_upward_slope(self): + today = int(time.time() // 86_400) + rows = [ + {"route": "/slow", "method": "GET", "day_bucket": today - 9 + i, "p90": 100.0 + i * 20} + for i in range(10) + ] + db = make_db(get_drift_data=lambda: rows) + insights = get_insights(db) + drift = next((i for i in insights if i["type"] == "DRIFT"), None) + assert drift is not None + assert "ms/day" in drift["message"] + assert "30-day projection" in drift["message"] + + def test_no_drift_with_fewer_than_7_days(self): + today = int(time.time() // 86_400) + rows = [ + {"route": "/r", "method": "GET", "day_bucket": today - 4 + i, "p90": 100.0 + i * 30} + for i in range(5) + ] + db = make_db(get_drift_data=lambda: rows) + insights = get_insights(db) + assert not any(i["type"] == "DRIFT" for i in insights) + + def test_no_drift_when_slope_below_threshold(self): + today = int(time.time() // 86_400) + rows = [ + {"route": "/flat", "method": "GET", "day_bucket": today - 9 + i, "p90": 100.0} + for i in range(10) + ] + db = make_db(get_drift_data=lambda: rows) + insights = get_insights(db) + assert not any(i["type"] == "DRIFT" for i in insights) + + def test_never_raises_even_when_all_db_methods_raise(self): + def boom(*_args, **_kwargs): + raise RuntimeError("db error") + + db = make_db( + get_latency_anomaly_data=boom, + get_dead_candidates=boom, + get_release_comparison=boom, + get_untracked_routes=boom, + get_drift_data=boom, + ) + result = get_insights(db) + assert result == [] + + +class TestComputeHealthScore: + def test_returns_none_when_no_traffic(self): + assert compute_health_score(make_db()) is None + + def test_returns_number_between_0_and_100(self): + db = make_db( + get_summary=lambda: { + "recent": { + "calls_total": 100, "calls_2xx": 95, + "calls_4xx": 3, "calls_5xx": 2, + "avg_p90": 80.0, "avg_p99": 150.0, + }, + "baseline": {"baseline_p90": 70.0}, + "active_routes": 4, + "total_routes": 5, + } + ) + score = compute_health_score(db) + assert isinstance(score, int) + assert 0 <= score <= 100 + + def test_high_score_when_api_is_healthy(self): + db = make_db( + get_summary=lambda: { + "recent": { + "calls_total": 200, "calls_2xx": 200, + "calls_4xx": 0, "calls_5xx": 0, + "avg_p90": 50.0, "avg_p99": 80.0, + }, + "baseline": {"baseline_p90": 60.0}, + "active_routes": 5, + "total_routes": 5, + } + ) + score = compute_health_score(db) + assert score >= 80 + + def test_returns_none_when_get_summary_raises(self): + db = make_db(get_summary=lambda: (_ for _ in ()).throw(RuntimeError("db error"))) + # Python workaround: use a proper raising function + class BadDb: + def get_summary(self): + raise RuntimeError("db error") + assert compute_health_score(BadDb()) is None diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..184d53f --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,106 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apiforgepy import ApiForgeMiddleware + + +def make_app(db_path=":memory:", sampling=1.0, ignore_paths=None): + app = FastAPI() + app.add_middleware( + ApiForgeMiddleware, + mode="local", + db_path=db_path, + dashboard_port=0, + flush_interval=999_999, + sampling=sampling, + ignore_paths=ignore_paths or [], + ) + + @app.get("/health") + def health(): + return {"status": "ok"} + + @app.get("/users/{user_id}") + def get_user(user_id: int): + return {"id": user_id} + + @app.get("/users") + def list_users(): + return [] + + @app.post("/users") + def create_user(): + return {"id": 1} + + @app.get("/error") + def raise_error(): + from fastapi import HTTPException + raise HTTPException(status_code=500, detail="boom") + + return app + + +class TestPassThrough: + def test_request_passes_through_unchanged(self): + client = TestClient(make_app()) + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_parametric_route_returns_correct_data(self): + client = TestClient(make_app()) + resp = client.get("/users/42") + assert resp.status_code == 200 + assert resp.json() == {"id": 42} + + def test_post_route_returns_201(self): + client = TestClient(make_app()) + resp = client.post("/users") + assert resp.status_code == 200 + + def test_404_passthrough(self): + client = TestClient(make_app()) + resp = client.get("/nonexistent") + assert resp.status_code == 404 + + +class TestIgnorePaths: + def test_ignored_path_still_returns_response(self): + client = TestClient(make_app(ignore_paths=["/health"])) + resp = client.get("/health") + assert resp.status_code == 200 + + def test_non_ignored_path_still_works(self): + client = TestClient(make_app(ignore_paths=["/health"])) + resp = client.get("/users") + assert resp.status_code == 200 + + +class TestSampling: + def test_sampling_at_1_records_all_requests(self): + """At sampling=1.0, every request should pass through.""" + client = TestClient(make_app(sampling=1.0)) + for _ in range(5): + resp = client.get("/health") + assert resp.status_code == 200 + + def test_sampling_at_0_still_passes_requests_through(self): + """At sampling=0.0, requests are not recorded but still served.""" + client = TestClient(make_app(sampling=0.0)) + resp = client.get("/health") + assert resp.status_code == 200 + + +class TestErrorHandling: + def test_5xx_response_passes_through(self): + client = TestClient(make_app(), raise_server_exceptions=False) + resp = client.get("/error") + assert resp.status_code == 500 + + def test_middleware_does_not_crash_the_app(self): + """Multiple requests including errors should not crash the server.""" + client = TestClient(make_app(), raise_server_exceptions=False) + for path in ["/health", "/users", "/error", "/health"]: + resp = client.get(path) + assert resp.status_code in (200, 500)