From 1d878e0f46b2d86489ff96417749bc881bec5aed Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Mon, 27 Jul 2026 18:50:43 +0200 Subject: [PATCH] feat(garmin): mark a wrapper 404 with not_found in the error response garminconnect's own GarminConnectNotFoundError already exists specifically for this (its docstring: "so callers can now catch a missing resource specifically, e.g. deleting an already-deleted workout"), raised by connectapi() for any real HTTP 404. dispatch() now surfaces that distinction as an extra not_found: true field alongside the existing error string, so internal/garmin/client.go (next commit) can tell a definitive 404 apart from a transient failure. Co-Authored-By: Claude Sonnet 5 --- .../garmin/pyscript/tests/test_wrapper.py | 22 +++++++++++++++++++ backend/internal/garmin/pyscript/wrapper.py | 12 ++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/internal/garmin/pyscript/tests/test_wrapper.py b/backend/internal/garmin/pyscript/tests/test_wrapper.py index 6e3fe67..3d20cc3 100644 --- a/backend/internal/garmin/pyscript/tests/test_wrapper.py +++ b/backend/internal/garmin/pyscript/tests/test_wrapper.py @@ -269,3 +269,25 @@ def test_authenticate_prevents_later_lazy_startup_login(): wrapper.dispatch({"id": 34, "cmd": "call", "params": {"method": "x", "args": {}}}) mock_startup.assert_not_called() + + +def test_call_marks_not_found_error_specifically(): + from garminconnect import GarminConnectNotFoundError + + wrapper._auth_state = "authenticated" + wrapper._client = MagicMock() + wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404") + resp = wrapper.dispatch({ + "id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}}, + }) + assert resp == {"id": 11, "error": "API Error 404", "not_found": True} + + +def test_call_does_not_mark_other_errors_as_not_found(): + wrapper._auth_state = "authenticated" + wrapper._client = MagicMock() + wrapper._client.get_workout_by_id.side_effect = Exception("rate limited") + resp = wrapper.dispatch({ + "id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}}, + }) + assert resp == {"id": 12, "error": "rate limited"} diff --git a/backend/internal/garmin/pyscript/wrapper.py b/backend/internal/garmin/pyscript/wrapper.py index fc0e823..074f024 100644 --- a/backend/internal/garmin/pyscript/wrapper.py +++ b/backend/internal/garmin/pyscript/wrapper.py @@ -9,7 +9,7 @@ import sys import threading import traceback -from garminconnect import Garmin +from garminconnect import Garmin, GarminConnectNotFoundError TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin")) @@ -208,7 +208,15 @@ def dispatch(req): except Exception as exc: _debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}") _debug(traceback.format_exc()) - return {"id": req.get("id"), "error": str(exc)} + resp = {"id": req.get("id"), "error": str(exc)} + # A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's + # side after being linked to an activity) is definitive, not a + # transient failure worth retrying forever -- marked specifically so + # internal/garmin/client.go can tell the two apart (see + # docs/superpowers/specs/2026-07-27-workout-not-found-design.md). + if isinstance(exc, GarminConnectNotFoundError): + resp["not_found"] = True + return resp def main():