Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.17.0
5.17.1
1 change: 1 addition & 0 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
1 change: 1 addition & 0 deletions app/routes/api_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ def api_info():


@api_v1_bp.route("/health", methods=["GET"])
@limiter.exempt
def health_check():
"""API health check endpoint
---
Expand Down
2 changes: 2 additions & 0 deletions app/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion browser-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion browser-extension/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions docs/GAP_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

setup(
name='timetracker',
version='5.17.0',
version='5.17.1',
packages=find_packages(),
include_package_data=True,
package_data={
Expand Down
37 changes: 37 additions & 0 deletions tests/test_health_ratelimit_exempt.py
Original file line number Diff line number Diff line change
@@ -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}"
12 changes: 6 additions & 6 deletions tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading