6.5 KiB
Stop retrying a permanently-missing Garmin workout forever — Design
Status: Approved, ready for implementation planning.
Origin: user report -- /api/sync/status's workouts_pending count stuck at 1 forever, no
matter how many times "Sync now" is clicked.
Root cause
fillPendingWorkouts/fillActivityWorkout (backend/internal/sync/service.go:339-371) treats
every get_workout_by_id failure identically: log it, leave workout_raw_json NULL, and let
ActivitiesMissingWorkout naturally retry it on the next sync. This is the right behavior for a
transient failure (network blip, rate limiting), but wrong for a genuine HTTP 404 -- the
activity's workout_id points at a workout that was deleted on Garmin's side after the activity
was recorded (confirmed via the user's own backend log: a 404 for a specific workout_id). A 404
is definitive and will never succeed on retry, yet the current code retries it every single sync,
forever, with the failure only ever logged server-side (log.Printf) -- never surfaced to the
user beyond a perpetual, unexplained "1 more workout pending" nudge.
Fix
An activity can legitimately end up with workout_id set but workout_raw_json permanently
NULL when the workout can't be found on Garmin -- workout_raw_json stays NULL forever (no
fabricated data), but a new, separate marker records "confirmed missing" distinctly from "not yet
fetched," so ActivitiesMissingWorkout/CountActivitiesMissingWorkout stop counting it.
internal/garmin/pyscript/wrapper.py
garminconnect already defines GarminConnectNotFoundError (a subclass of
GarminConnectConnectionError) specifically for this case -- its own docstring says "so callers
can now catch a missing resource specifically (e.g. deleting an already-deleted workout)." It's
raised by connectapi() (used by get_workout_by_id and other lookups) whenever the underlying
HTTP response is a 404.
dispatch()'s generic exception handler (used by every cmd, including the generic call
dispatcher that get_workout_by_id goes through) gains a check: if the caught exception is a
GarminConnectNotFoundError, the returned JSON error response gets an additional
"not_found": true field alongside the existing "error" string. This is deliberately generic
(any Garmin API call that 404s gets this marker, not just workouts) -- the decision about what to
do with a not-found error stays method-specific in the Go/sync layer.
internal/garmin/client.go
wireResponsegainsNotFound booljson:"not_found,omitempty"``.- A new sentinel:
var ErrNotFound = errors.New("garmin: resource not found"). execute: whenresp.Error != "" && resp.NotFound, the returned error wrapsErrNotFound(fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)), so callers canerrors.Is(err, garmin.ErrNotFound)regardless of which method was called.
internal/store (schema + queries)
schema.sql:activitiesgainsworkout_not_found_at TEXT(nullable), placed next toworkout_raw_json, following the exact same style as the existingdetails_fetched_at/splits_fetched_attimestamp markers.- New method
SetActivityWorkoutNotFound(ctx, userID, activityID) error, mirroringSetActivitySplitsFetched's exact shape (UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ?). ActivitiesMissingWorkout/CountActivitiesMissingWorkoutboth gainAND workout_not_found_at IS NULLin theirWHEREclause.UpsertActivity'sON CONFLICT DO UPDATEalready never touchesdetails_fetched_at/details_raw_json/splits_fetched_at/workout_raw_json--workout_not_found_atfollows the same pattern, so it's untouched by a later re-sync and the "confirmed missing" marker persists indefinitely once set.docs/DATABASE.mdregenerated viago run ./cmd/dumpschemaafter the schema edit.
internal/sync/service.go
In fillPendingWorkouts's loop, when fillActivityWorkout returns an error:
- If
errors.Is(err, garmin.ErrNotFound): calldb.SetActivityWorkoutNotFound(ctx, s.userID, a.ID)and log at Info level ("workout not found on Garmin, likely deleted -- marking as such, will not retry"). No further retry. - Otherwise: keep the existing behavior exactly as it is today (log a warning, leave
workout_raw_jsonNULL, naturally retried on the next sync).
internal/garmin/mock
mock.Client's existing WorkoutErrByID map[int64]error field (added in a previous plan) needs
no shape change -- a test simulating a 404 just sets the mapped error to
fmt.Errorf("...: %w", garmin.ErrNotFound) (or garmin.ErrNotFound directly), which
errors.Is picks up the same way a real wrapped error would.
Non-goals
- No change to
fillActivityDetails/fillPendingActivityDetails's error handling (a details/splits fetch failure still aborts the batch and surfaces as a sync error, as it does today) -- this fix is scoped to workouts specifically, matching the confirmed root cause. If a details/splits 404 ever turns out to need the same treatment, that's a separate, unconfirmed problem to investigate on its own evidence. - No frontend change --
workouts_pendingsimply stops counting a confirmed-missing workout, so the existing SyncModal/banner UI needs nothing new. - No handling for the (very unlikely) edge case of Garmin re-linking a different
workout_idto the same activity afterworkout_not_found_atwas set for an earlier one -- accepted as an edge case not worth the added complexity.
Testing
internal/garmin/pyscript/tests/test_wrapper.py: a new test mirroring the existingtest_call_propagates_garminconnect_exception_as_error, but raisingGarminConnectNotFoundErrorand asserting the response includes"not_found": true.internal/garmin(Go): a test assertingexecutewraps the error withErrNotFoundwhen the wire response setsnot_found: true.internal/store: extend the existingActivitiesMissingWorkouttest (or add a new one) with a fixture that hasworkout_not_found_atset, asserting it's excluded from bothActivitiesMissingWorkoutandCountActivitiesMissingWorkout.internal/sync: a test usingmock.Client.WorkoutErrByIDset to agarmin.ErrNotFound-wrapped error, assertingfillPendingWorkouts/FillPendingDetailscompletes without error, the activity'sworkout_not_found_atgets set, and it's excluded from a subsequentCountActivitiesMissingWorkoutcall (i.e., confirming it does NOT get retried on a second call, unlike today's forever-retry behavior for this exact scenario).