diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4bde8..3f38878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ existing callers. ### Fixed +- **Provisioning + deploy calls now default to a 120 s per-request timeout + (reads stay at 30 s).** Provisioning is synchronous server-side: `POST /db/new` + (and the other `/*/new` endpoints plus `/deploy/new`) blocks while the real + Postgres / Redis / Mongo / NATS / bucket / pod is created. Under prod hot-pool + contention a *fresh* Postgres provision can exceed the old 30 s client timeout; + when the client gave up the server kept working and held a 60 s in-flight + idempotency marker, so the caller's retry hit `409 idempotency_key_in_progress` + instead of succeeding. The provisioning + deploy paths now run on a dedicated + HTTP client with no client-wide cap and a 120 s per-request context deadline, + comfortably outliving both the slow provision and the server's in-flight + window. Read calls (list/get/claim/delete/rotate) are unchanged at 30 s. + `WithTimeout` still overrides both (it now governs the provisioning deadline + too), and a tighter deadline on the caller's `context.Context` is always + honoured — the SDK only lengthens an open-ended context, never overrides a + shorter caller deadline. - **`Client.Claim` now sends the canonical `token` wire field instead of the deprecated `jwt` alias.** The api ClaimRequest doc names the Go SDK as one of three drift sources for the legacy `jwt` name (alongside the dashboard diff --git a/README.md b/README.md index 942f618..c623f6c 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,32 @@ q, err := client.ProvisionQueue(ctx, &instant.ProvisionOpts{Name: "app-queue"}) Anonymous resources expire after **24 hours**. Claim them permanently with a free account (see [Claim](#claim) below or visit the URL in `result.Note`). +### Timeouts: provisioning runs longer than reads + +Provisioning is **synchronous** — `ProvisionDatabase` / `Cache` / `MongoDB` / +`Queue` / `Storage` / `Webhook` and `Deploy` block while the API creates the +real backend. Under production hot-pool contention a *fresh* Postgres provision +can take **more than 30 seconds**. If the client gave up at 30 s, the server +kept working and held a 60 s in-flight idempotency marker, so the next retry hit +`409 idempotency_key_in_progress` instead of succeeding. + +To avoid that, the SDK gives provisioning + deploy calls a **120 s** per-request +deadline by default, while read calls (list, get, claim, delete, rotate) keep +the shorter **30 s** default. You do not need to do anything — the split is +automatic. + +Override either with `WithTimeout`, which sets a single budget governing **both** +read and provisioning calls: + +```go +// One 90 s budget for every call — set high enough to outlive a slow provision. +client := instant.New(instant.WithTimeout(90 * time.Second)) +``` + +A deadline you set on the `context.Context` you pass in is always honoured if it +is *tighter* than the SDK's provisioning budget — the SDK only lengthens an +open-ended context, it never overrides a shorter caller deadline. + --- ## Deploy @@ -217,7 +243,7 @@ fmt.Println("team_id:", result.TeamID) client := instant.New( instant.WithAPIKey("inst_live_..."), // default: INSTANT_API_KEY env var instant.WithBaseURL("http://localhost:8080"), // default: INSTANT_API_URL or https://api.instanode.dev (port-forward svc/instant-api for local k8s) - instant.WithTimeout(15 * time.Second), // default: 30s + instant.WithTimeout(15 * time.Second), // governs reads AND provisioning; defaults: reads 30s, provisioning 120s instant.WithHTTPClient(myClient), // custom transport (tracing, TLS, etc.) instant.WithLogger(slog.Default()), // advisory notices and upgrade prompts ) diff --git a/instant/cache.go b/instant/cache.go index 985f76d..f7de02e 100644 --- a/instant/cache.go +++ b/instant/cache.go @@ -38,7 +38,7 @@ func (c *Client) ProvisionCache(ctx context.Context, opts *ProvisionOpts) (*Prov } var result ProvisionResult - if err := c.postJSONWithHeaders(ctx, "/cache/new", body, provisionHeaders(opts), &result); err != nil { + if err := c.provisionJSONWithHeaders(ctx, "/cache/new", body, provisionHeaders(opts), &result); err != nil { return nil, fmt.Errorf("ProvisionCache: %w", err) } if result.Token == "" { diff --git a/instant/client.go b/instant/client.go index 8b21f8f..cfb103d 100644 --- a/instant/client.go +++ b/instant/client.go @@ -47,18 +47,54 @@ const ( // TLS, full OpenAPI surface. DefaultBaseURL = "https://api.instanode.dev" - // defaultTimeout is applied to every HTTP request. + // defaultTimeout is the per-request deadline applied to read-style calls + // (list, get, claim, delete, rotate). 30 s is plenty for a JSON round trip. defaultTimeout = 30 * time.Second + + // defaultProvisioningTimeout is the per-request deadline applied to the + // synchronous provisioning + deploy endpoints. + // + // Provisioning is synchronous on the server: POST /db/new (and the other + // /*/new endpoints, plus /deploy/new) blocks the handler while the real + // Postgres / Redis / Mongo / NATS / bucket / pod is created. Under prod + // hot-pool contention a *fresh* Postgres provision can exceed 30 s. When + // the client gave up at the read-path 30 s the server kept working and + // held a 60 s in-flight idempotency marker, so the caller's retry hit + // `409 idempotency_key_in_progress` instead of succeeding. A 120 s + // provisioning deadline comfortably outlives both the slow provision and + // the server's in-flight window, so the first call returns the resource + // rather than orphaning it behind a conflicting retry. + // + // Overridable: WithTimeout sets an explicit per-request deadline that + // governs BOTH read and provisioning calls (see [WithTimeout]). + defaultProvisioningTimeout = 120 * time.Second ) // Client is the instant.dev API client. // Construct one with [New]; all methods are safe for concurrent use. type Client struct { - baseURL string - apiKey string + baseURL string + apiKey string + + // httpClient governs read-style calls. Its Timeout is the read-path + // per-request deadline (defaultTimeout unless WithTimeout overrides it). httpClient *http.Client - userAgent string - logger *slog.Logger + + // provisionClient governs the synchronous provisioning + deploy calls. It + // shares httpClient's transport (so WithHTTPClient's transport chaining, + // auth header, and User-Agent all apply identically) but carries no + // client-wide Timeout cap — the per-request provisioning deadline is + // enforced via the request context instead. This lets provisioning run for + // up to provisionTimeout even though reads stay capped at the shorter + // read-path Timeout. + provisionClient *http.Client + + // provisionTimeout is the per-request deadline applied to provisioning + + // deploy calls (defaultProvisioningTimeout unless WithTimeout overrides it). + provisionTimeout time.Duration + + userAgent string + logger *slog.Logger } // Option configures a [Client]. Pass options to [New]. @@ -101,9 +137,22 @@ func WithHTTPClient(hc *http.Client) Option { } } -// WithTimeout sets the per-request HTTP timeout. Default is 30 s. +// WithTimeout sets the per-request HTTP timeout. +// +// It governs BOTH read-style calls and the synchronous provisioning / deploy +// calls. Without this option reads default to 30 s and provisioning defaults to +// 120 s (synchronous provisioning can exceed 30 s under prod hot-pool +// contention — see [defaultProvisioningTimeout]). Passing WithTimeout collapses +// both onto the single value you supply, so set it high enough to outlive a +// slow provision if you provision through this client: +// +// // give every call — reads and provisioning alike — a 90 s budget +// client := instant.New(instant.WithTimeout(90 * time.Second)) func WithTimeout(d time.Duration) Option { - return func(c *Client) { c.httpClient.Timeout = d } + return func(c *Client) { + c.httpClient.Timeout = d + c.provisionTimeout = d + } } // WithLogger sets the structured logger used for advisory notices and upgrade prompts. @@ -127,6 +176,10 @@ func New(opts ...Option) *Client { httpClient: &http.Client{ Timeout: defaultTimeout, }, + // 0 is the "not explicitly set" sentinel. WithTimeout overwrites it; + // otherwise it is resolved below to either the default provisioning + // timeout or a caller-supplied WithHTTPClient Timeout. + provisionTimeout: 0, } // Environment defaults (explicit options below override these) @@ -141,6 +194,21 @@ func New(opts ...Option) *Client { opt(c) } + // Resolve the provisioning deadline. Precedence: + // 1. WithTimeout — already stamped both httpClient.Timeout and + // provisionTimeout to the caller's explicit value (non-zero here). + // 2. WithHTTPClient with a non-zero Timeout — the caller set a deliberate + // per-request budget on their own client; honour it for provisioning + // too rather than silently widening it to 120 s. + // 3. Neither — provisioning gets the 120 s default while reads keep 30 s. + if c.provisionTimeout == 0 { + if c.httpClient.Timeout != defaultTimeout && c.httpClient.Timeout > 0 { + c.provisionTimeout = c.httpClient.Timeout + } else { + c.provisionTimeout = defaultProvisioningTimeout + } + } + // Wire the auth transport on top of whatever the caller supplied. We // preserve every field on the caller's *http.Client (Timeout, // CheckRedirect, Jar, Transport) and chain the caller's Transport as the @@ -154,21 +222,55 @@ func New(opts ...Option) *Client { if base == nil { base = http.DefaultTransport } - wrapped := &http.Client{ + sharedTransport := &authTransport{ + base: base, + apiKey: c.apiKey, + userAgent: c.userAgent, + } + c.httpClient = &http.Client{ Timeout: c.httpClient.Timeout, CheckRedirect: c.httpClient.CheckRedirect, Jar: c.httpClient.Jar, - Transport: &authTransport{ - base: base, - apiKey: c.apiKey, - userAgent: c.userAgent, - }, + Transport: sharedTransport, + } + + // provisionClient shares the same (auth-wrapped) transport but carries NO + // client-wide Timeout. Provisioning deadlines are enforced per-request via + // the request context (see provisionContext) so a slow synchronous + // provision can run for the full provisionTimeout instead of being killed + // at the shorter read-path Timeout. CheckRedirect / Jar are preserved so + // the two clients behave identically apart from the timeout cap. + c.provisionClient = &http.Client{ + Timeout: 0, + CheckRedirect: c.httpClient.CheckRedirect, + Jar: c.httpClient.Jar, + Transport: sharedTransport, } - c.httpClient = wrapped return c } +// provisionContext derives a child context carrying the provisioning deadline. +// +// It never *extends* a deadline the caller already set: if ctx already has an +// earlier deadline (the caller passed context.WithTimeout themselves) that +// tighter deadline wins. It only adds the provisioning budget when the caller +// left the context open-ended. The returned cancel func must always be called. +func (c *Client) provisionContext(ctx context.Context) (context.Context, context.CancelFunc) { + d := c.provisionTimeout + if d <= 0 { + d = defaultProvisioningTimeout + } + if existing, ok := ctx.Deadline(); ok { + // Caller set their own deadline; only shorten ours to honour it, never + // override a tighter caller budget. + if remaining := time.Until(existing); remaining <= d { + return context.WithCancel(ctx) + } + } + return context.WithTimeout(ctx, d) +} + // ─── internal HTTP helpers ──────────────────────────────────────────────────── // get executes a GET request and decodes the JSON response into out. @@ -187,19 +289,45 @@ func (c *Client) postJSON(ctx context.Context, path string, body any, out any) e } // postJSONWithHeaders is like postJSON but also sets extra request headers. -// Used by the provisioning helpers to forward the optional Idempotency-Key. +// It runs on the read-path client (defaultTimeout). Provisioning helpers must +// use provisionJSONWithHeaders instead so the synchronous provision gets the +// longer provisioning deadline. func (c *Client) postJSONWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error { - var r io.Reader - if body != nil { - b, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("marshalling request body: %w", err) - } - r = bytes.NewReader(b) + r, err := jsonBodyReader(body) + if err != nil { + return err } return c.doWithHeaders(ctx, http.MethodPost, path, r, headers, out) } +// provisionJSONWithHeaders POSTs a JSON body for a synchronous provisioning +// call. It routes through provisionClient (no client-wide Timeout cap) and +// applies the longer provisioning deadline via the request context, so a slow +// hot-pool provision can complete instead of timing out at the read-path 30 s +// and orphaning the resource behind a 409 idempotency_key_in_progress retry. +func (c *Client) provisionJSONWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error { + r, err := jsonBodyReader(body) + if err != nil { + return err + } + pctx, cancel := c.provisionContext(ctx) + defer cancel() + return c.doWithClient(pctx, c.provisionClient, http.MethodPost, path, r, headers, out) +} + +// jsonBodyReader marshals body to a re-readable JSON reader. A nil body yields +// a nil reader (no Content-Type is set downstream in that case). +func jsonBodyReader(body any) (io.Reader, error) { + if body == nil { + return nil, nil + } + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshalling request body: %w", err) + } + return bytes.NewReader(b), nil +} + // delete executes a DELETE request and decodes the JSON response into out. func (c *Client) delete(ctx context.Context, path string, out any) error { return c.do(ctx, http.MethodDelete, path, nil, out) @@ -211,8 +339,18 @@ func (c *Client) do(ctx context.Context, method, path string, body io.Reader, ou } // doWithHeaders is like do but allows the caller to attach extra request -// headers (e.g. Idempotency-Key). headers may be nil. +// headers (e.g. Idempotency-Key). headers may be nil. It runs on the read-path +// httpClient (read-path Timeout). func (c *Client) doWithHeaders(ctx context.Context, method, path string, body io.Reader, headers map[string]string, out any) error { + return c.doWithClient(ctx, c.httpClient, method, path, body, headers, out) +} + +// doWithClient executes an HTTP request on the supplied client, retrying once +// on a transport error or 5xx. It is the shared core behind doWithHeaders +// (read path) and provisionJSONWithHeaders (provisioning path); the only +// difference between the two is the *http.Client (hence the timeout regime) +// and the request context. +func (c *Client) doWithClient(ctx context.Context, hc *http.Client, method, path string, body io.Reader, headers map[string]string, out any) error { rawURL := c.baseURL + path // If we have a body reader we need to be able to re-read it on retry. @@ -246,7 +384,7 @@ func (c *Client) doWithHeaders(ctx context.Context, method, path string, body io } } - resp, err := c.httpClient.Do(req) + resp, err := hc.Do(req) if err != nil { lastErr = fmt.Errorf("request failed: %w", err) if attempt == 0 { diff --git a/instant/database.go b/instant/database.go index 2c73c4e..56def3e 100644 --- a/instant/database.go +++ b/instant/database.go @@ -34,7 +34,7 @@ func (c *Client) ProvisionDatabase(ctx context.Context, opts *ProvisionOpts) (*P } var result ProvisionResult - if err := c.postJSONWithHeaders(ctx, "/db/new", body, provisionHeaders(opts), &result); err != nil { + if err := c.provisionJSONWithHeaders(ctx, "/db/new", body, provisionHeaders(opts), &result); err != nil { return nil, fmt.Errorf("ProvisionDatabase: %w", err) } if result.Token == "" { diff --git a/instant/deploy.go b/instant/deploy.go index 12e85e6..115d9b5 100644 --- a/instant/deploy.go +++ b/instant/deploy.go @@ -171,8 +171,16 @@ func (c *Client) Deploy(ctx context.Context, opts DeployOpts) (*Deployment, erro return nil, fmt.Errorf("instant: closing multipart writer: %w", err) } + // Deploy is a synchronous create like the /*/new provisioning endpoints: + // the API blocks while the Kaniko build is kicked off and the row is + // written. Use the provisioning client (no 30 s read-path cap) plus the + // longer provisioning deadline so a slow accept under load doesn't time out + // client-side and strand the build behind a 409 idempotency conflict. + pctx, cancel := c.provisionContext(ctx) + defer cancel() + url := c.baseURL + "/deploy/new" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) + req, err := http.NewRequestWithContext(pctx, http.MethodPost, url, &buf) if err != nil { return nil, fmt.Errorf("instant: building deploy request: %w", err) } @@ -181,7 +189,7 @@ func (c *Client) Deploy(ctx context.Context, opts DeployOpts) (*Deployment, erro req.Header.Set("Idempotency-Key", opts.IdempotencyKey) } - resp, err := c.httpClient.Do(req) + resp, err := c.provisionClient.Do(req) if err != nil { return nil, fmt.Errorf("instant: deploy request failed: %w", err) } diff --git a/instant/mongodb.go b/instant/mongodb.go index 82bfaba..82c1e3d 100644 --- a/instant/mongodb.go +++ b/instant/mongodb.go @@ -34,7 +34,7 @@ func (c *Client) ProvisionMongoDB(ctx context.Context, opts *ProvisionOpts) (*Pr } var result ProvisionResult - if err := c.postJSONWithHeaders(ctx, "/nosql/new", body, provisionHeaders(opts), &result); err != nil { + if err := c.provisionJSONWithHeaders(ctx, "/nosql/new", body, provisionHeaders(opts), &result); err != nil { return nil, fmt.Errorf("ProvisionMongoDB: %w", err) } if result.Token == "" { diff --git a/instant/provision_timeout_test.go b/instant/provision_timeout_test.go new file mode 100644 index 0000000..7b22a49 --- /dev/null +++ b/instant/provision_timeout_test.go @@ -0,0 +1,183 @@ +package instant + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestProvisioningTimeoutDefaults pins the read-vs-provisioning timeout split. +// +// Synchronous provisioning (POST /db/new etc.) can exceed the 30 s read-path +// budget under prod hot-pool contention; a client-side give-up at 30 s strands +// the resource behind a 60 s in-flight idempotency marker, so the retry hits +// 409 idempotency_key_in_progress. The fix gives provisioning a 120 s deadline. +// This test fails if either constant drifts or the wiring is dropped. +func TestProvisioningTimeoutDefaults(t *testing.T) { + c := New() + + if c.httpClient.Timeout != defaultTimeout { + t.Errorf("read-path Timeout = %v, want %v (reads must stay short)", c.httpClient.Timeout, defaultTimeout) + } + if c.provisionTimeout != defaultProvisioningTimeout { + t.Errorf("provisionTimeout = %v, want %v (provisioning must outlive reads)", c.provisionTimeout, defaultProvisioningTimeout) + } + if c.provisionTimeout <= c.httpClient.Timeout { + t.Errorf("provisionTimeout (%v) must be strictly longer than the read Timeout (%v)", c.provisionTimeout, c.httpClient.Timeout) + } + if defaultProvisioningTimeout != 120*time.Second { + t.Errorf("defaultProvisioningTimeout = %v, want 120s (the value verified to fix the prod 409 conflict)", defaultProvisioningTimeout) + } + // The provisioning client must carry NO client-wide Timeout cap — the + // deadline is enforced per-request via provisionContext. A non-zero cap + // here would silently re-impose a ceiling on slow provisions. + if c.provisionClient.Timeout != 0 { + t.Errorf("provisionClient.Timeout = %v, want 0 (cap must come from the request context, not the client)", c.provisionClient.Timeout) + } +} + +// TestProvisioningOutlivesReadCap is the behavioral proof: a provisioning call +// succeeds against a server that stalls *past* the read-path timeout, because +// the provisioning path runs on provisionClient (no cap) with the longer +// provisioning deadline — while a read against the same slow server times out. +// +// Scaled down from the real 30 s / 120 s split (read = 40 ms, provision = 2 s, +// server stalls 250 ms) so it runs deterministically in milliseconds; the +// mechanism under test is identical. +func TestProvisioningOutlivesReadCap(t *testing.T) { + const serverStall = 250 * time.Millisecond + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(serverStall) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "token": "tok-db", "connection_url": "postgres://h", + }) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) + // Reproduce the prod split at millisecond scale: reads would give up well + // before the server responds; provisioning has ample headroom. + c.httpClient.Timeout = 40 * time.Millisecond // read-path cap (stand-in for 30 s) + c.provisionTimeout = 2 * time.Second // provisioning deadline (stand-in for 120 s) + + // Read path: must trip the short read-path cap. + if _, err := c.ListResources(context.Background()); err == nil { + t.Fatal("read call should have timed out at the short read-path cap, but succeeded") + } + + // Provisioning path: must outlive the read-path cap and succeed. + r, err := c.ProvisionDatabase(context.Background(), &ProvisionOpts{Name: "db1"}) + if err != nil { + t.Fatalf("ProvisionDatabase should have outlived the read-path cap, got error: %v", err) + } + if r.Token != "tok-db" { + t.Errorf("Token = %q, want tok-db", r.Token) + } +} + +// TestProvisioningHonorsTighterCallerDeadline proves provisionContext never +// *extends* a deadline the caller already set: a caller passing a 30 ms context +// to a provisioning call against a 250 ms server must still time out, even +// though the provisioning default (120 s) is far longer. +func TestProvisioningHonorsTighterCallerDeadline(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(250 * time.Millisecond) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "t", "connection_url": "x"}) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + if _, err := c.ProvisionDatabase(ctx, &ProvisionOpts{Name: "db1"}); err == nil { + t.Fatal("provisioning must honour a tighter caller deadline and time out, but it succeeded") + } +} + +// TestWithTimeoutGovernsProvisioning proves WithTimeout overrides BOTH the read +// and provisioning defaults: a 40 ms explicit budget makes a provisioning call +// against a 250 ms server time out, confirming the option remains the escape +// hatch for callers who want a single, smaller budget. +func TestWithTimeoutGovernsProvisioning(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(250 * time.Millisecond) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "t", "connection_url": "x"}) + })) + defer srv.Close() + + c := New(WithBaseURL(srv.URL), WithTimeout(40*time.Millisecond)) + if c.provisionTimeout != 40*time.Millisecond { + t.Fatalf("WithTimeout did not govern provisionTimeout: got %v", c.provisionTimeout) + } + + if _, err := c.ProvisionDatabase(context.Background(), &ProvisionOpts{Name: "db1"}); err == nil { + t.Fatal("provisioning should respect the explicit WithTimeout budget and time out, but it succeeded") + } +} + +// TestWithHTTPClientTimeoutGovernsProvisioning proves a caller-supplied +// http.Client whose Timeout is explicitly set (via WithHTTPClient) governs the +// provisioning deadline too — the SDK must not silently widen the caller's +// deliberate budget to 120 s. +func TestWithHTTPClientTimeoutGovernsProvisioning(t *testing.T) { + c := New(WithHTTPClient(&http.Client{Timeout: 7 * time.Second})) + if c.provisionTimeout != 7*time.Second { + t.Errorf("WithHTTPClient Timeout should govern provisionTimeout, got %v want 7s", c.provisionTimeout) + } +} + +// TestProvisionContextOpenEndedGetsBudget covers the open-ended-context branch +// of provisionContext directly: a context with no deadline receives the full +// provisioning budget. +func TestProvisionContextOpenEndedGetsBudget(t *testing.T) { + c := New() + pctx, cancel := c.provisionContext(context.Background()) + defer cancel() + dl, ok := pctx.Deadline() + if !ok { + t.Fatal("provisionContext should add a deadline to an open-ended context") + } + remaining := time.Until(dl) + // Allow a little slack for scheduling; it should be close to 120 s. + if remaining < defaultProvisioningTimeout-time.Second || remaining > defaultProvisioningTimeout+time.Second { + t.Errorf("provisioning deadline = %v out, want ~%v", remaining, defaultProvisioningTimeout) + } +} + +// TestProvisionContextZeroTimeoutFallsBack covers the defensive d<=0 guard: +// a Client whose provisionTimeout is somehow zero (e.g. a zero-value Client) +// still gets the default provisioning budget rather than an instantly-expired +// deadline. +func TestProvisionContextZeroTimeoutFallsBack(t *testing.T) { + c := New() + c.provisionTimeout = 0 // force the defensive branch + + pctx, cancel := c.provisionContext(context.Background()) + defer cancel() + dl, ok := pctx.Deadline() + if !ok { + t.Fatal("provisionContext should still add a deadline when provisionTimeout is 0") + } + if remaining := time.Until(dl); remaining < defaultProvisioningTimeout-time.Second { + t.Errorf("zero provisionTimeout should fall back to ~%v, got %v", defaultProvisioningTimeout, remaining) + } +} + +// TestProvisionJSONMarshalError covers the marshal-error branch of +// provisionJSONWithHeaders: an unmarshalable body surfaces the marshalling +// error before any network request, mirroring the read-path postJSON behaviour. +func TestProvisionJSONMarshalError(t *testing.T) { + c := New(WithBaseURL("http://unused")) + // channels are not JSON-marshalable + err := c.provisionJSONWithHeaders(context.Background(), "/db/new", make(chan int), nil, nil) + if err == nil || !strings.Contains(err.Error(), "marshalling") { + t.Errorf("expected marshalling error, got %v", err) + } +} diff --git a/instant/queue.go b/instant/queue.go index 08cf9a3..6c20004 100644 --- a/instant/queue.go +++ b/instant/queue.go @@ -39,7 +39,7 @@ func (c *Client) ProvisionQueue(ctx context.Context, opts *ProvisionOpts) (*Prov } var result ProvisionResult - if err := c.postJSONWithHeaders(ctx, "/queue/new", body, provisionHeaders(opts), &result); err != nil { + if err := c.provisionJSONWithHeaders(ctx, "/queue/new", body, provisionHeaders(opts), &result); err != nil { return nil, fmt.Errorf("ProvisionQueue: %w", err) } if result.Token == "" { diff --git a/instant/storage.go b/instant/storage.go index 1c8af7f..3ee9d27 100644 --- a/instant/storage.go +++ b/instant/storage.go @@ -137,7 +137,7 @@ func (c *Client) ProvisionStorage(ctx context.Context, opts *ProvisionOpts) (*St } var result StorageResult - if err := c.postJSONWithHeaders(ctx, storagePath, body, provisionHeaders(opts), &result); err != nil { + if err := c.provisionJSONWithHeaders(ctx, storagePath, body, provisionHeaders(opts), &result); err != nil { return nil, fmt.Errorf("ProvisionStorage: %w", err) } if result.Token == "" { diff --git a/instant/webhook.go b/instant/webhook.go index 32740d1..ef45699 100644 --- a/instant/webhook.go +++ b/instant/webhook.go @@ -91,7 +91,7 @@ func (c *Client) ProvisionWebhook(ctx context.Context, opts *ProvisionOpts) (*We } var result WebhookResult - if err := c.postJSONWithHeaders(ctx, "/webhook/new", body, provisionHeaders(opts), &result); err != nil { + if err := c.provisionJSONWithHeaders(ctx, "/webhook/new", body, provisionHeaders(opts), &result); err != nil { return nil, fmt.Errorf("ProvisionWebhook: %w", err) } if result.Token == "" {