diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15758b1..b09e2fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,21 @@ jobs: echo "::error ::SDKVersion=$got does not match tag $tag (expected $expected). Bump instant/version.go before tagging." exit 1 fi + + # Live integration tests against PROD instanode.dev. The `integration` build + # tag gates these tests (read-only: an unauthenticated list to verify the + # agent-native error envelope, plus the public capabilities matrix). They skip + # cleanly if prod is unreachable from the runner, so this job is informative on + # an isolated runner but asserts the live envelope contract when reachable. + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - run: go build -tags integration ./... + - name: Run live integration tests against prod + run: go test -tags integration ./... -v -count=1 -run TestProdIntegration diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f00f9..aa4bde8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,39 @@ existing callers. (`TestDeploymentStatusDocMatchesAPIContract`) parses the field's doc comment from the AST and fails if either ghost status reappears or a contract status is dropped. No wire/behavior change. +- **P0: `APIError` no longer drops the agent-native 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`. Worse, `Code` was tagged + `json:"error"` so it held the *category* (`"unauthorized"`) rather than the + canonical machine code (`"missing_credentials"` in `error_code`). All five + dropped fields now have tagged homes: `ErrorCode`, `AgentAction`, + `UpgradeURL`, `RetryAfterSeconds *int`, `RequestID`. `Code`/`Message` are + retained for back-compat. New `APIError.CanonicalCode()` returns the + finer-grained `ErrorCode`, falling back to `Code`. `Error()` now folds + `agent_action` + `upgrade_url` into the string so logs are actionable; the + legacy `(code): message` shape is unchanged when neither is present. A + registry test (`TestAPIError_EnvelopeKeysAllHaveAHome` + + `…RegistryIsComplete`) asserts every envelope key the API can emit has a + tagged field and round-trips, so a future field can't silently drop. ### Added +- **`Client.Capabilities(ctx)`** → `GET /api/v1/capabilities`. Returns the full + tier matrix (`*Capabilities` with typed `[]TierCapabilities`: storage, + connection, resource-count, and deployment caps per tier, plus durability + and pricing) so an agent can discover "what can I do at which tier" without + provisioning-and-failing. Public/unauthenticated — works in anonymous mode. + The complete decoded JSON is also preserved on `Capabilities.Raw` for + forward compatibility. Six provider doc comments already referenced this + endpoint; this is the first method that calls it. +- **`APIError.ErrorCode` / `.AgentAction` / `.UpgradeURL` / + `.RetryAfterSeconds` / `.RequestID`** — the previously-dropped error-envelope + fields (see Fixed above), plus `APIError.CanonicalCode()` and the exported + `APIErrorEnvelopeKeys` registry. + - **`ClaimResult.SessionToken`** (`string`, `json:"session_token,omitempty"`). Populated when the api mints a session JWT for the newly created team on `POST /claim`. Callers can use it as the Bearer token for follow-up diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..6d648d0 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,5 @@ +# Domain abbreviations the typos spell-checker mis-flags as misspellings. +# RTO = Recovery Time Objective, RPO = Recovery Point Objective (durability SLOs). +[default.extend-words] +rto = "rto" +rpo = "rpo" diff --git a/instant/apierror_envelope_test.go b/instant/apierror_envelope_test.go new file mode 100644 index 0000000..835fa80 --- /dev/null +++ b/instant/apierror_envelope_test.go @@ -0,0 +1,233 @@ +package instant + +// apierror_envelope_test.go — registry contract for the canonical +// instanode.dev error envelope (rule 18 style: iterate the registry, not a +// hand-typed list). The API replies to every 4xx/5xx with +// +// {ok, error, error_code, message, agent_action, upgrade_url, +// retry_after_seconds, request_id} +// +// and APIError must give every one of those keys a home — the original +// struct captured only error+message, silently dropping agent_action, +// error_code, upgrade_url, retry_after_seconds, and request_id, which +// defeated the agent-native contract. These tests fail if a future envelope +// key is added to APIErrorEnvelopeKeys without a tagged field, OR if a sample +// full body fails to round-trip every field onto the struct. + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +// jsonTagsOfAPIError reflects the JSON tag set declared on APIError, skipping +// untagged fields (StatusCode), the unexported raw field, and json:"-". +func jsonTagsOfAPIError(t *testing.T) map[string]string { + t.Helper() + tags := map[string]string{} + rt := reflect.TypeOf(APIError{}) + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + tag := f.Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.Split(tag, ",")[0] + if name == "" { + continue + } + tags[name] = f.Name + } + return tags +} + +// TestAPIError_EnvelopeKeysAllHaveAHome asserts every envelope key the SDK +// claims to map (APIErrorEnvelopeKeys) is backed by a tagged field on +// APIError. A future API field added to the registry without a struct field +// reds this test instead of silently dropping at runtime. +func TestAPIError_EnvelopeKeysAllHaveAHome(t *testing.T) { + tags := jsonTagsOfAPIError(t) + for _, key := range APIErrorEnvelopeKeys { + if _, ok := tags[key]; !ok { + t.Errorf("envelope key %q in APIErrorEnvelopeKeys has no tagged field on APIError "+ + "(add the field with `json:%q` so it can't drop)", key, key) + } + } +} + +// TestAPIError_EnvelopeKeysRegistryIsComplete guards the other direction: +// every documented envelope key the API can emit (the canonical contract +// list) must appear in APIErrorEnvelopeKeys. This is the registry-iterating +// guard from rule 18 — if the API adds a key here-but-not-in-the-SDK the test +// names exactly which one is missing. +func TestAPIError_EnvelopeKeysRegistryIsComplete(t *testing.T) { + // The canonical key set the api emits on its ErrorResponse envelope + // (api/internal/handlers/helpers.go: ErrorResponse). claim_url is the + // recycle-gate-only alias the SDK folds into upgrade_url; ok is the + // success/failure flag the SDK reads via the typed predicates, not via + // APIError — both are intentionally excluded and listed here so the + // exclusion is explicit rather than an oversight. + canonical := []string{ + "error", + "error_code", + "message", + "agent_action", + "upgrade_url", + "retry_after_seconds", + "request_id", + } + have := map[string]bool{} + for _, k := range APIErrorEnvelopeKeys { + have[k] = true + } + for _, k := range canonical { + if !have[k] { + t.Errorf("canonical envelope key %q is NOT in APIErrorEnvelopeKeys — "+ + "the SDK will drop it; add it to the registry and APIError", k) + } + } +} + +// TestAPIError_FullEnvelopeRoundTrips decodes a sample of the complete error +// envelope and asserts EVERY field lands on the struct — the regression the +// fix closes (pre-fix, only Code+Message survived). It also asserts the tag +// mapping: Code holds the category "error", ErrorCode holds the canonical +// "error_code", and CanonicalCode prefers error_code. +func TestAPIError_FullEnvelopeRoundTrips(t *testing.T) { + const retry = 30 + body := `{ + "ok": false, + "error": "unauthorized", + "error_code": "missing_credentials", + "message": "No INSTANODE_TOKEN was provided.", + "agent_action": "Have the user log in at https://instanode.dev/login.", + "upgrade_url": "https://instanode.dev/login", + "retry_after_seconds": 30, + "request_id": "req_abc123" + }` + + var e APIError + if err := json.Unmarshal([]byte(body), &e); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if e.Code != "unauthorized" { + t.Errorf("Code (json:\"error\", the category) = %q, want %q", e.Code, "unauthorized") + } + if e.ErrorCode != "missing_credentials" { + t.Errorf("ErrorCode (json:\"error_code\") = %q, want %q", e.ErrorCode, "missing_credentials") + } + if e.Message != "No INSTANODE_TOKEN was provided." { + t.Errorf("Message = %q", e.Message) + } + if !strings.Contains(e.AgentAction, "log in") { + t.Errorf("AgentAction not captured: %q", e.AgentAction) + } + if e.UpgradeURL != "https://instanode.dev/login" { + t.Errorf("UpgradeURL = %q", e.UpgradeURL) + } + if e.RetryAfterSeconds == nil { + t.Fatal("RetryAfterSeconds = nil, want non-nil 30") + } + if *e.RetryAfterSeconds != retry { + t.Errorf("RetryAfterSeconds = %d, want %d", *e.RetryAfterSeconds, retry) + } + if e.RequestID != "req_abc123" { + t.Errorf("RequestID = %q", e.RequestID) + } + + // CanonicalCode prefers the finer-grained error_code over the category. + if got := e.CanonicalCode(); got != "missing_credentials" { + t.Errorf("CanonicalCode() = %q, want the canonical machine code %q", got, "missing_credentials") + } +} + +// TestAPIError_CanonicalCodeFallback — when the server omits error_code the +// canonical code falls back to the category (Code / json:"error"). +func TestAPIError_CanonicalCodeFallback(t *testing.T) { + e := &APIError{Code: "not_found"} + if got := e.CanonicalCode(); got != "not_found" { + t.Errorf("CanonicalCode() with empty ErrorCode = %q, want fallback to Code %q", got, "not_found") + } + e2 := &APIError{Code: "unauthorized", ErrorCode: "missing_credentials"} + if got := e2.CanonicalCode(); got != "missing_credentials" { + t.Errorf("CanonicalCode() = %q, want %q", got, "missing_credentials") + } +} + +// TestAPIError_ErrorStringFoldsAgentActionAndUpgrade — the Error() string must +// surface agent_action + upgrade_url so logs are actionable, while staying +// backward-compatible (no trailing " | ..." when neither is present). +func TestAPIError_ErrorStringFoldsAgentActionAndUpgrade(t *testing.T) { + full := &APIError{ + StatusCode: 402, + Code: "quota_exceeded", + ErrorCode: "storage_limit_reached", + Message: "storage limit reached", + AgentAction: "Upgrade to Pro at https://instanode.dev/pricing.", + UpgradeURL: "https://instanode.dev/pricing", + } + s := full.Error() + // canonical code wins over category in the prefix + if !strings.Contains(s, "(storage_limit_reached)") { + t.Errorf("Error() should use canonical code: %q", s) + } + if !strings.Contains(s, "agent_action: Upgrade to Pro") { + t.Errorf("Error() must fold in agent_action: %q", s) + } + if !strings.Contains(s, "upgrade_url: https://instanode.dev/pricing") { + t.Errorf("Error() must fold in upgrade_url: %q", s) + } + + // Backward compat: with neither agent_action nor upgrade_url, the string + // is exactly the legacy shape (no trailing pipes). + plain := &APIError{StatusCode: 404, Code: "not_found", Message: "Resource not found"} + if got, want := plain.Error(), "instant.dev API error 404 (not_found): Resource not found"; got != want { + t.Errorf("Error() backward-compat = %q, want %q", got, want) + } +} + +// TestAPIError_FullEnvelopeOverHTTP wires the full envelope through the real +// client error path (do → json.Unmarshal(raw, apiErr)) to prove the wire +// decode — not just a direct unmarshal — populates every field. +func TestAPIError_FullEnvelopeOverHTTP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"ok":false,"error":"rate_limited","error_code":"too_many_requests",`+ + `"message":"slow down","agent_action":"Wait 60 seconds and retry.",`+ + `"upgrade_url":"https://instanode.dev/pricing","retry_after_seconds":60,"request_id":"req_z9"}`) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) + var out map[string]any + err := c.get(context.Background(), "/x", &out) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != http.StatusTooManyRequests { + t.Errorf("StatusCode = %d", apiErr.StatusCode) + } + if apiErr.ErrorCode != "too_many_requests" { + t.Errorf("ErrorCode = %q (dropped on the wire path?)", apiErr.ErrorCode) + } + if apiErr.AgentAction == "" || apiErr.UpgradeURL == "" || apiErr.RequestID == "" { + t.Errorf("agent-native fields dropped on the wire path: action=%q upgrade=%q rid=%q", + apiErr.AgentAction, apiErr.UpgradeURL, apiErr.RequestID) + } + if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 60 { + t.Errorf("RetryAfterSeconds not captured: %v", apiErr.RetryAfterSeconds) + } + if !IsRateLimited(err) { + t.Error("IsRateLimited should match the 429") + } +} diff --git a/instant/capabilities.go b/instant/capabilities.go new file mode 100644 index 0000000..c89142f --- /dev/null +++ b/instant/capabilities.go @@ -0,0 +1,127 @@ +package instant + +import ( + "context" + "encoding/json" + "fmt" +) + +// capabilitiesPath is the public, unauthenticated tier-matrix endpoint. +// GET /api/v1/capabilities lets an agent discover "what can I do at which +// tier" without provisioning-and-failing or scraping llms.txt. +const capabilitiesPath = "/api/v1/capabilities" + +// Capabilities is the typed result of GET /api/v1/capabilities — the full +// tier matrix plus the docs + support pointers. The shape is contract-stable; +// see [Client.Capabilities]. +type Capabilities struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Tiers is the plan matrix in upgrade order (anonymous → team). + Tiers []TierCapabilities `json:"tiers"` + + // Docs is the LLM-targeted docs URL returned in the envelope. + Docs string `json:"docs"` + + // Contact is the support/enterprise contact link (a mailto:). + Contact string `json:"contact"` + + // Raw holds the full decoded JSON so callers can read fields newer than + // this SDK version without an upgrade. The typed fields above are the + // stable surface; Raw is the escape hatch for forward compatibility. + Raw map[string]any `json:"-"` +} + +// TierCapabilities describes a single plan tier in the [Capabilities] matrix. +// Unknown/future fields the API adds are preserved on [Capabilities.Raw]; the +// typed fields here are the stable, documented surface. +type TierCapabilities struct { + // Tier is the machine name (e.g. "anonymous", "hobby", "pro", "team"). + Tier string `json:"tier"` + + // DisplayName is the human-readable tier label. + DisplayName string `json:"display_name"` + + // PriceUSDMonthly is the monthly price in whole US dollars (0 for free tiers). + PriceUSDMonthly int `json:"price_usd_monthly"` + + // PaidFromDayOne is true for any tier that bills from signup (no trial). + PaidFromDayOne bool `json:"paid_from_day_one"` + + // StorageLimitMB maps a service ("postgres", "redis", "mongodb", "queue", + // "storage", "webhook", "vector") to its per-resource storage cap in MB. + StorageLimitMB map[string]int `json:"storage_limit_mb"` + + // ConnectionsLimit maps a service to its per-resource connection cap. + ConnectionsLimit map[string]int `json:"connections_limit"` + + // ResourceCountLimit maps a service to the max number of active resources + // a team may hold. -1 means unlimited. + ResourceCountLimit map[string]int `json:"resource_count_limit"` + + // Deployments is the per-tier deployments_apps cap. + Deployments int `json:"deployments_apps"` + + // BackupRetentionDays is how long automated backups are retained (0 = none). + BackupRetentionDays int `json:"backup_retention_days"` + + // BackupRestoreEnabled reports whether self-serve restore is offered. + BackupRestoreEnabled bool `json:"backup_restore_enabled"` + + // ManualBackupsPerDay is the per-day manual-backup quota. + ManualBackupsPerDay int `json:"manual_backups_per_day"` + + // RPOMinutes / RTOMinutes are the recovery objectives (0 = not promised). + RPOMinutes int `json:"rpo_minutes"` + RTOMinutes int `json:"rto_minutes"` + + // AnnualDiscountPercent is the discount of the yearly variant vs 12× monthly. + AnnualDiscountPercent int `json:"annual_discount_percent"` + + // UpgradeURL is where to upgrade to a higher tier. nil on the terminal + // (top) tier — there is nothing to upgrade to. Pairs with IsTerminalTier. + UpgradeURL *string `json:"upgrade_url"` + + // IsTerminalTier is true for the top tier (UpgradeURL is nil when true). + IsTerminalTier bool `json:"is_terminal_tier"` +} + +// Capabilities fetches the full tier matrix from GET /api/v1/capabilities. +// +// The endpoint is public and unauthenticated, so this works in anonymous mode +// (no API key required). Use it to discover tier limits — storage, connection, +// resource-count, and deployment caps per plan — before provisioning, so an +// agent can pick the right tier or warn the user about a limit up front instead +// of provisioning-and-failing. +// +// Forward compatibility: the typed [Capabilities] / [TierCapabilities] fields +// are the stable surface, and the complete decoded JSON is also preserved on +// [Capabilities.Raw] so callers can read fields newer than this SDK release. +// +// Example: +// +// caps, err := client.Capabilities(ctx) +// if err != nil { log.Fatal(err) } +// for _, t := range caps.Tiers { +// fmt.Printf("%s: postgres %dMB, %d deploys\n", +// t.Tier, t.StorageLimitMB["postgres"], t.Deployments) +// } +func (c *Client) Capabilities(ctx context.Context) (*Capabilities, error) { + // One HTTP round-trip: capture the raw JSON, decode it into the typed + // struct, and keep the complete forward-compatible JSON on Raw so callers + // can read fields newer than this SDK release. We decode the raw bytes + // directly into both targets — re-encoding an already-decoded map can never + // fail, so this avoids a dead, untestable error branch. + var rawMsg json.RawMessage + if err := c.get(ctx, capabilitiesPath, &rawMsg); err != nil { + return nil, fmt.Errorf("Capabilities: %w", err) + } + var out Capabilities + if err := json.Unmarshal(rawMsg, &out); err != nil { + return nil, fmt.Errorf("Capabilities: decoding response: %w", err) + } + out.Raw = map[string]any{} + _ = json.Unmarshal(rawMsg, &out.Raw) //nolint:errcheck // best-effort; the typed decode above already validated the body + return &out, nil +} diff --git a/instant/capabilities_test.go b/instant/capabilities_test.go new file mode 100644 index 0000000..d936396 --- /dev/null +++ b/instant/capabilities_test.go @@ -0,0 +1,194 @@ +package instant + +// capabilities_test.go — exercises Client.Capabilities against a httptest +// server serving a realistic two-tier matrix matching the api +// /api/v1/capabilities envelope shape (api/internal/handlers/capabilities.go). + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// capabilitiesBody is a trimmed but shape-accurate sample of the live +// /api/v1/capabilities response: a free terminal-free tier and a paid tier, +// plus the docs/contact pointers and a forward-compat field +// ("future_unknown_field") that must survive on Raw without breaking decode. +const capabilitiesBody = `{ + "ok": true, + "docs": "https://instanode.dev/llms-full.txt", + "contact": "mailto:enterprise@instanode.dev", + "future_unknown_field": "tolerated", + "tiers": [ + { + "tier": "anonymous", + "display_name": "Anonymous", + "price_usd_monthly": 0, + "paid_from_day_one": false, + "storage_limit_mb": {"postgres": 10, "redis": 5}, + "connections_limit": {"postgres": 2}, + "resource_count_limit": {"postgres": 2}, + "deployments_apps": 0, + "backup_retention_days": 0, + "backup_restore_enabled": false, + "manual_backups_per_day": 0, + "rpo_minutes": 0, + "rto_minutes": 0, + "annual_discount_percent": 0, + "upgrade_url": "https://instanode.dev/pricing/", + "is_terminal_tier": false + }, + { + "tier": "team", + "display_name": "Team", + "price_usd_monthly": 199, + "paid_from_day_one": true, + "storage_limit_mb": {"postgres": -1}, + "connections_limit": {"postgres": -1}, + "resource_count_limit": {"postgres": -1}, + "deployments_apps": -1, + "backup_retention_days": 30, + "backup_restore_enabled": true, + "manual_backups_per_day": 10, + "rpo_minutes": 5, + "rto_minutes": 15, + "annual_discount_percent": 17, + "upgrade_url": null, + "is_terminal_tier": true + } + ] +}` + +func TestCapabilities_DecodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Valid JSON, but `tiers` is a string where the typed struct expects an + // array — the typed decode fails, exercising the decode-error branch. + _, _ = io.WriteString(w, `{"ok":true,"tiers":"not-an-array"}`) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) + if _, err := c.Capabilities(context.Background()); err == nil { + t.Fatal("Capabilities: expected a decode error for malformed tiers, got nil") + } +} + +func TestCapabilities_TypedDecode(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, capabilitiesBody) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) + caps, err := c.Capabilities(context.Background()) + if err != nil { + t.Fatalf("Capabilities: %v", err) + } + + if gotPath != capabilitiesPath { + t.Errorf("hit path %q, want %q", gotPath, capabilitiesPath) + } + if !caps.OK { + t.Error("OK = false, want true") + } + if caps.Docs != "https://instanode.dev/llms-full.txt" { + t.Errorf("Docs = %q", caps.Docs) + } + if caps.Contact != "mailto:enterprise@instanode.dev" { + t.Errorf("Contact = %q", caps.Contact) + } + if len(caps.Tiers) != 2 { + t.Fatalf("len(Tiers) = %d, want 2", len(caps.Tiers)) + } + + anon := caps.Tiers[0] + if anon.Tier != "anonymous" || anon.DisplayName != "Anonymous" { + t.Errorf("anon tier mismatch: %+v", anon) + } + if anon.StorageLimitMB["postgres"] != 10 { + t.Errorf("anon postgres storage = %d, want 10", anon.StorageLimitMB["postgres"]) + } + if anon.ConnectionsLimit["postgres"] != 2 { + t.Errorf("anon postgres conns = %d, want 2", anon.ConnectionsLimit["postgres"]) + } + if anon.PaidFromDayOne { + t.Error("anon PaidFromDayOne = true, want false") + } + if anon.IsTerminalTier { + t.Error("anon IsTerminalTier = true, want false") + } + if anon.UpgradeURL == nil || *anon.UpgradeURL != "https://instanode.dev/pricing/" { + t.Errorf("anon UpgradeURL = %v, want pricing page", anon.UpgradeURL) + } + + team := caps.Tiers[1] + if team.Tier != "team" { + t.Errorf("team.Tier = %q", team.Tier) + } + if team.PriceUSDMonthly != 199 || !team.PaidFromDayOne { + t.Errorf("team price/paid mismatch: %d %v", team.PriceUSDMonthly, team.PaidFromDayOne) + } + if team.Deployments != -1 { + t.Errorf("team Deployments = %d, want -1 (unlimited)", team.Deployments) + } + if !team.IsTerminalTier { + t.Error("team IsTerminalTier = false, want true") + } + if team.UpgradeURL != nil { + t.Errorf("terminal tier UpgradeURL = %v, want nil", team.UpgradeURL) + } + if team.BackupRestoreEnabled != true || team.RPOMinutes != 5 || team.RTOMinutes != 15 { + t.Errorf("team durability fields mismatch: %+v", team) + } + if team.AnnualDiscountPercent != 17 { + t.Errorf("team AnnualDiscountPercent = %d, want 17", team.AnnualDiscountPercent) + } + + // Forward-compat escape hatch: the unknown field is preserved on Raw. + if caps.Raw == nil { + t.Fatal("Raw map is nil, want the full decoded body") + } + if caps.Raw["future_unknown_field"] != "tolerated" { + t.Errorf("Raw[future_unknown_field] = %v, want the forward-compat value preserved", + caps.Raw["future_unknown_field"]) + } +} + +func TestCapabilities_AnonymousMode(t *testing.T) { + // The endpoint is public — Capabilities must work with no API key set. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("anonymous client should send no Authorization header, got %q", got) + } + _, _ = io.WriteString(w, capabilitiesBody) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) // no WithAPIKey + if _, err := c.Capabilities(context.Background()); err != nil { + t.Fatalf("Capabilities (anon): %v", err) + } +} + +func TestCapabilities_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"ok":false,"error":"plans_unavailable","message":"Tier matrix not loaded"}`) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) + _, err := c.Capabilities(context.Background()) + if err == nil { + t.Fatal("expected error on 503") + } + if !IsServiceUnavailable(err) { + t.Errorf("IsServiceUnavailable should match: %v", err) + } +} diff --git a/instant/integration_prod_test.go b/instant/integration_prod_test.go new file mode 100644 index 0000000..e25b06a --- /dev/null +++ b/instant/integration_prod_test.go @@ -0,0 +1,161 @@ +//go:build integration + +package instant_test + +// Live integration tests against the PROD instanode.dev API. +// +// Unlike integration_test.go (which targets a local k8s cluster via +// INSTANT_API_URL and provisions real resources), this file pins the public +// production host and exercises only READ-ONLY, side-effect-free surfaces: +// +// - an unauthenticated GET /api/v1/resources, which the API rejects with the +// canonical agent-native error envelope — proving the APIError fix +// (agent_action + error_code + upgrade_url survive the round trip), and +// - GET /api/v1/capabilities, the public tier matrix — proving Capabilities() +// decodes the live response and returns tiers. +// +// Run: +// +// go test ./instant/... -tags integration -v -run TestProdIntegration +// +// INSTANODE_PROD_API_URL overrides the host (defaults to the public prod host). +// The suite skips cleanly if the host is unreachable so CI on a network-isolated +// runner is not a hard failure. + +import ( + "context" + "errors" + "net/http" + "os" + "testing" + "time" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +// defaultProdAPIURL is the canonical public production backend host. The fix +// being verified is the agent-native error envelope, which is a prod contract — +// so this test pins prod rather than a local cluster. +const defaultProdAPIURL = "https://api.instanode.dev" + +// prodClient returns a Client pointed at the prod host, or skips the test if the +// host is unreachable. No API key is set: the resources call is meant to 401, and +// capabilities is public. +func prodClient(t *testing.T) (*instant.Client, string) { + t.Helper() + + apiURL := os.Getenv("INSTANODE_PROD_API_URL") + if apiURL == "" { + apiURL = defaultProdAPIURL + } + + // Probe /healthz to give a clean skip when prod is unreachable from this + // runner (e.g. an air-gapped CI box) rather than a hard failure. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"/healthz", nil) + if err != nil { + t.Skipf("cannot build healthz request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Skipf("prod instanode.dev unreachable at %s: %v", apiURL, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Skipf("prod instanode.dev returned %d from /healthz", resp.StatusCode) + } + + return instant.New(instant.WithBaseURL(apiURL)), apiURL +} + +// TestProdIntegration_APIErrorEnvelope hits an erroring endpoint on PROD with no +// credentials and asserts the SDK's APIError carries the full agent-native +// envelope: a machine-readable error_code (via CanonicalCode) AND a non-empty +// agent_action AND an upgrade_url. This proves the envelope fix against the real +// production response, not a mock. +func TestProdIntegration_APIErrorEnvelope(t *testing.T) { + client, apiURL := prodClient(t) + ctx := context.Background() + + // Unauthenticated list — RequireAuth rejects with the canonical envelope. + _, err := client.ListResources(ctx) + if err == nil { + t.Fatalf("ListResources against %s with no credentials: want an error, got nil", apiURL) + } + + var apiErr *instant.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error is not *instant.APIError: %T: %v", err, err) + } + + t.Logf("prod APIError: status=%d code=%q error_code=%q canonical=%q agent_action=%q upgrade_url=%q request_id=%q", + apiErr.StatusCode, apiErr.Code, apiErr.ErrorCode, apiErr.CanonicalCode(), + apiErr.AgentAction, apiErr.UpgradeURL, apiErr.RequestID) + + // The auth-required surface must be a 401 (or 403 if the host re-shapes it). + if apiErr.StatusCode != http.StatusUnauthorized && apiErr.StatusCode != http.StatusForbidden { + t.Errorf("StatusCode = %d, want 401 or 403 for an unauthenticated list", apiErr.StatusCode) + } + + // CanonicalCode must resolve to a machine-readable code (error_code preferred, + // error category fallback). Empty means the envelope was dropped. + if apiErr.CanonicalCode() == "" { + t.Error("CanonicalCode() is empty — error envelope (error/error_code) was not captured") + } + + // agent_action is the LLM-ready next step — the core of the agent-native + // contract. The fix exists to stop this being dropped. + if apiErr.AgentAction == "" { + t.Error("AgentAction is empty — agent_action was dropped from the prod envelope") + } + + // upgrade_url points the user at claim/login to clear the auth error. + if apiErr.UpgradeURL == "" { + t.Error("UpgradeURL is empty — upgrade_url was dropped from the prod envelope") + } +} + +// TestProdIntegration_Capabilities calls Capabilities() against PROD and asserts +// the public tier matrix decodes and returns tiers — proving the new +// Capabilities() surface works against the live, unauthenticated endpoint. +func TestProdIntegration_Capabilities(t *testing.T) { + client, apiURL := prodClient(t) + ctx := context.Background() + + caps, err := client.Capabilities(ctx) + if err != nil { + t.Fatalf("Capabilities against %s: %v", apiURL, err) + } + + if !caps.OK { + t.Error("Capabilities.OK = false, want true") + } + if len(caps.Tiers) == 0 { + t.Fatal("Capabilities returned zero tiers — tier matrix did not decode") + } + + // Sanity: the anonymous tier should be present at the head of the upgrade + // order, and at least one tier should name a postgres storage limit. + sawAnonymous := false + sawPostgresLimit := false + for _, tier := range caps.Tiers { + if tier.Tier == "anonymous" { + sawAnonymous = true + } + if _, ok := tier.StorageLimitMB["postgres"]; ok { + sawPostgresLimit = true + } + t.Logf("tier=%s display=%q price=$%d/mo postgres=%dMB deploys=%d", + tier.Tier, tier.DisplayName, tier.PriceUSDMonthly, + tier.StorageLimitMB["postgres"], tier.Deployments) + } + + if !sawAnonymous { + t.Error("no 'anonymous' tier in the prod capabilities matrix") + } + if !sawPostgresLimit { + t.Error("no tier reported a postgres storage limit — matrix shape unexpected") + } +} diff --git a/instant/types.go b/instant/types.go index 6a1d141..b560a6a 100644 --- a/instant/types.go +++ b/instant/types.go @@ -243,29 +243,119 @@ func (o ClaimOpts) claimToken() string { // APIError is returned when the server responds with a 4xx or 5xx status code. // It implements the error interface so it can be used directly in error comparisons. +// +// The instanode.dev API replies to every error with the canonical envelope: +// +// { +// "ok": false, +// "error": "unauthorized", // category (Code below) +// "error_code": "missing_credentials", // canonical machine code (ErrorCode below) +// "message": "...", // human-readable description +// "agent_action": "Have the user log in ...",// LLM-ready next step +// "upgrade_url": "https://instanode.dev/...",// where to upgrade/claim, if applicable +// "retry_after_seconds": 30, // null on 4xx, set on 429/502/503/504 +// "request_id": "req_..." // correlation id for support +// } +// +// Every field above has a home on this struct so the agent-native contract +// (machine code + the next action to take + where to upgrade) survives the +// round trip. APIErrorEnvelopeKeys is the authoritative list of envelope keys +// the SDK maps; a registry test asserts each one round-trips so a future API +// field can't be silently dropped. type APIError struct { // StatusCode is the HTTP status code returned by the server. StatusCode int - // Code is the machine-readable error code from the server (e.g. "not_found"). + // Code is the error category from the server's "error" field + // (e.g. "unauthorized", "not_found"). For the canonical machine-readable + // code, prefer [APIError.CanonicalCode], which returns ErrorCode when the + // server supplied the finer-grained "error_code" field and falls back to + // this category otherwise. Code string `json:"error"` + // ErrorCode is the canonical machine-readable error code from the server's + // "error_code" field (e.g. "missing_credentials"). It is finer-grained than + // Code (the category). Empty when the server only sent the category. + ErrorCode string `json:"error_code"` + // Message is the human-readable error description. Message string `json:"message"` + // AgentAction is the LLM-ready next step the API recommends — a full + // sentence an agent can relay to the user, usually carrying a concrete + // instanode.dev URL. Empty when the server did not supply one. + AgentAction string `json:"agent_action"` + + // UpgradeURL points at the page where the user can upgrade their plan or + // claim their resources to clear the error. Empty when not applicable. + UpgradeURL string `json:"upgrade_url"` + + // RetryAfterSeconds is the number of seconds the client should wait before + // retrying. It is a pointer so the SDK can distinguish "retry in 0s" from + // "do not retry" (null). Non-nil on 429/502/503/504; nil on 4xx that the + // caller must fix rather than retry. + RetryAfterSeconds *int `json:"retry_after_seconds"` + + // RequestID is the server-side correlation id for this request. Include it + // when contacting support so the request can be traced. + RequestID string `json:"request_id"` + // raw is the full response body for debugging. raw string } -// Error implements the error interface. +// APIErrorEnvelopeKeys is the authoritative list of JSON keys the +// instanode.dev error envelope can emit that this SDK maps onto [APIError]. +// Every key here MUST have a tagged field on APIError; the registry test +// (TestAPIError_EnvelopeKeysAllHaveAHome) iterates this list against the +// struct tags so a newly added API field can't silently drop. When the API +// adds an envelope key, add it here AND give it a tagged field on APIError in +// the same change. +// +// claim_url is intentionally excluded: it is a recycle-gate-only alias of +// upgrade_url and the SDK folds that surface into UpgradeURL. +var APIErrorEnvelopeKeys = []string{ + "error", + "error_code", + "message", + "agent_action", + "upgrade_url", + "retry_after_seconds", + "request_id", +} + +// CanonicalCode returns the most specific machine-readable error code the +// server supplied: the finer-grained ErrorCode ("error_code") when present, +// falling back to the Code category ("error") otherwise. Branch on this rather +// than on Code directly so a caller keying off "missing_credentials" keeps +// working even though the category is the coarser "unauthorized". +func (e *APIError) CanonicalCode() string { + if e.ErrorCode != "" { + return e.ErrorCode + } + return e.Code +} + +// Error implements the error interface. The canonical machine code, the +// human message, and — when the server supplied them — the agent_action and +// upgrade_url are folded into one line so a log entry is actionable without a +// second lookup. func (e *APIError) Error() string { - if e.Code != "" { - return fmt.Sprintf("instant.dev API error %d (%s): %s", e.StatusCode, e.Code, e.Message) + var s string + if code := e.CanonicalCode(); code != "" { + s = fmt.Sprintf("instant.dev API error %d (%s): %s", e.StatusCode, code, e.Message) + } else if e.raw != "" { + s = fmt.Sprintf("instant.dev API error %d: %s", e.StatusCode, e.raw) + } else { + s = fmt.Sprintf("instant.dev API error %d", e.StatusCode) + } + if e.AgentAction != "" { + s += " | agent_action: " + e.AgentAction } - if e.raw != "" { - return fmt.Sprintf("instant.dev API error %d: %s", e.StatusCode, e.raw) + if e.UpgradeURL != "" { + s += " | upgrade_url: " + e.UpgradeURL } - return fmt.Sprintf("instant.dev API error %d", e.StatusCode) + return s } // IsNotFound reports whether the error is a 404 Not Found.