diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go index 4f082d0..b540b75 100644 --- a/backend/internal/garmin/client.go +++ b/backend/internal/garmin/client.go @@ -8,6 +8,7 @@ import ( "context" _ "embed" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -77,11 +78,20 @@ type wireRequest struct { // wireResponse is one line read from the wrapper subprocess's stdout. type wireResponse struct { - ID int `json:"id"` - Result json.RawMessage `json:"result,omitempty"` - Error string `json:"error,omitempty"` + ID int `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` + NotFound bool `json:"not_found,omitempty"` } +// ErrNotFound wraps any error a Client method returns when the wrapper +// reported a definitive HTTP 404 (garminconnect's own +// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted +// on Garmin's side after being linked to an activity. Callers use +// errors.Is(err, ErrNotFound) to distinguish this from a transient failure +// worth retrying. +var ErrNotFound = errors.New("garmin: resource not found") + // callParams is the Params payload for a generic "call" request: dispatches // to any garminconnect.Garmin method by name. type callParams struct { @@ -254,7 +264,11 @@ func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params return nil, err } if resp.Error != "" { - err = fmt.Errorf("%s: %s", cmdName, resp.Error) + if resp.NotFound { + err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound) + } else { + err = fmt.Errorf("%s: %s", cmdName, resp.Error) + } return nil, err } result = resp.Result diff --git a/backend/internal/garmin/client_test.go b/backend/internal/garmin/client_test.go index 9bf09d9..a6b8336 100644 --- a/backend/internal/garmin/client_test.go +++ b/backend/internal/garmin/client_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "log/slog" "os/exec" @@ -18,8 +19,9 @@ import ( // wireResponsePayload is what a fake wrapper handler returns for one // request; the harness fills in the response ID. type wireResponsePayload struct { - result json.RawMessage - err string + result json.RawMessage + err string + notFound bool } func fakeResult(v any) wireResponsePayload { @@ -34,6 +36,10 @@ func fakeError(msg string) wireResponsePayload { return wireResponsePayload{err: msg} } +func fakeNotFoundError(msg string) wireResponsePayload { + return wireResponsePayload{err: msg, notFound: true} +} + // newFakeWrapperClient wires a subprocessClient to an in-process goroutine // that plays the Python wrapper's role, so protocol-level Go logic can be // tested without python3/garminconnect installed. @@ -61,7 +67,7 @@ func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawM continue } payload := handle(req.Cmd, req.Params) - resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err} + resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound} if err := enc.Encode(resp); err != nil { return } @@ -134,6 +140,31 @@ func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) { } } +func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + return fakeNotFoundError("API Error 404") + }) + + _, err := c.roundTrip(context.Background(), "call", nil) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err) + } + if !strings.Contains(err.Error(), "API Error 404") { + t.Errorf("roundTrip error = %v, want it to still contain the original message", err) + } +} + +func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + return fakeError("boom") + }) + + _, err := c.roundTrip(context.Background(), "call", nil) + if errors.Is(err, ErrNotFound) { + t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err) + } +} + func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) { c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} c.started = true // simulate an already-spawned subprocess, no real cmd/pipes