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:
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -77,11 +78,20 @@ type wireRequest struct {
|
|||||||
|
|
||||||
// wireResponse is one line read from the wrapper subprocess's stdout.
|
// wireResponse is one line read from the wrapper subprocess's stdout.
|
||||||
type wireResponse struct {
|
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 != "" {
|
||||||
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
|
return nil, err
|
||||||
}
|
}
|
||||||
result = resp.Result
|
result = resp.Result
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -18,8 +19,9 @@ import (
|
|||||||
// wireResponsePayload is what a fake wrapper handler returns for one
|
// wireResponsePayload is what a fake wrapper handler returns for one
|
||||||
// request; the harness fills in the response ID.
|
// request; the harness fills in the response ID.
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user