feat(garmin): expose ErrNotFound for a wrapper-reported 404

roundTrip now wraps the returned error with the new ErrNotFound
sentinel whenever the wrapper's response set not_found (Task 1),
letting any Client method's caller distinguish a definitive 404 from
a transient failure via errors.Is, regardless of which garminconnect
method was called.
This commit is contained in:
2026-07-27 18:53:59 +02:00
parent 1d878e0f46
commit c6e2c5c00e
2 changed files with 52 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ import (
"context" "context"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -80,8 +81,17 @@ type wireResponse struct {
ID int `json:"id"` ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,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 // callParams is the Params payload for a generic "call" request: dispatches
// to any garminconnect.Garmin method by name. // to any garminconnect.Garmin method by name.
type callParams struct { type callParams struct {
@@ -254,7 +264,11 @@ func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params
return nil, err return nil, err
} }
if resp.Error != "" { if resp.Error != "" {
if resp.NotFound {
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)
} else {
err = fmt.Errorf("%s: %s", cmdName, resp.Error) err = fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return nil, err return nil, err
} }
result = resp.Result result = resp.Result

View File

@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"log/slog" "log/slog"
"os/exec" "os/exec"
@@ -20,6 +21,7 @@ import (
type wireResponsePayload struct { type wireResponsePayload struct {
result json.RawMessage result json.RawMessage
err string err string
notFound bool
} }
func fakeResult(v any) wireResponsePayload { func fakeResult(v any) wireResponsePayload {
@@ -34,6 +36,10 @@ func fakeError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg} return wireResponsePayload{err: msg}
} }
func fakeNotFoundError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg, notFound: true}
}
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine // newFakeWrapperClient wires a subprocessClient to an in-process goroutine
// that plays the Python wrapper's role, so protocol-level Go logic can be // that plays the Python wrapper's role, so protocol-level Go logic can be
// tested without python3/garminconnect installed. // tested without python3/garminconnect installed.
@@ -61,7 +67,7 @@ func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawM
continue continue
} }
payload := handle(req.Cmd, req.Params) 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 { if err := enc.Encode(resp); err != nil {
return 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) { func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes c.started = true // simulate an already-spawned subprocess, no real cmd/pipes