diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml new file mode 100644 index 0000000..d6ba304 --- /dev/null +++ b/.github/workflows/keepalive.yml @@ -0,0 +1,34 @@ +name: Keep SupportOps available + +on: + schedule: + - cron: "17 13 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + health-check: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - name: Wake the API + run: | + curl --fail-with-body \ + --retry 3 \ + --retry-all-errors \ + --retry-delay 10 \ + --connect-timeout 15 \ + --max-time 90 \ + https://supportops-api.onrender.com/health + + - name: Verify the database connection + run: | + curl --fail-with-body \ + --retry 5 \ + --retry-all-errors \ + --retry-delay 20 \ + --connect-timeout 15 \ + --max-time 90 \ + https://supportops-api.onrender.com/health/dependencies diff --git a/README.md b/README.md index 7b2e208..31193cb 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,9 @@ App runs at `http://localhost:5173`. - **Frontend** — Vercel, SPA rewrites in `frontend/vercel.json`. Env: `VITE_API_BASE_URL`. - **Backend** — Render web service from `render.yaml` at repo root. Python 3.11.9. Env: `SUPABASE_URL`, `SUPABASE_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`. Health check: `/health`. -Free-tier dynos sleep after 15 min of inactivity. The frontend warms the backend on app load — see `frontend/src/api.js` (`warmupBackend`). +Free-tier dynos sleep after 15 min of inactivity. The frontend warms the backend on app load, and `.github/workflows/keepalive.yml` runs a daily dependency check to keep the free Supabase project active and catch outages early. + +`GET /health` confirms the API process is running. `GET /health/dependencies` performs a real database query and returns `503` with a `Retry-After` header when Supabase is unavailable. --- diff --git a/backend/database.py b/backend/database.py index 3d24182..6b1203b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -8,8 +8,7 @@ SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_KEY") DATABASE_UNAVAILABLE_DETAIL = ( - "SupportOps database is unavailable. Check SUPABASE_URL, SUPABASE_KEY, " - "and Supabase table setup in Render." + "SupportOps data service is temporarily unavailable. It may be starting up; retry in a moment." ) @@ -57,13 +56,17 @@ def handle_response(response): def request_supabase(method, table, params="", data=None): query = f"?{params}" if params else "" - response = requests.request( - method, - f"{SUPABASE_URL}/rest/v1/{table}{query}", - headers=get_headers(), - json=data, - timeout=20, - ) + try: + response = requests.request( + method, + f"{SUPABASE_URL}/rest/v1/{table}{query}", + headers=get_headers(), + json=data, + timeout=20, + ) + except requests.RequestException as error: + raise DatabaseRequestError(DATABASE_UNAVAILABLE_DETAIL) from error + return handle_response(response) diff --git a/backend/main.py b/backend/main.py index 9b87fe0..b1d7063 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,11 @@ def database_config_error(_request: Request, exc: DatabaseConfigError): @app.exception_handler(DatabaseRequestError) def database_request_error(_request: Request, exc: DatabaseRequestError): - return JSONResponse(status_code=503, content={"detail": str(exc)}) + return JSONResponse( + status_code=503, + content={"detail": str(exc)}, + headers={"Retry-After": "30"}, + ) # --- Models --- @@ -104,6 +108,7 @@ def dependency_health(response: Response): reachable = True except (DatabaseConfigError, DatabaseRequestError) as error: response.status_code = 503 + response.headers["Retry-After"] = "30" detail = str(error) return { diff --git a/backend/test_database.py b/backend/test_database.py index fbccf45..9b5f3a4 100644 --- a/backend/test_database.py +++ b/backend/test_database.py @@ -76,5 +76,17 @@ def test_supabase_http_error_raises_database_error(monkeypatch): fake_response.raise_for_status.side_effect = requests.HTTPError("503") monkeypatch.setattr(database.requests, "request", MagicMock(return_value=fake_response)) - with pytest.raises(database.DatabaseRequestError, match="SupportOps database is unavailable"): + with pytest.raises(database.DatabaseRequestError, match="temporarily unavailable"): + database.db_get("tickets") + + +@pytest.mark.parametrize( + "error", [requests.Timeout("timed out"), requests.ConnectionError("offline")] +) +def test_supabase_network_error_raises_database_error(monkeypatch, error): + monkeypatch.setattr(database, "SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setattr(database, "SUPABASE_KEY", "secret") + monkeypatch.setattr(database.requests, "request", MagicMock(side_effect=error)) + + with pytest.raises(database.DatabaseRequestError, match="temporarily unavailable"): database.db_get("tickets") diff --git a/backend/test_main.py b/backend/test_main.py index db693e6..3260ab7 100644 --- a/backend/test_main.py +++ b/backend/test_main.py @@ -196,10 +196,28 @@ def test_dependency_health_returns_503_when_supabase_query_fails(client, monkeyp res = test_client.get("/health/dependencies") assert res.status_code == 503 + assert res.headers["retry-after"] == "30" assert res.json()["status"] == "error" assert res.json()["supabase"]["reachable"] is False +def test_data_route_returns_503_when_supabase_times_out(client, monkeypatch): + test_client, main = client + monkeypatch.setattr( + main, + "db_get", + lambda table, params="": (_ for _ in ()).throw( + main.DatabaseRequestError("SupportOps data service is temporarily unavailable.") + ), + ) + + res = test_client.get("/customers") + + assert res.status_code == 503 + assert res.headers["retry-after"] == "30" + assert res.json()["detail"] == "SupportOps data service is temporarily unavailable." + + def test_get_ticket_not_found_returns_404(client, monkeypatch): test_client, main = client monkeypatch.setattr(main, "db_get", lambda table, params="": []) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 85888b0..e07ee98 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,36 +6,41 @@ import Tickets from "./pages/Tickets"; import RMAs from "./pages/RMAs"; export default function App() { + const navigation = [ + { to: "/", label: "Dashboard" }, + { to: "/tickets", label: "Tickets" }, + { to: "/customers", label: "Customers" }, + { to: "/devices", label: "Devices" }, + { to: "/rmas", label: "RMAs" }, + ]; + return ( -
- {/* Sidebar */} -