Skip to content

Commit cb85bb0

Browse files
fix(cli): item-envelope detail decode + provision-timeout orphan guidance (#29)
* fix(cli): item-envelope detail decode + provision-timeout orphan guidance Two bugs from the live durability sweep. BUG 1 (silent): `instant resource <token>` printed an all-empty object. runResourceDetail only unwrapped a bare object or a `"resource"` envelope, but prod GET /api/v1/resources/:token wraps the object under `"item"` ({"item":{...},"ok":true}) — neither branch matched, so every field but the path-token fallback rendered empty with exit 0. Teach the decoder the "item" key (preferred), keeping "resource" + bare-object fallbacks for back-compat. BUG 2 (durability UX): on a provision timeout the CLI exited 1 with a bare "context deadline exceeded" — but provisioning is synchronous server-side, so the resource may have already landed as an orphan the user can't see. The 60s default timeout is intentional (TestBugHunt_T16_P2_2) and unchanged; instead, detect the client/context-deadline timeout in provisionResource and surface actionable recovery guidance (run `instant resources` / `instant resource delete <token> --yes`) via a named const, preserving the cause with %w. Tests: TestRunResourceDetail_ItemEnvelopeRenders (decodes {"item":{...}} and asserts fields render), TestProvisionTimeout_SurfacesOrphanGuidance (forces a real client timeout, asserts the guidance). Both proven red-without-fix / green-with-fix. Gate green: go build/vet/test -short, gofmt, golangci-lint 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cli): pin every isTimeoutErr branch for 100% patch coverage The diff-cover gate flagged cmd/monitor.go 297-298 (nil-error guard) and 304-305 (net.Error Timeout()==true branch) as uncovered: the existing provision-timeout regression test only reaches the errors.Is(DeadlineExceeded) path because http.Client.Timeout errors match DeadlineExceeded first. Add TestIsTimeoutErr — a table-driven unit test with a synthetic net.Error (fakeNetTimeoutErr, message via named const) covering nil, bare/wrapped DeadlineExceeded, bare/wrapped timeout net.Error, non-timeout net.Error, and plain errors. isTimeoutErr is now 100% covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 86dbb5e commit cb85bb0

6 files changed

Lines changed: 201 additions & 13 deletions

File tree

cmd/bughunt_p2_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package cmd
2121
import (
2222
"encoding/json"
2323
"net/http"
24+
"net/http/httptest"
2425
"strings"
2526
"testing"
2627
"time"
@@ -141,6 +142,44 @@ func TestBugHunt_T16_P2_2_ProvisionTimeoutAtLeast60s(t *testing.T) {
141142
}
142143
}
143144

145+
// TestProvisionTimeout_SurfacesOrphanGuidance is the BUG 2 durability
146+
// regression: a provision that hits the client/context deadline must NOT exit
147+
// with a bare "context deadline exceeded" — the resource may have already
148+
// landed server-side (provisioning is synchronous on the api), so the user
149+
// needs an actionable next step to find + clean up the potential orphan.
150+
//
151+
// We point a short-timeout client at a server that never responds, forcing a
152+
// real client-timeout (*url.Error with Timeout()==true) through
153+
// provisionResource, and assert the named-const guidance is surfaced.
154+
func TestProvisionTimeout_SurfacesOrphanGuidance(t *testing.T) {
155+
block := make(chan struct{})
156+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157+
<-block // hang until the client times out
158+
}))
159+
defer srv.Close()
160+
defer close(block)
161+
162+
prevURL, prevClient := APIBaseURL, HTTPClient
163+
APIBaseURL = srv.URL
164+
HTTPClient = &http.Client{Timeout: 50 * time.Millisecond}
165+
t.Cleanup(func() { APIBaseURL, HTTPClient = prevURL, prevClient })
166+
167+
_, err := provisionResource("/db/new", "orphan-on-timeout", "")
168+
if err == nil {
169+
t.Fatal("provision against a hanging server must error (client timeout)")
170+
}
171+
msg := err.Error()
172+
if !strings.Contains(msg, provisionTimeoutGuidance) {
173+
t.Errorf("timeout error must surface orphan guidance %q; got %q", provisionTimeoutGuidance, msg)
174+
}
175+
// The guidance must name the recovery commands so an agent/user can act.
176+
for _, want := range []string{"instant resources", "instant resource delete"} {
177+
if !strings.Contains(msg, want) {
178+
t.Errorf("timeout guidance must mention %q; got %q", want, msg)
179+
}
180+
}
181+
}
182+
144183
// ── T16 P2-3 — `up` env default is "development", not "production".
145184

146185
// TestBugHunt_T16_P2_3_UpDefaultsToDevelopmentNotProd asserts an `up` run with

cmd/extras.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,23 @@ func runResourceDetail(cmd *cobra.Command, token string) error {
141141
return parseAPIError(resp.StatusCode, raw)
142142
}
143143

144-
// The API may return the bare resource object OR an envelope:
145-
// {ok:true, resource:{...}}. Accept either shape.
144+
// The API may return the bare resource object OR an envelope. Prod
145+
// GET /api/v1/resources/:token wraps the object under "item"
146+
// ({"item":{...},"ok":true}); older/alternate shapes use "resource".
147+
// Accept "item" first (today's prod), then "resource" (back-compat),
148+
// then fall back to the bare object — so a future un-enveloped response
149+
// still renders.
146150
var envelope struct {
147151
OK bool `json:"ok"`
152+
Item json.RawMessage `json:"item"`
148153
Resource json.RawMessage `json:"resource"`
149154
}
150155
_ = json.Unmarshal(raw, &envelope)
151156
body := raw
152-
if len(envelope.Resource) > 0 {
157+
switch {
158+
case len(envelope.Item) > 0:
159+
body = envelope.Item
160+
case len(envelope.Resource) > 0:
153161
body = envelope.Resource
154162
}
155163

cmd/extras_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,58 @@ func TestRunResourceDetail_Success_JSON(t *testing.T) {
131131
}
132132
}
133133

134+
// TestRunResourceDetail_ItemEnvelopeRenders is the BUG 1 regression: prod
135+
// GET /api/v1/resources/:token wraps the object under "item"
136+
// ({"item":{...},"ok":true}). Before the fix the detail decoder only knew
137+
// the bare-object and "resource"-envelope shapes, so it rendered an all-empty
138+
// object with exit 0 (silent). This asserts the "item" key is unwrapped and
139+
// the fields actually render to stdout — mirroring how discover.go's list
140+
// path keys off "items".
141+
func TestRunResourceDetail_ItemEnvelopeRenders(t *testing.T) {
142+
withCleanState(t)
143+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144+
w.Header().Set("Content-Type", "application/json")
145+
_, _ = w.Write([]byte(`{"ok":true,"item":{
146+
"token":"tok-item","id":"id-item","resource_type":"postgres","name":"app-item",
147+
"env":"production","tier":"pro","status":"active",
148+
"connection_url":"postgres://u:p@x/db"
149+
}}`))
150+
}))
151+
defer srv.Close()
152+
prev := APIBaseURL
153+
APIBaseURL = srv.URL
154+
t.Cleanup(func() { APIBaseURL = prev })
155+
156+
// runResourceDetail short-circuits with errAuthRequired unless haveAuth()
157+
// is true — wire an authTransport so the request actually reaches the mock.
158+
prevClient := HTTPClient
159+
HTTPClient = &http.Client{Transport: &authTransport{base: http.DefaultTransport, apiKey: "x"}}
160+
t.Cleanup(func() { HTTPClient = prevClient })
161+
162+
stdout, _ := captureStdout(t, func() {
163+
if err := runResourceDetail(nil, "tok-item"); err != nil {
164+
t.Fatalf("runResourceDetail (item envelope): %v", err)
165+
}
166+
})
167+
168+
// The fields nested under "item" must render — an all-empty object is the
169+
// pre-fix bug.
170+
for _, want := range []string{
171+
"tok-item", // TOKEN
172+
"id-item", // ID
173+
"postgres", // TYPE
174+
"app-item", // NAME
175+
"production", // ENV
176+
"pro", // TIER
177+
"active", // STATUS
178+
"postgres://u:p@x/db", // URL
179+
} {
180+
if !strings.Contains(stdout, want) {
181+
t.Errorf("item-envelope detail must render %q; stdout=%q", want, stdout)
182+
}
183+
}
184+
}
185+
134186
func TestRunResourceDelete_EmptyToken(t *testing.T) {
135187
err := runResourceDelete(nil, "")
136188
if err == nil || !strings.Contains(err.Error(), "token is required") {

cmd/monitor.go

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package cmd
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
7+
"errors"
68
"fmt"
79
"io"
10+
"net"
811
"net/http"
912
"os"
1013
"regexp"
@@ -67,20 +70,22 @@ func validateResourceName(name string) error {
6770
// storage / webhook / vector) MUST reject unknown sub-sub-commands with a
6871
// non-zero exit. The previous behaviour was:
6972
//
70-
// instant db delete <id> → prints help, exits 0
73+
// instant db delete <id> → prints help, exits 0
7174
//
7275
// which silently hid typo bugs in agent scripts and let `... | xargs instant`
7376
// pipelines look successful. The pattern below combines:
7477
//
75-
// 1. Args: cobra.NoArgs — refuses any positional arg
76-
// 2. RunE: showGroupHelp — when called with zero args, shows
77-
// help and exits 0 (the legacy path)
78-
// 3. cobra's built-in "did you mean?" suggestions surface for typos that
79-
// are within 2 edits of a valid subcommand (cobra default).
78+
// 1. Args: cobra.NoArgs — refuses any positional arg
79+
// 2. RunE: showGroupHelp — when called with zero args, shows
80+
// help and exits 0 (the legacy path)
81+
// 3. cobra's built-in "did you mean?" suggestions surface for typos that
82+
// are within 2 edits of a valid subcommand (cobra default).
8083
//
8184
// Together, `instant db delete <id>` now errors with:
82-
// Error: unknown command "delete" for "instant db"
83-
// Run 'instant db --help' for usage.
85+
//
86+
// Error: unknown command "delete" for "instant db"
87+
// Run 'instant db --help' for usage.
88+
//
8489
// and exits 1.
8590
func showGroupHelp(cmd *cobra.Command, args []string) error {
8691
return cmd.Help()
@@ -248,6 +253,13 @@ func provisionResource(endpoint, name, env string) (*provisionResponse, error) {
248253

249254
resp, err := HTTPClient.Post(url, "application/json", bytes.NewReader(body))
250255
if err != nil {
256+
// A provision that hit the client/context deadline may have ALREADY
257+
// landed server-side (provisioning is synchronous on the api) — exit 1
258+
// alone hides a possible orphan. Surface actionable durability guidance
259+
// while preserving the underlying cause for %w-aware callers.
260+
if isTimeoutErr(err) {
261+
return nil, fmt.Errorf("%s (%w)", provisionTimeoutGuidance, err)
262+
}
251263
return nil, err
252264
}
253265
defer func() { _ = resp.Body.Close() }()
@@ -273,6 +285,27 @@ func provisionResource(endpoint, name, env string) (*provisionResponse, error) {
273285
return &result, nil
274286
}
275287

288+
// isTimeoutErr reports whether err is a request timeout — either the
289+
// http.Client.Timeout firing (surfaces as a net.Error with Timeout()==true,
290+
// wrapped in a *url.Error) or a context deadline being exceeded. Both mean the
291+
// provision request was abandoned client-side, so the resource may still be
292+
// landing server-side. Kept separate from json_error.go's network classifier
293+
// because that path emits a generic "network_error"; here we want the orphan
294+
// durability hint specifically on the timeout case.
295+
func isTimeoutErr(err error) bool {
296+
if err == nil {
297+
return false
298+
}
299+
if errors.Is(err, context.DeadlineExceeded) {
300+
return true
301+
}
302+
var netErr net.Error
303+
if errors.As(err, &netErr) && netErr.Timeout() {
304+
return true
305+
}
306+
return false
307+
}
308+
276309
// ── status command ────────────────────────────────────────────────────────────
277310

278311
// statusJSON is the --json flag for `instant status`. T16 P3: machine-readable

cmd/monitor_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ package cmd
66

77
import (
88
"bytes"
9+
"context"
10+
"errors"
11+
"fmt"
12+
"net"
913
"net/http"
1014
"net/http/httptest"
1115
"strings"
@@ -140,3 +144,47 @@ func withTestAPI(t *testing.T, baseURL string) {
140144
HTTPClient = prevClient
141145
})
142146
}
147+
148+
// ── isTimeoutErr unit coverage ────────────────────────────────────────────────
149+
150+
// fakeNetTimeoutErrMsg is the message carried by fakeNetTimeoutErr; named so
151+
// tests don't scatter string literals.
152+
const fakeNetTimeoutErrMsg = "dial tcp: i/o timeout (synthetic)"
153+
154+
// fakeNetTimeoutErr is a minimal net.Error with a configurable Timeout().
155+
// It deliberately does NOT wrap context.DeadlineExceeded, so it exercises the
156+
// errors.As(net.Error) branch of isTimeoutErr rather than the errors.Is one
157+
// (an http.Client.Timeout error matches DeadlineExceeded first, leaving the
158+
// net.Error branch unreachable through provisionResource alone).
159+
type fakeNetTimeoutErr struct{ timeout bool }
160+
161+
func (e fakeNetTimeoutErr) Error() string { return fakeNetTimeoutErrMsg }
162+
func (e fakeNetTimeoutErr) Timeout() bool { return e.timeout }
163+
func (e fakeNetTimeoutErr) Temporary() bool { return e.timeout }
164+
165+
// TestIsTimeoutErr pins every branch of isTimeoutErr: the nil guard, the
166+
// context.DeadlineExceeded path, the net.Error-with-Timeout() path (including
167+
// when wrapped, as *url.Error does), and the non-timeout fallthrough.
168+
func TestIsTimeoutErr(t *testing.T) {
169+
// Compile-time proof the fake satisfies net.Error.
170+
var _ net.Error = fakeNetTimeoutErr{}
171+
172+
cases := []struct {
173+
name string
174+
err error
175+
want bool
176+
}{
177+
{"nil error is not a timeout", nil, false},
178+
{"context deadline exceeded", context.DeadlineExceeded, true},
179+
{"wrapped context deadline exceeded", fmt.Errorf("post: %w", context.DeadlineExceeded), true},
180+
{"net.Error with Timeout()==true", fakeNetTimeoutErr{timeout: true}, true},
181+
{"wrapped net.Error with Timeout()==true", fmt.Errorf("post: %w", fakeNetTimeoutErr{timeout: true}), true},
182+
{"net.Error with Timeout()==false", fakeNetTimeoutErr{timeout: false}, false},
183+
{"plain non-timeout error", errors.New("connection refused"), false},
184+
}
185+
for _, tc := range cases {
186+
t.Run(tc.name, func(t *testing.T) {
187+
assert.Equal(t, tc.want, isTimeoutErr(tc.err))
188+
})
189+
}
190+
}

cmd/root.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import (
99
"strings"
1010
"time"
1111

12-
"github.com/spf13/cobra"
1312
"github.com/InstaNode-dev/cli/internal/cliconfig"
1413
"github.com/InstaNode-dev/cli/internal/secretstore"
14+
"github.com/spf13/cobra"
1515
)
1616

1717
var _ = httpListTimeout // documented constant; referenced in tests / future refactor
@@ -64,6 +64,15 @@ const httpListTimeout = 10 * time.Second
6464
// CLI simplicity; <=0 falls back to the default).
6565
const httpProvisionTimeout = 60 * time.Second
6666

67+
// provisionTimeoutGuidance is appended to a provision error when the request
68+
// hit the client/context deadline. Provisioning is synchronous server-side, so
69+
// a timeout does NOT mean the resource was not created — it may have landed and
70+
// become an orphan the user can't see. Surface an actionable next step rather
71+
// than a bare "context deadline exceeded". Named const (not an inline literal)
72+
// so the message is greppable + asserted by the timeout regression test.
73+
const provisionTimeoutGuidance = "Request timed out, but the resource may still be provisioning — " +
74+
"run `instant resources` to check (and `instant resource delete <token> --yes` if it's an orphan)."
75+
6776
// HTTPClient is the shared HTTP client used by all subcommands.
6877
// It is configured with the auth transport during init.
6978
//
@@ -348,4 +357,3 @@ func initConfig() {
348357
},
349358
}
350359
}
351-

0 commit comments

Comments
 (0)