From f52a19181dfe76eecec9f8649c137f87dd3a457e Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 14:00:28 -0500 Subject: [PATCH] fix: fail closed on cancelled Connect and timeout RPC fallback --- connect.go | 10 +++++++--- rh_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/connect.go b/connect.go index ed0f8f6..4945c0b 100644 --- a/connect.go +++ b/connect.go @@ -2,7 +2,10 @@ package rh import ( "context" + "errors" "fmt" + "net/http" + "time" "s1d3sw1ped/robinhood-agentic-mcp/accounts" "s1d3sw1ped/robinhood-agentic-mcp/auth" @@ -18,9 +21,6 @@ import ( // Connect reads tokens and returns a wired API. Session connect is preferred; // JSON-RPC tools/call is used when the session cannot be opened. Missing tokens fail closed. func Connect(ctx context.Context, cfg Config) (*API, error) { - if err := ctx.Err(); err != nil { - return nil, err - } cfg = cfg.WithDefaults() if cfg.TokenFile == "" { return nil, fmt.Errorf("connect: missing TokenFile") @@ -34,11 +34,15 @@ func Connect(ctx context.Context, cfg Config) (*API, error) { } c, err := client.ConnectSession(ctx, cfg.URL, auth.ClientToken(tok), cfg.Name, cfg.Version) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } c = &client.Client{ URL: cfg.URL, Token: tok.AccessToken, Name: cfg.Name, Version: cfg.Version, + HTTP: &http.Client{Timeout: 60 * time.Second}, } } return &API{ diff --git a/rh_test.go b/rh_test.go index 1264307..7dc1aaf 100644 --- a/rh_test.go +++ b/rh_test.go @@ -1,11 +1,14 @@ package rh_test import ( + "context" "encoding/json" + "errors" "os" "path/filepath" "sort" "testing" + "time" "github.com/google/go-cmp/cmp" "s1d3sw1ped/robinhood-agentic-mcp" @@ -44,8 +47,48 @@ func TestConnect_rpcFallback(t *testing.T) { if err != nil { t.Fatal(err) } + if api.Client.HTTP == nil { + t.Fatal("nil HTTP client") + } + if api.Client.HTTP.Timeout != 60*time.Second { + t.Fatalf("HTTP.Timeout = %v", api.Client.HTTP.Timeout) + } _, err = api.Equity.Quotes(t.Context(), equity.QuotesRequest{Symbols: []string{"MU"}}) if err != nil { t.Fatal(err) } } + +func TestConnect_canceledContext(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "tokens.json") + if err := auth.WriteTokens(path, "tok", ""); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + api, err := rh.Connect(ctx, rh.Config{URL: "http://127.0.0.1:1", TokenFile: path, Name: "tradey"}) + if api != nil { + t.Fatal("expected nil API") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("%v", err) + } +} + +func TestConnect_deadlineExceeded(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "tokens.json") + if err := auth.WriteTokens(path, "tok", ""); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer cancel() + api, err := rh.Connect(ctx, rh.Config{URL: "http://127.0.0.1:1", TokenFile: path, Name: "tradey"}) + if api != nil { + t.Fatal("expected nil API") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("%v", err) + } +}