From a5d0371a30eb873e6d0c11fb4ea9c0bc27eb0a03 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 23 Sep 2026 21:44:24 +0200 Subject: [PATCH 1/2] fix(ratelimit): exempt health probes so Render liveness checks stop causing 502s Render probes /_health every 5s from one IP, which hit the default 50/hour limit and returned 429, marking the instance unhealthy and restarting it. --- app/routes/api.py | 1 + app/routes/api_v1.py | 1 + app/routes/main.py | 2 ++ tests/test_health_ratelimit_exempt.py | 37 +++++++++++++++++++++++++++ tests/test_security.py | 12 ++++----- 5 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 tests/test_health_ratelimit_exempt.py diff --git a/app/routes/api.py b/app/routes/api.py index 928a2409..b013b39a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -43,6 +43,7 @@ def _ai_error_response(exc: AIServiceError): @api_bp.route("/api/health") +@limiter.exempt @deprecated_session_api("/api/v1/health") def health_check(): """Health check endpoint for monitoring and error handling""" diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 155a885b..ce532ab6 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -225,6 +225,7 @@ def api_info(): @api_v1_bp.route("/health", methods=["GET"]) +@limiter.exempt def health_check(): """API health check endpoint --- diff --git a/app/routes/main.py b/app/routes/main.py index 06b79189..13773ce1 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -585,12 +585,14 @@ def productivity_dashboard(): @main_bp.route("/_health") +@limiter.exempt def health_check(): """Liveness probe: shallow checks only, no DB access""" return {"status": "healthy"}, 200 @main_bp.route("/_ready") +@limiter.exempt def readiness_check(): """Readiness probe: verify DB connectivity and critical dependencies""" try: diff --git a/tests/test_health_ratelimit_exempt.py b/tests/test_health_ratelimit_exempt.py new file mode 100644 index 00000000..be0fdb19 --- /dev/null +++ b/tests/test_health_ratelimit_exempt.py @@ -0,0 +1,37 @@ +"""Health/readiness probes must not be subject to the default rate limit. + +Render (and similar platforms) probe /_health every few seconds from a fixed +IP. With RATELIMIT_DEFAULT of "50 per hour", those probes would otherwise +return 429 after ~4 minutes and trigger unhealthy restarts / 502s. +""" + +import pytest + + +@pytest.mark.routes +def test_health_check_exempt_from_default_rate_limit(app, client): + """/_health must stay 200 even past the default 50-per-hour limit.""" + assert "50 per hour" in (app.config.get("RATELIMIT_DEFAULT") or "") + + for i in range(55): + response = client.get("/_health") + assert response.status_code == 200, f"request {i + 1} got {response.status_code}" + assert response.get_json()["status"] == "healthy" + + +@pytest.mark.routes +def test_ready_check_exempt_from_default_rate_limit(client): + """/_ready must also be exempt so readiness probes are not rate-limited.""" + for i in range(55): + response = client.get("/_ready") + assert response.status_code == 200, f"request {i + 1} got {response.status_code}" + + +@pytest.mark.routes +def test_api_health_endpoints_exempt_from_default_rate_limit(client): + """API health endpoints used by monitors/frontend probes stay available.""" + for i in range(55): + r1 = client.get("/api/health") + r2 = client.get("/api/v1/health") + assert r1.status_code == 200, f"/api/health request {i + 1} got {r1.status_code}" + assert r2.status_code == 200, f"/api/v1/health request {i + 1} got {r2.status_code}" diff --git a/tests/test_security.py b/tests/test_security.py index 5310e59e..0216825d 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -234,16 +234,16 @@ def test_path_traversal_in_file_download(authenticated_client): @pytest.mark.security @pytest.mark.slow def test_api_rate_limiting(client): - """Test API rate limiting (if implemented).""" - # Make many requests in quick succession + """Test that burst traffic against a rate-limited route does not crash the app. + + Health probes are exempt from the default limit; use a normal public page. + """ responses = [] for i in range(100): - response = client.get("/_health") + response = client.get("/about") responses.append(response.status_code) - # If rate limiting is implemented, should get 429 responses - # If not implemented, all should be 200 - # This test just checks the system doesn't crash + # May be 200 or 429 depending on limit state; must not error assert all(code in [200, 429] for code in responses) From 4807601e698bc9190d18272f4d5144da5d7bd832 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 23 Sep 2026 21:45:45 +0200 Subject: [PATCH 2/2] chore: bump version to 5.17.1 and update docs Add 5.17.1 changelog entry for the health-probe rate-limit exemption, add README highlights, align VERSION and desktop/extension/mobile clients, and refresh GAP_ROADMAP version references. --- CHANGELOG.md | 10 ++++++++++ README.md | 4 ++++ VERSION | 2 +- browser-extension/manifest.json | 2 +- browser-extension/package.json | 2 +- desktop/package.json | 2 +- docs/GAP_ROADMAP.md | 4 ++-- mobile/pubspec.yaml | 2 +- setup.py | 2 +- 9 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c02b31a..8d4e4762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.17.1] - 2026-09-23 + +### Fixed + +- **Health probes rate-limited** — `/_health`, `/_ready`, `/api/health`, and `/api/v1/health` are now exempt from the default rate limit. Render probes `/_health` every 5s from a single IP, which exceeded the default limit, returned 429, and caused the instance to be marked unhealthy and restarted (surfacing as 502s). + +### Documentation + +- **Version** — Bumped `setup.py` to **5.17.1**; `VERSION` and desktop/browser-extension/mobile client versions aligned. + ## [5.17.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index 4ee9a02c..9cc7f2bd 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh **Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. +### ✨ Highlights of v5.17.1 + +**Patch (5.17.1):** **Health probes exempt from rate limiting** — `/_health`, `/_ready`, and the API health endpoints no longer return 429 under frequent liveness checks, fixing Render restart loops and 502s. See [CHANGELOG.md](CHANGELOG.md#5171---2026-09-23). + ### ✨ Highlights of v5.17.0 **Minor (5.17.0):** **Factur-X / ZUGFeRD (#433)** — structured addresses, VAT categories, PDF/A-3 embed, and EN 16931 CII fixes. **Idle unanswered action (#722)** — admin choice of review vs auto-stop when Still working? expires. **Phase 4 gap roadmap** — GDPR erasure, weekly goals / recurring tasks / project templates API, estimates vs actuals report, multi-level timesheet approval, XRechnung helper, Mollie skeleton. **Phase 5 foundations** — OAuth app models, SCIM Users stub, AI summarize-entries, Teams bot stub, and design docs. **UI / hygiene** — shared confirm dialogs, empty states, command palette expansion, security rate limits, and dead-code cleanup. See [CHANGELOG.md](CHANGELOG.md#5170---2026-09-23). diff --git a/VERSION b/VERSION index ad95545a..d3f30982 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.17.0 +5.17.1 diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 01c72ebe..07883715 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "TimeTracker Timer", - "version": "5.17.0", + "version": "5.17.1", "description": "Start and stop TimeTracker timers from your browser toolbar.", "permissions": [ "storage", diff --git a/browser-extension/package.json b/browser-extension/package.json index d05d76ba..6c848f74 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -1,7 +1,7 @@ { "name": "timetracker-browser-extension", "private": true, - "version": "5.17.0", + "version": "5.17.1", "description": "TimeTracker Chromium extension (Manifest V3)", "type": "module", "scripts": { diff --git a/desktop/package.json b/desktop/package.json index 92410b58..2b6730d7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "timetracker-desktop", - "version": "5.17.0", + "version": "5.17.1", "description": "TimeTracker desktop app for Windows, Linux, and macOS", "main": "src/main/main.js", "scripts": { diff --git a/docs/GAP_ROADMAP.md b/docs/GAP_ROADMAP.md index 0000b88b..25df770f 100644 --- a/docs/GAP_ROADMAP.md +++ b/docs/GAP_ROADMAP.md @@ -76,8 +76,8 @@ See implementation commits and CHANGELOG Unreleased section for other Phase 1 it **Versions** -- Source of truth: `setup.py` (`5.17.0`); mirror `VERSION` at repo root. -- Clients aligned: `desktop/package.json`, `browser-extension/package.json` + `manifest.json`, `mobile/pubspec.yaml` (`5.17.0+1`). +- Source of truth: `setup.py` (`5.17.1`); mirror `VERSION` at repo root. +- Clients aligned: `desktop/package.json`, `browser-extension/package.json` + `manifest.json`, `mobile/pubspec.yaml` (`5.17.1+1`). - Desktop sidebar label via Vite `__APP_VERSION__` (`desktop/vite.config.mjs` reads `desktop/package.json`). **API login + 2FA** diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 302c94a0..6acc3fe8 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: timetracker_mobile description: TimeTracker mobile app for Android and iOS publish_to: 'none' -version: 5.17.0+1 +version: 5.17.1+1 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/setup.py b/setup.py index 3b8b319e..9aedc05c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='5.17.0', + version='5.17.1', packages=find_packages(), include_package_data=True, package_data={