Skip to content

Commit fff3fe5

Browse files
fix(errors): full agent-native APIError envelope + Capabilities() + live prod integration test (#20)
* fix(errors): capture full agent-native error envelope + add Capabilities P0: APIError dropped most of the API's canonical error envelope. The api replies to every 4xx/5xx with {ok, error, error_code, message, agent_action, upgrade_url, retry_after_seconds, request_id}, but APIError captured only error + message — silently dropping agent_action, error_code, upgrade_url, retry_after_seconds, and request_id. And Code was tagged json:"error" so it held the category ("unauthorized"), not the canonical machine code ("missing_credentials" in error_code). This defeated the agent-native contract: an agent had no next-step (agent_action), no upgrade path (upgrade_url), no precise code to branch on, no request_id for support. Fix: - Add tagged fields ErrorCode, AgentAction, UpgradeURL, RetryAfterSeconds (*int, to distinguish "retry in 0s" from "do not retry"), and RequestID to APIError. Code/Message retained for back-compat. - Add APIError.CanonicalCode() — prefers ErrorCode, falls back to Code — so callers branch on the canonical machine code. - Fold agent_action + upgrade_url into Error() so logs are actionable; the legacy "(code): message" shape is unchanged when neither is present. - Exported APIErrorEnvelopeKeys registry + rule-18 registry tests (TestAPIError_EnvelopeKeysAllHaveAHome / …RegistryIsComplete / …FullEnvelopeRoundTrips / …FullEnvelopeOverHTTP) so a future envelope key can't silently drop. Existing APIError tests (DecodedFromBody, RawFallback, RawOnly, Error string) still pass — backward compatible. Add Client.Capabilities(ctx) → GET /api/v1/capabilities, returning the typed tier matrix (*Capabilities / []TierCapabilities) plus a Raw map escape hatch for forward compatibility. Six provider doc comments referenced this endpoint with no method to call it; this is the first. Public/unauthenticated — works in anonymous mode. Covered by capabilities_test.go. Gate: go build ./... && go test ./... -short — green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(integration): live prod envelope + Capabilities check, wired into CI Add a build-tagged (//go:build integration) live integration test that hits PROD https://api.instanode.dev and proves the agent-native error envelope fix against the real API: - unauthenticated GET /api/v1/resources → assert APIError carries agent_action + error_code (CanonicalCode) + upgrade_url - Capabilities() → assert the public tier matrix decodes and returns tiers Both are read-only / side-effect-free and skip cleanly when prod is unreachable from the runner. Wired into ci.yml as a new 'integration' job (go test -tags integration ... -run TestProdIntegration). Verified passing against prod: status=401 missing_credentials, agent_action + upgrade_url populated; 7 tiers returned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(typos): allowlist RTO/RPO domain abbreviations in capabilities typos v1.47 flags RTO/rto (Recovery Time Objective) as 'should be TO'. These are real durability-SLO terms used by the Capabilities() API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(sdk): 100% patch coverage on Capabilities; drop dead marshal branch diff-cover flagged capabilities.go:120-125 (re-encode + decode error paths). Re-encoding a decoded map can't fail, so decode the raw bytes directly into both the typed struct and Raw — removing the dead branch — and add a decode-error test for the type-mismatch path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9d6038c commit fff3fe5

8 files changed

Lines changed: 865 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,21 @@ jobs:
4141
echo "::error ::SDKVersion=$got does not match tag $tag (expected $expected). Bump instant/version.go before tagging."
4242
exit 1
4343
fi
44+
45+
# Live integration tests against PROD instanode.dev. The `integration` build
46+
# tag gates these tests (read-only: an unauthenticated list to verify the
47+
# agent-native error envelope, plus the public capabilities matrix). They skip
48+
# cleanly if prod is unreachable from the runner, so this job is informative on
49+
# an isolated runner but asserts the live envelope contract when reachable.
50+
integration:
51+
runs-on: ubuntu-latest
52+
steps:
53+
- uses: actions/checkout@v6
54+
55+
- uses: actions/setup-go@v6
56+
with:
57+
go-version: '1.25'
58+
59+
- run: go build -tags integration ./...
60+
- name: Run live integration tests against prod
61+
run: go test -tags integration ./... -v -count=1 -run TestProdIntegration

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,39 @@ existing callers.
2626
(`TestDeploymentStatusDocMatchesAPIContract`) parses the field's doc
2727
comment from the AST and fails if either ghost status reappears or a
2828
contract status is dropped. No wire/behavior change.
29+
- **P0: `APIError` no longer drops the agent-native error envelope.** The api
30+
replies to every 4xx/5xx with
31+
`{ok, error, error_code, message, agent_action, upgrade_url,
32+
retry_after_seconds, request_id}`, but `APIError` captured only `error` +
33+
`message` — silently dropping `agent_action`, `error_code`, `upgrade_url`,
34+
`retry_after_seconds`, and `request_id`. Worse, `Code` was tagged
35+
`json:"error"` so it held the *category* (`"unauthorized"`) rather than the
36+
canonical machine code (`"missing_credentials"` in `error_code`). All five
37+
dropped fields now have tagged homes: `ErrorCode`, `AgentAction`,
38+
`UpgradeURL`, `RetryAfterSeconds *int`, `RequestID`. `Code`/`Message` are
39+
retained for back-compat. New `APIError.CanonicalCode()` returns the
40+
finer-grained `ErrorCode`, falling back to `Code`. `Error()` now folds
41+
`agent_action` + `upgrade_url` into the string so logs are actionable; the
42+
legacy `(code): message` shape is unchanged when neither is present. A
43+
registry test (`TestAPIError_EnvelopeKeysAllHaveAHome` +
44+
`…RegistryIsComplete`) asserts every envelope key the API can emit has a
45+
tagged field and round-trips, so a future field can't silently drop.
2946

3047
### Added
3148

49+
- **`Client.Capabilities(ctx)`**`GET /api/v1/capabilities`. Returns the full
50+
tier matrix (`*Capabilities` with typed `[]TierCapabilities`: storage,
51+
connection, resource-count, and deployment caps per tier, plus durability
52+
and pricing) so an agent can discover "what can I do at which tier" without
53+
provisioning-and-failing. Public/unauthenticated — works in anonymous mode.
54+
The complete decoded JSON is also preserved on `Capabilities.Raw` for
55+
forward compatibility. Six provider doc comments already referenced this
56+
endpoint; this is the first method that calls it.
57+
- **`APIError.ErrorCode` / `.AgentAction` / `.UpgradeURL` /
58+
`.RetryAfterSeconds` / `.RequestID`** — the previously-dropped error-envelope
59+
fields (see Fixed above), plus `APIError.CanonicalCode()` and the exported
60+
`APIErrorEnvelopeKeys` registry.
61+
3262
- **`ClaimResult.SessionToken`** (`string`, `json:"session_token,omitempty"`).
3363
Populated when the api mints a session JWT for the newly created team on
3464
`POST /claim`. Callers can use it as the Bearer token for follow-up

_typos.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Domain abbreviations the typos spell-checker mis-flags as misspellings.
2+
# RTO = Recovery Time Objective, RPO = Recovery Point Objective (durability SLOs).
3+
[default.extend-words]
4+
rto = "rto"
5+
rpo = "rpo"

instant/apierror_envelope_test.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package instant
2+
3+
// apierror_envelope_test.go — registry contract for the canonical
4+
// instanode.dev error envelope (rule 18 style: iterate the registry, not a
5+
// hand-typed list). The API replies to every 4xx/5xx with
6+
//
7+
// {ok, error, error_code, message, agent_action, upgrade_url,
8+
// retry_after_seconds, request_id}
9+
//
10+
// and APIError must give every one of those keys a home — the original
11+
// struct captured only error+message, silently dropping agent_action,
12+
// error_code, upgrade_url, retry_after_seconds, and request_id, which
13+
// defeated the agent-native contract. These tests fail if a future envelope
14+
// key is added to APIErrorEnvelopeKeys without a tagged field, OR if a sample
15+
// full body fails to round-trip every field onto the struct.
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"io"
21+
"net/http"
22+
"net/http/httptest"
23+
"reflect"
24+
"strings"
25+
"testing"
26+
)
27+
28+
// jsonTagsOfAPIError reflects the JSON tag set declared on APIError, skipping
29+
// untagged fields (StatusCode), the unexported raw field, and json:"-".
30+
func jsonTagsOfAPIError(t *testing.T) map[string]string {
31+
t.Helper()
32+
tags := map[string]string{}
33+
rt := reflect.TypeOf(APIError{})
34+
for i := 0; i < rt.NumField(); i++ {
35+
f := rt.Field(i)
36+
tag := f.Tag.Get("json")
37+
if tag == "" || tag == "-" {
38+
continue
39+
}
40+
name := strings.Split(tag, ",")[0]
41+
if name == "" {
42+
continue
43+
}
44+
tags[name] = f.Name
45+
}
46+
return tags
47+
}
48+
49+
// TestAPIError_EnvelopeKeysAllHaveAHome asserts every envelope key the SDK
50+
// claims to map (APIErrorEnvelopeKeys) is backed by a tagged field on
51+
// APIError. A future API field added to the registry without a struct field
52+
// reds this test instead of silently dropping at runtime.
53+
func TestAPIError_EnvelopeKeysAllHaveAHome(t *testing.T) {
54+
tags := jsonTagsOfAPIError(t)
55+
for _, key := range APIErrorEnvelopeKeys {
56+
if _, ok := tags[key]; !ok {
57+
t.Errorf("envelope key %q in APIErrorEnvelopeKeys has no tagged field on APIError "+
58+
"(add the field with `json:%q` so it can't drop)", key, key)
59+
}
60+
}
61+
}
62+
63+
// TestAPIError_EnvelopeKeysRegistryIsComplete guards the other direction:
64+
// every documented envelope key the API can emit (the canonical contract
65+
// list) must appear in APIErrorEnvelopeKeys. This is the registry-iterating
66+
// guard from rule 18 — if the API adds a key here-but-not-in-the-SDK the test
67+
// names exactly which one is missing.
68+
func TestAPIError_EnvelopeKeysRegistryIsComplete(t *testing.T) {
69+
// The canonical key set the api emits on its ErrorResponse envelope
70+
// (api/internal/handlers/helpers.go: ErrorResponse). claim_url is the
71+
// recycle-gate-only alias the SDK folds into upgrade_url; ok is the
72+
// success/failure flag the SDK reads via the typed predicates, not via
73+
// APIError — both are intentionally excluded and listed here so the
74+
// exclusion is explicit rather than an oversight.
75+
canonical := []string{
76+
"error",
77+
"error_code",
78+
"message",
79+
"agent_action",
80+
"upgrade_url",
81+
"retry_after_seconds",
82+
"request_id",
83+
}
84+
have := map[string]bool{}
85+
for _, k := range APIErrorEnvelopeKeys {
86+
have[k] = true
87+
}
88+
for _, k := range canonical {
89+
if !have[k] {
90+
t.Errorf("canonical envelope key %q is NOT in APIErrorEnvelopeKeys — "+
91+
"the SDK will drop it; add it to the registry and APIError", k)
92+
}
93+
}
94+
}
95+
96+
// TestAPIError_FullEnvelopeRoundTrips decodes a sample of the complete error
97+
// envelope and asserts EVERY field lands on the struct — the regression the
98+
// fix closes (pre-fix, only Code+Message survived). It also asserts the tag
99+
// mapping: Code holds the category "error", ErrorCode holds the canonical
100+
// "error_code", and CanonicalCode prefers error_code.
101+
func TestAPIError_FullEnvelopeRoundTrips(t *testing.T) {
102+
const retry = 30
103+
body := `{
104+
"ok": false,
105+
"error": "unauthorized",
106+
"error_code": "missing_credentials",
107+
"message": "No INSTANODE_TOKEN was provided.",
108+
"agent_action": "Have the user log in at https://instanode.dev/login.",
109+
"upgrade_url": "https://instanode.dev/login",
110+
"retry_after_seconds": 30,
111+
"request_id": "req_abc123"
112+
}`
113+
114+
var e APIError
115+
if err := json.Unmarshal([]byte(body), &e); err != nil {
116+
t.Fatalf("unmarshal: %v", err)
117+
}
118+
119+
if e.Code != "unauthorized" {
120+
t.Errorf("Code (json:\"error\", the category) = %q, want %q", e.Code, "unauthorized")
121+
}
122+
if e.ErrorCode != "missing_credentials" {
123+
t.Errorf("ErrorCode (json:\"error_code\") = %q, want %q", e.ErrorCode, "missing_credentials")
124+
}
125+
if e.Message != "No INSTANODE_TOKEN was provided." {
126+
t.Errorf("Message = %q", e.Message)
127+
}
128+
if !strings.Contains(e.AgentAction, "log in") {
129+
t.Errorf("AgentAction not captured: %q", e.AgentAction)
130+
}
131+
if e.UpgradeURL != "https://instanode.dev/login" {
132+
t.Errorf("UpgradeURL = %q", e.UpgradeURL)
133+
}
134+
if e.RetryAfterSeconds == nil {
135+
t.Fatal("RetryAfterSeconds = nil, want non-nil 30")
136+
}
137+
if *e.RetryAfterSeconds != retry {
138+
t.Errorf("RetryAfterSeconds = %d, want %d", *e.RetryAfterSeconds, retry)
139+
}
140+
if e.RequestID != "req_abc123" {
141+
t.Errorf("RequestID = %q", e.RequestID)
142+
}
143+
144+
// CanonicalCode prefers the finer-grained error_code over the category.
145+
if got := e.CanonicalCode(); got != "missing_credentials" {
146+
t.Errorf("CanonicalCode() = %q, want the canonical machine code %q", got, "missing_credentials")
147+
}
148+
}
149+
150+
// TestAPIError_CanonicalCodeFallback — when the server omits error_code the
151+
// canonical code falls back to the category (Code / json:"error").
152+
func TestAPIError_CanonicalCodeFallback(t *testing.T) {
153+
e := &APIError{Code: "not_found"}
154+
if got := e.CanonicalCode(); got != "not_found" {
155+
t.Errorf("CanonicalCode() with empty ErrorCode = %q, want fallback to Code %q", got, "not_found")
156+
}
157+
e2 := &APIError{Code: "unauthorized", ErrorCode: "missing_credentials"}
158+
if got := e2.CanonicalCode(); got != "missing_credentials" {
159+
t.Errorf("CanonicalCode() = %q, want %q", got, "missing_credentials")
160+
}
161+
}
162+
163+
// TestAPIError_ErrorStringFoldsAgentActionAndUpgrade — the Error() string must
164+
// surface agent_action + upgrade_url so logs are actionable, while staying
165+
// backward-compatible (no trailing " | ..." when neither is present).
166+
func TestAPIError_ErrorStringFoldsAgentActionAndUpgrade(t *testing.T) {
167+
full := &APIError{
168+
StatusCode: 402,
169+
Code: "quota_exceeded",
170+
ErrorCode: "storage_limit_reached",
171+
Message: "storage limit reached",
172+
AgentAction: "Upgrade to Pro at https://instanode.dev/pricing.",
173+
UpgradeURL: "https://instanode.dev/pricing",
174+
}
175+
s := full.Error()
176+
// canonical code wins over category in the prefix
177+
if !strings.Contains(s, "(storage_limit_reached)") {
178+
t.Errorf("Error() should use canonical code: %q", s)
179+
}
180+
if !strings.Contains(s, "agent_action: Upgrade to Pro") {
181+
t.Errorf("Error() must fold in agent_action: %q", s)
182+
}
183+
if !strings.Contains(s, "upgrade_url: https://instanode.dev/pricing") {
184+
t.Errorf("Error() must fold in upgrade_url: %q", s)
185+
}
186+
187+
// Backward compat: with neither agent_action nor upgrade_url, the string
188+
// is exactly the legacy shape (no trailing pipes).
189+
plain := &APIError{StatusCode: 404, Code: "not_found", Message: "Resource not found"}
190+
if got, want := plain.Error(), "instant.dev API error 404 (not_found): Resource not found"; got != want {
191+
t.Errorf("Error() backward-compat = %q, want %q", got, want)
192+
}
193+
}
194+
195+
// TestAPIError_FullEnvelopeOverHTTP wires the full envelope through the real
196+
// client error path (do → json.Unmarshal(raw, apiErr)) to prove the wire
197+
// decode — not just a direct unmarshal — populates every field.
198+
func TestAPIError_FullEnvelopeOverHTTP(t *testing.T) {
199+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200+
w.WriteHeader(http.StatusTooManyRequests)
201+
_, _ = io.WriteString(w, `{"ok":false,"error":"rate_limited","error_code":"too_many_requests",`+
202+
`"message":"slow down","agent_action":"Wait 60 seconds and retry.",`+
203+
`"upgrade_url":"https://instanode.dev/pricing","retry_after_seconds":60,"request_id":"req_z9"}`)
204+
}))
205+
defer srv.Close()
206+
207+
c := New(WithBaseURL(srv.URL))
208+
var out map[string]any
209+
err := c.get(context.Background(), "/x", &out)
210+
if err == nil {
211+
t.Fatal("expected error")
212+
}
213+
apiErr, ok := err.(*APIError)
214+
if !ok {
215+
t.Fatalf("expected *APIError, got %T", err)
216+
}
217+
if apiErr.StatusCode != http.StatusTooManyRequests {
218+
t.Errorf("StatusCode = %d", apiErr.StatusCode)
219+
}
220+
if apiErr.ErrorCode != "too_many_requests" {
221+
t.Errorf("ErrorCode = %q (dropped on the wire path?)", apiErr.ErrorCode)
222+
}
223+
if apiErr.AgentAction == "" || apiErr.UpgradeURL == "" || apiErr.RequestID == "" {
224+
t.Errorf("agent-native fields dropped on the wire path: action=%q upgrade=%q rid=%q",
225+
apiErr.AgentAction, apiErr.UpgradeURL, apiErr.RequestID)
226+
}
227+
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 60 {
228+
t.Errorf("RetryAfterSeconds not captured: %v", apiErr.RetryAfterSeconds)
229+
}
230+
if !IsRateLimited(err) {
231+
t.Error("IsRateLimited should match the 429")
232+
}
233+
}

0 commit comments

Comments
 (0)