Skip to content

Commit eadcc21

Browse files
fix(cli): wave 3 — BugHunt 2026-05-20 T16 P2/P3 fixes
T16 P2-1 — parse the structured error envelope from the api instead of dumping the raw JSON body at the user. New apierror.go translates {error, error_code, message, agent_action, upgrade_url, retry_after_seconds} into a human-readable single-line error, with status-specific framing for 402 (tier wall), 429 (rate limited + retry hint), and 5xx (server error). Wired into provisionResource, discover/resources, fetchExistingResources, and provisionForUp — every code path that surfaced a raw body before. T16 P2-2 — HTTPClient timeout raised from 10s to 60s (configurable via INSTANT_TIMEOUT_SECONDS env var). Synchronous provisioning on the api can legitimately exceed 10s under load; the previous timeout aborted slow-but- successful requests AFTER the server created the resource, leaving an orphan the user was billed for. 60s matches the api's documented provisioning budget. T16 P2-3 — `up`'s env default changes from "production" to "development" to match the platform default (CLAUDE.md rule 11 / migration 026). New upDefaultEnv constant. The auth gate now applies to any non-default env: `--env=production` anonymously fails fast with ExitAuthRequired instead of silently shipping live. New env resolution order: --env > manifest.env > $INSTANT_ENV > "development". T16 P2-4 — webhook REUSE preserves --emit-env output. Previously the credentials endpoint returned 404 for webhooks (no connection_url) and the REUSE path printed "credentials hidden" + dropped the export line, breaking idempotent .env regeneration. Now the CLI reconstructs the webhook receive URL deterministically from APIBaseURL + token, matching the api's `receiveURL()` shape. T16 P2-5 — fetchCredentials parameter renamed id → token with a load- bearing doc comment pinning the contract: the api's /api/v1/resources/:id/credentials path parameter is the resource's TOKEN (UUID), not its database row id. Added mock idDifferentFromToken mode + a regression test that drives the CLI through PROVISION → REUSE when id and token are distinct values. T16 P3 — --json output mode added to `resources`, `status`, and `whoami`. Stable JSON schemas for agent consumption. The whoami JSON output NEVER includes the bearer token (T16 P1-1 still holds for the machine-readable surface) — only the truncated key_display + secret_backend name. Tests (cmd/bughunt_p2_test.go, 11 new regression tests, hermetic): TestBugHunt_T16_P2_1_402_TierWallSurfacesAgentAction TestBugHunt_T16_P2_1_429_RateLimitedShowsRetry TestBugHunt_T16_P2_1_5xxFallsBackToRawBodyOnNonJSON TestBugHunt_T16_P2_2_ProvisionTimeoutAtLeast60s TestBugHunt_T16_P2_3_UpDefaultsToDevelopmentNotProd TestBugHunt_T16_P2_3_UpExplicitProductionStillNeedsAuth TestBugHunt_T16_P2_4_WebhookReuseEmitsExportLine TestBugHunt_T16_P2_5_CredentialsKeyedByTokenNotID TestBugHunt_T16_P3_ResourcesJSONOutput TestBugHunt_T16_P3_StatusJSONOutput TestBugHunt_T16_P3_WhoamiJSONOutputNeverLeaksToken Existing test fixtures updated env: production → env: development to match the new platform default (the explicit auth-required-for-non-prod test still uses --env=staging). Build + vet + test (race) all green: 55 PASS, 0 FAIL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f3dbb06 commit eadcc21

10 files changed

Lines changed: 962 additions & 26 deletions

File tree

cmd/apierror.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package cmd
2+
3+
// apierror.go — parse the standardised API error envelope and surface its
4+
// fields cleanly to the user.
5+
//
6+
// T16 P2-1: every 4xx/5xx response from api.instanode.dev follows the W7G
7+
// envelope:
8+
//
9+
// {
10+
// "ok": false,
11+
// "error": "quota_exceeded", // machine-readable code
12+
// "error_code": "quota_exceeded", // alternate spelling (server may emit either)
13+
// "message": "You've hit your hobby-tier postgres limit (1 / 1).",
14+
// "agent_action": "Run `instant upgrade` to raise the limit, …",
15+
// "upgrade_url": "https://instanode.dev/billing",
16+
// "request_id": "req_abc123",
17+
// "retry_after_seconds": 30
18+
// }
19+
//
20+
// Before this fix the CLI dumped the entire raw JSON body to the user,
21+
// which is non-actionable noise (and may leak server-internal strings).
22+
// parseAPIError() turns the envelope into a stable, human-readable error
23+
// message that always carries the most useful field for the status code:
24+
//
25+
// 402 → message + agent_action + upgrade_url
26+
// 429 → "rate limited, retry in N seconds" + agent_action
27+
// 5xx → message + agent_action ("server error, retry later")
28+
// 4xx → message + agent_action
29+
// other → fall back to truncated raw body (defensive)
30+
//
31+
// The helper never returns nil — even if parsing fails (non-JSON body),
32+
// it returns a truncated raw-body error so the caller still gets *some*
33+
// message.
34+
35+
import (
36+
"encoding/json"
37+
"fmt"
38+
"strings"
39+
)
40+
41+
// apiErrorEnvelope mirrors the W7G error shape served by api.instanode.dev.
42+
// Optional fields are pointers/empty-zero so we can detect "field absent"
43+
// vs "field present but empty".
44+
type apiErrorEnvelope struct {
45+
OK bool `json:"ok"`
46+
Error string `json:"error"`
47+
ErrorCode string `json:"error_code"`
48+
Message string `json:"message"`
49+
AgentAction string `json:"agent_action"`
50+
UpgradeURL string `json:"upgrade_url"`
51+
Upgrade string `json:"upgrade"` // legacy shape
52+
RequestID string `json:"request_id"`
53+
RetryAfterSeconds int `json:"retry_after_seconds"`
54+
}
55+
56+
// code returns the most-specific error code the envelope carries.
57+
func (e *apiErrorEnvelope) code() string {
58+
if e.ErrorCode != "" {
59+
return e.ErrorCode
60+
}
61+
return e.Error
62+
}
63+
64+
// humanMessage returns the most descriptive single-line message available.
65+
// Preference: message > agent_action > error code > "".
66+
func (e *apiErrorEnvelope) humanMessage() string {
67+
if e.Message != "" {
68+
return e.Message
69+
}
70+
if e.AgentAction != "" {
71+
return e.AgentAction
72+
}
73+
return e.code()
74+
}
75+
76+
// parseAPIError takes an HTTP status code and a raw response body, and
77+
// produces a single error whose message is the cleanest representation
78+
// the envelope contents allow. The status is part of the message so an
79+
// agent script can grep on it; the body is only echoed (truncated) when
80+
// parsing fails entirely.
81+
//
82+
// Never returns nil.
83+
func parseAPIError(status int, raw []byte) error {
84+
body := strings.TrimSpace(string(raw))
85+
if body == "" {
86+
return fmt.Errorf("server returned %d (no body)", status)
87+
}
88+
89+
var env apiErrorEnvelope
90+
if err := json.Unmarshal(raw, &env); err != nil {
91+
// Body wasn't JSON, or wasn't the envelope shape. Fall back to the
92+
// raw body, truncated so we don't dump megabytes of HTML / stack
93+
// traces into the user's terminal.
94+
return fmt.Errorf("server returned %d: %s", status, truncate(body, 200))
95+
}
96+
97+
// If we got an envelope but every interesting field is empty, fall back
98+
// to the truncated raw body so we still surface *something*.
99+
if env.code() == "" && env.Message == "" && env.AgentAction == "" {
100+
return fmt.Errorf("server returned %d: %s", status, truncate(body, 200))
101+
}
102+
103+
// Build the human message. Status-specific framing first, then the
104+
// envelope's own message, then agent_action, then upgrade_url.
105+
var parts []string
106+
107+
switch {
108+
case status == 402:
109+
// Tier wall — the most actionable status.
110+
parts = append(parts, fmt.Sprintf("%d %s", status, codeOrDefault(env.code(), "tier limit reached")))
111+
case status == 429:
112+
// Rate limited. Include retry hint when the server provides one.
113+
if env.RetryAfterSeconds > 0 {
114+
parts = append(parts,
115+
fmt.Sprintf("%d rate limited (retry in %ds)",
116+
status, env.RetryAfterSeconds))
117+
} else {
118+
parts = append(parts, fmt.Sprintf("%d rate limited", status))
119+
}
120+
case status >= 500:
121+
// Transient server error — agent should retry.
122+
parts = append(parts, fmt.Sprintf("%d %s", status, codeOrDefault(env.code(), "server error, retry later")))
123+
default:
124+
parts = append(parts, fmt.Sprintf("%d %s", status, codeOrDefault(env.code(), "request rejected")))
125+
}
126+
127+
if env.Message != "" {
128+
parts = append(parts, env.Message)
129+
}
130+
if env.AgentAction != "" && env.AgentAction != env.Message {
131+
parts = append(parts, "→ "+env.AgentAction)
132+
}
133+
upgrade := env.UpgradeURL
134+
if upgrade == "" {
135+
upgrade = env.Upgrade
136+
}
137+
if upgrade != "" {
138+
parts = append(parts, "upgrade: "+upgrade)
139+
}
140+
if env.RequestID != "" {
141+
parts = append(parts, "(request_id="+env.RequestID+")")
142+
}
143+
144+
return fmt.Errorf("%s", strings.Join(parts, " — "))
145+
}
146+
147+
// codeOrDefault returns code when non-empty, otherwise the fallback.
148+
func codeOrDefault(code, fallback string) string {
149+
if code == "" {
150+
return fallback
151+
}
152+
return code
153+
}

cmd/bughunt_p1_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func p1_4_fixture(t *testing.T, listStatus int, expectedExitCode int) {
4848
c.mock.mu.Unlock()
4949

5050
manifest := writeManifest(t, `
51-
env: production
51+
env: development
5252
resources:
5353
- type: postgres
5454
name: dont-double-up
@@ -114,7 +114,7 @@ func TestBugHunt_T16_P1_4_UpAbortsOnListFetch_401_AuthenticatedSession(t *testin
114114
c.mock.mu.Unlock()
115115

116116
manifest := writeManifest(t, `
117-
env: production
117+
env: development
118118
resources:
119119
- type: postgres
120120
name: dont-double-up-401
@@ -164,7 +164,7 @@ func TestBugHunt_T16_P1_3_ExitCodes_UpResourceFailed(t *testing.T) {
164164
c.mock.mu.Unlock()
165165

166166
manifest := writeManifest(t, `
167-
env: production
167+
env: development
168168
resources:
169169
- type: postgres
170170
name: doomed
@@ -216,7 +216,7 @@ func TestBugHunt_T16_P1_5_EmitEnvShellQuotesHostileValues(t *testing.T) {
216216
c.mock.mu.Unlock()
217217

218218
manifest := writeManifest(t, `
219-
env: production
219+
env: development
220220
resources:
221221
- type: postgres
222222
name: hostile-url
@@ -260,7 +260,7 @@ resources:
260260
func TestBugHunt_T16_P1_5_EmitEnvSanitizesExportName(t *testing.T) {
261261
c := newITContext(t)
262262
manifest := writeManifest(t, `
263-
env: production
263+
env: development
264264
resources:
265265
- type: postgres
266266
name: My App DB
@@ -420,7 +420,7 @@ func TestBugHunt_T16_P1_2_UniformExitCodeFor401(t *testing.T) {
420420

421421
// (c) `up`
422422
manifest := writeManifest(t, `
423-
env: production
423+
env: development
424424
resources:
425425
- type: postgres
426426
name: stale-up

0 commit comments

Comments
 (0)