Skip to content

Commit a71a6e0

Browse files
fix(timeout): 120s provisioning deadline so slow provisions don't 409 (#21)
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 client-wide 30s timeout; when the client gave up, the server kept working and held a 60s in-flight idempotency marker, so the caller's retry hit 409 idempotency_key_in_progress instead of succeeding. Give provisioning + deploy a dedicated HTTP client with no client-wide Timeout cap and a 120s per-request context deadline (provisionContext), comfortably outliving both the slow provision and the server in-flight window. Reads (list/get/claim/delete/rotate) stay capped at 30s via the existing httpClient.Timeout, so the guarded read-path timeout tests are unchanged. WithTimeout now governs both budgets; a tighter caller context deadline is always honoured (provisionContext only lengthens an open-ended context, never overrides a shorter caller deadline). Tests (package instant, run under -race in CI): - TestProvisioningTimeoutDefaults: pins read=30s / provision=120s split + provisionClient.Timeout==0. - TestProvisioningOutlivesReadCap: behavioral proof — a provision succeeds against a server that stalls past the read-path cap while a read against the same server times out (scaled to ms). - TestProvisioningHonorsTighterCallerDeadline / TestWithTimeoutGoverns Provisioning / TestWithHTTPClientTimeoutGovernsProvisioning / TestProvisionContext{OpenEndedGetsBudget,ZeroTimeoutFallsBack} / TestProvisionJSONMarshalError. README (Provisioning + Client configuration) and CHANGELOG document the 120s provisioning default and the WithTimeout override. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fff3fe5 commit a71a6e0

11 files changed

Lines changed: 403 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ existing callers.
99

1010
### Fixed
1111

12+
- **Provisioning + deploy calls now default to a 120 s per-request timeout
13+
(reads stay at 30 s).** Provisioning is synchronous server-side: `POST /db/new`
14+
(and the other `/*/new` endpoints plus `/deploy/new`) blocks while the real
15+
Postgres / Redis / Mongo / NATS / bucket / pod is created. Under prod hot-pool
16+
contention a *fresh* Postgres provision can exceed the old 30 s client timeout;
17+
when the client gave up the server kept working and held a 60 s in-flight
18+
idempotency marker, so the caller's retry hit `409 idempotency_key_in_progress`
19+
instead of succeeding. The provisioning + deploy paths now run on a dedicated
20+
HTTP client with no client-wide cap and a 120 s per-request context deadline,
21+
comfortably outliving both the slow provision and the server's in-flight
22+
window. Read calls (list/get/claim/delete/rotate) are unchanged at 30 s.
23+
`WithTimeout` still overrides both (it now governs the provisioning deadline
24+
too), and a tighter deadline on the caller's `context.Context` is always
25+
honoured — the SDK only lengthens an open-ended context, never overrides a
26+
shorter caller deadline.
1227
- **`Client.Claim` now sends the canonical `token` wire field instead of the
1328
deprecated `jwt` alias.** The api ClaimRequest doc names the Go SDK as one
1429
of three drift sources for the legacy `jwt` name (alongside the dashboard

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,32 @@ q, err := client.ProvisionQueue(ctx, &instant.ProvisionOpts{Name: "app-queue"})
112112
Anonymous resources expire after **24 hours**. Claim them permanently with a free account
113113
(see [Claim](#claim) below or visit the URL in `result.Note`).
114114

115+
### Timeouts: provisioning runs longer than reads
116+
117+
Provisioning is **synchronous**`ProvisionDatabase` / `Cache` / `MongoDB` /
118+
`Queue` / `Storage` / `Webhook` and `Deploy` block while the API creates the
119+
real backend. Under production hot-pool contention a *fresh* Postgres provision
120+
can take **more than 30 seconds**. If the client gave up at 30 s, the server
121+
kept working and held a 60 s in-flight idempotency marker, so the next retry hit
122+
`409 idempotency_key_in_progress` instead of succeeding.
123+
124+
To avoid that, the SDK gives provisioning + deploy calls a **120 s** per-request
125+
deadline by default, while read calls (list, get, claim, delete, rotate) keep
126+
the shorter **30 s** default. You do not need to do anything — the split is
127+
automatic.
128+
129+
Override either with `WithTimeout`, which sets a single budget governing **both**
130+
read and provisioning calls:
131+
132+
```go
133+
// One 90 s budget for every call — set high enough to outlive a slow provision.
134+
client := instant.New(instant.WithTimeout(90 * time.Second))
135+
```
136+
137+
A deadline you set on the `context.Context` you pass in is always honoured if it
138+
is *tighter* than the SDK's provisioning budget — the SDK only lengthens an
139+
open-ended context, it never overrides a shorter caller deadline.
140+
115141
---
116142

117143
## Deploy
@@ -217,7 +243,7 @@ fmt.Println("team_id:", result.TeamID)
217243
client := instant.New(
218244
instant.WithAPIKey("inst_live_..."), // default: INSTANT_API_KEY env var
219245
instant.WithBaseURL("http://localhost:8080"), // default: INSTANT_API_URL or https://api.instanode.dev (port-forward svc/instant-api for local k8s)
220-
instant.WithTimeout(15 * time.Second), // default: 30s
246+
instant.WithTimeout(15 * time.Second), // governs reads AND provisioning; defaults: reads 30s, provisioning 120s
221247
instant.WithHTTPClient(myClient), // custom transport (tracing, TLS, etc.)
222248
instant.WithLogger(slog.Default()), // advisory notices and upgrade prompts
223249
)

instant/cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (c *Client) ProvisionCache(ctx context.Context, opts *ProvisionOpts) (*Prov
3838
}
3939

4040
var result ProvisionResult
41-
if err := c.postJSONWithHeaders(ctx, "/cache/new", body, provisionHeaders(opts), &result); err != nil {
41+
if err := c.provisionJSONWithHeaders(ctx, "/cache/new", body, provisionHeaders(opts), &result); err != nil {
4242
return nil, fmt.Errorf("ProvisionCache: %w", err)
4343
}
4444
if result.Token == "" {

instant/client.go

Lines changed: 162 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,54 @@ const (
4747
// TLS, full OpenAPI surface.
4848
DefaultBaseURL = "https://api.instanode.dev"
4949

50-
// defaultTimeout is applied to every HTTP request.
50+
// defaultTimeout is the per-request deadline applied to read-style calls
51+
// (list, get, claim, delete, rotate). 30 s is plenty for a JSON round trip.
5152
defaultTimeout = 30 * time.Second
53+
54+
// defaultProvisioningTimeout is the per-request deadline applied to the
55+
// synchronous provisioning + deploy endpoints.
56+
//
57+
// Provisioning is synchronous on the server: POST /db/new (and the other
58+
// /*/new endpoints, plus /deploy/new) blocks the handler while the real
59+
// Postgres / Redis / Mongo / NATS / bucket / pod is created. Under prod
60+
// hot-pool contention a *fresh* Postgres provision can exceed 30 s. When
61+
// the client gave up at the read-path 30 s the server kept working and
62+
// held a 60 s in-flight idempotency marker, so the caller's retry hit
63+
// `409 idempotency_key_in_progress` instead of succeeding. A 120 s
64+
// provisioning deadline comfortably outlives both the slow provision and
65+
// the server's in-flight window, so the first call returns the resource
66+
// rather than orphaning it behind a conflicting retry.
67+
//
68+
// Overridable: WithTimeout sets an explicit per-request deadline that
69+
// governs BOTH read and provisioning calls (see [WithTimeout]).
70+
defaultProvisioningTimeout = 120 * time.Second
5271
)
5372

5473
// Client is the instant.dev API client.
5574
// Construct one with [New]; all methods are safe for concurrent use.
5675
type Client struct {
57-
baseURL string
58-
apiKey string
76+
baseURL string
77+
apiKey string
78+
79+
// httpClient governs read-style calls. Its Timeout is the read-path
80+
// per-request deadline (defaultTimeout unless WithTimeout overrides it).
5981
httpClient *http.Client
60-
userAgent string
61-
logger *slog.Logger
82+
83+
// provisionClient governs the synchronous provisioning + deploy calls. It
84+
// shares httpClient's transport (so WithHTTPClient's transport chaining,
85+
// auth header, and User-Agent all apply identically) but carries no
86+
// client-wide Timeout cap — the per-request provisioning deadline is
87+
// enforced via the request context instead. This lets provisioning run for
88+
// up to provisionTimeout even though reads stay capped at the shorter
89+
// read-path Timeout.
90+
provisionClient *http.Client
91+
92+
// provisionTimeout is the per-request deadline applied to provisioning +
93+
// deploy calls (defaultProvisioningTimeout unless WithTimeout overrides it).
94+
provisionTimeout time.Duration
95+
96+
userAgent string
97+
logger *slog.Logger
6298
}
6399

64100
// Option configures a [Client]. Pass options to [New].
@@ -101,9 +137,22 @@ func WithHTTPClient(hc *http.Client) Option {
101137
}
102138
}
103139

104-
// WithTimeout sets the per-request HTTP timeout. Default is 30 s.
140+
// WithTimeout sets the per-request HTTP timeout.
141+
//
142+
// It governs BOTH read-style calls and the synchronous provisioning / deploy
143+
// calls. Without this option reads default to 30 s and provisioning defaults to
144+
// 120 s (synchronous provisioning can exceed 30 s under prod hot-pool
145+
// contention — see [defaultProvisioningTimeout]). Passing WithTimeout collapses
146+
// both onto the single value you supply, so set it high enough to outlive a
147+
// slow provision if you provision through this client:
148+
//
149+
// // give every call — reads and provisioning alike — a 90 s budget
150+
// client := instant.New(instant.WithTimeout(90 * time.Second))
105151
func WithTimeout(d time.Duration) Option {
106-
return func(c *Client) { c.httpClient.Timeout = d }
152+
return func(c *Client) {
153+
c.httpClient.Timeout = d
154+
c.provisionTimeout = d
155+
}
107156
}
108157

109158
// WithLogger sets the structured logger used for advisory notices and upgrade prompts.
@@ -127,6 +176,10 @@ func New(opts ...Option) *Client {
127176
httpClient: &http.Client{
128177
Timeout: defaultTimeout,
129178
},
179+
// 0 is the "not explicitly set" sentinel. WithTimeout overwrites it;
180+
// otherwise it is resolved below to either the default provisioning
181+
// timeout or a caller-supplied WithHTTPClient Timeout.
182+
provisionTimeout: 0,
130183
}
131184

132185
// Environment defaults (explicit options below override these)
@@ -141,6 +194,21 @@ func New(opts ...Option) *Client {
141194
opt(c)
142195
}
143196

197+
// Resolve the provisioning deadline. Precedence:
198+
// 1. WithTimeout — already stamped both httpClient.Timeout and
199+
// provisionTimeout to the caller's explicit value (non-zero here).
200+
// 2. WithHTTPClient with a non-zero Timeout — the caller set a deliberate
201+
// per-request budget on their own client; honour it for provisioning
202+
// too rather than silently widening it to 120 s.
203+
// 3. Neither — provisioning gets the 120 s default while reads keep 30 s.
204+
if c.provisionTimeout == 0 {
205+
if c.httpClient.Timeout != defaultTimeout && c.httpClient.Timeout > 0 {
206+
c.provisionTimeout = c.httpClient.Timeout
207+
} else {
208+
c.provisionTimeout = defaultProvisioningTimeout
209+
}
210+
}
211+
144212
// Wire the auth transport on top of whatever the caller supplied. We
145213
// preserve every field on the caller's *http.Client (Timeout,
146214
// CheckRedirect, Jar, Transport) and chain the caller's Transport as the
@@ -154,21 +222,55 @@ func New(opts ...Option) *Client {
154222
if base == nil {
155223
base = http.DefaultTransport
156224
}
157-
wrapped := &http.Client{
225+
sharedTransport := &authTransport{
226+
base: base,
227+
apiKey: c.apiKey,
228+
userAgent: c.userAgent,
229+
}
230+
c.httpClient = &http.Client{
158231
Timeout: c.httpClient.Timeout,
159232
CheckRedirect: c.httpClient.CheckRedirect,
160233
Jar: c.httpClient.Jar,
161-
Transport: &authTransport{
162-
base: base,
163-
apiKey: c.apiKey,
164-
userAgent: c.userAgent,
165-
},
234+
Transport: sharedTransport,
235+
}
236+
237+
// provisionClient shares the same (auth-wrapped) transport but carries NO
238+
// client-wide Timeout. Provisioning deadlines are enforced per-request via
239+
// the request context (see provisionContext) so a slow synchronous
240+
// provision can run for the full provisionTimeout instead of being killed
241+
// at the shorter read-path Timeout. CheckRedirect / Jar are preserved so
242+
// the two clients behave identically apart from the timeout cap.
243+
c.provisionClient = &http.Client{
244+
Timeout: 0,
245+
CheckRedirect: c.httpClient.CheckRedirect,
246+
Jar: c.httpClient.Jar,
247+
Transport: sharedTransport,
166248
}
167-
c.httpClient = wrapped
168249

169250
return c
170251
}
171252

253+
// provisionContext derives a child context carrying the provisioning deadline.
254+
//
255+
// It never *extends* a deadline the caller already set: if ctx already has an
256+
// earlier deadline (the caller passed context.WithTimeout themselves) that
257+
// tighter deadline wins. It only adds the provisioning budget when the caller
258+
// left the context open-ended. The returned cancel func must always be called.
259+
func (c *Client) provisionContext(ctx context.Context) (context.Context, context.CancelFunc) {
260+
d := c.provisionTimeout
261+
if d <= 0 {
262+
d = defaultProvisioningTimeout
263+
}
264+
if existing, ok := ctx.Deadline(); ok {
265+
// Caller set their own deadline; only shorten ours to honour it, never
266+
// override a tighter caller budget.
267+
if remaining := time.Until(existing); remaining <= d {
268+
return context.WithCancel(ctx)
269+
}
270+
}
271+
return context.WithTimeout(ctx, d)
272+
}
273+
172274
// ─── internal HTTP helpers ────────────────────────────────────────────────────
173275

174276
// 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
187289
}
188290

189291
// postJSONWithHeaders is like postJSON but also sets extra request headers.
190-
// Used by the provisioning helpers to forward the optional Idempotency-Key.
292+
// It runs on the read-path client (defaultTimeout). Provisioning helpers must
293+
// use provisionJSONWithHeaders instead so the synchronous provision gets the
294+
// longer provisioning deadline.
191295
func (c *Client) postJSONWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error {
192-
var r io.Reader
193-
if body != nil {
194-
b, err := json.Marshal(body)
195-
if err != nil {
196-
return fmt.Errorf("marshalling request body: %w", err)
197-
}
198-
r = bytes.NewReader(b)
296+
r, err := jsonBodyReader(body)
297+
if err != nil {
298+
return err
199299
}
200300
return c.doWithHeaders(ctx, http.MethodPost, path, r, headers, out)
201301
}
202302

303+
// provisionJSONWithHeaders POSTs a JSON body for a synchronous provisioning
304+
// call. It routes through provisionClient (no client-wide Timeout cap) and
305+
// applies the longer provisioning deadline via the request context, so a slow
306+
// hot-pool provision can complete instead of timing out at the read-path 30 s
307+
// and orphaning the resource behind a 409 idempotency_key_in_progress retry.
308+
func (c *Client) provisionJSONWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error {
309+
r, err := jsonBodyReader(body)
310+
if err != nil {
311+
return err
312+
}
313+
pctx, cancel := c.provisionContext(ctx)
314+
defer cancel()
315+
return c.doWithClient(pctx, c.provisionClient, http.MethodPost, path, r, headers, out)
316+
}
317+
318+
// jsonBodyReader marshals body to a re-readable JSON reader. A nil body yields
319+
// a nil reader (no Content-Type is set downstream in that case).
320+
func jsonBodyReader(body any) (io.Reader, error) {
321+
if body == nil {
322+
return nil, nil
323+
}
324+
b, err := json.Marshal(body)
325+
if err != nil {
326+
return nil, fmt.Errorf("marshalling request body: %w", err)
327+
}
328+
return bytes.NewReader(b), nil
329+
}
330+
203331
// delete executes a DELETE request and decodes the JSON response into out.
204332
func (c *Client) delete(ctx context.Context, path string, out any) error {
205333
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
211339
}
212340

213341
// doWithHeaders is like do but allows the caller to attach extra request
214-
// headers (e.g. Idempotency-Key). headers may be nil.
342+
// headers (e.g. Idempotency-Key). headers may be nil. It runs on the read-path
343+
// httpClient (read-path Timeout).
215344
func (c *Client) doWithHeaders(ctx context.Context, method, path string, body io.Reader, headers map[string]string, out any) error {
345+
return c.doWithClient(ctx, c.httpClient, method, path, body, headers, out)
346+
}
347+
348+
// doWithClient executes an HTTP request on the supplied client, retrying once
349+
// on a transport error or 5xx. It is the shared core behind doWithHeaders
350+
// (read path) and provisionJSONWithHeaders (provisioning path); the only
351+
// difference between the two is the *http.Client (hence the timeout regime)
352+
// and the request context.
353+
func (c *Client) doWithClient(ctx context.Context, hc *http.Client, method, path string, body io.Reader, headers map[string]string, out any) error {
216354
rawURL := c.baseURL + path
217355

218356
// 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
246384
}
247385
}
248386

249-
resp, err := c.httpClient.Do(req)
387+
resp, err := hc.Do(req)
250388
if err != nil {
251389
lastErr = fmt.Errorf("request failed: %w", err)
252390
if attempt == 0 {

instant/database.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (c *Client) ProvisionDatabase(ctx context.Context, opts *ProvisionOpts) (*P
3434
}
3535

3636
var result ProvisionResult
37-
if err := c.postJSONWithHeaders(ctx, "/db/new", body, provisionHeaders(opts), &result); err != nil {
37+
if err := c.provisionJSONWithHeaders(ctx, "/db/new", body, provisionHeaders(opts), &result); err != nil {
3838
return nil, fmt.Errorf("ProvisionDatabase: %w", err)
3939
}
4040
if result.Token == "" {

instant/deploy.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,16 @@ func (c *Client) Deploy(ctx context.Context, opts DeployOpts) (*Deployment, erro
171171
return nil, fmt.Errorf("instant: closing multipart writer: %w", err)
172172
}
173173

174+
// Deploy is a synchronous create like the /*/new provisioning endpoints:
175+
// the API blocks while the Kaniko build is kicked off and the row is
176+
// written. Use the provisioning client (no 30 s read-path cap) plus the
177+
// longer provisioning deadline so a slow accept under load doesn't time out
178+
// client-side and strand the build behind a 409 idempotency conflict.
179+
pctx, cancel := c.provisionContext(ctx)
180+
defer cancel()
181+
174182
url := c.baseURL + "/deploy/new"
175-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
183+
req, err := http.NewRequestWithContext(pctx, http.MethodPost, url, &buf)
176184
if err != nil {
177185
return nil, fmt.Errorf("instant: building deploy request: %w", err)
178186
}
@@ -181,7 +189,7 @@ func (c *Client) Deploy(ctx context.Context, opts DeployOpts) (*Deployment, erro
181189
req.Header.Set("Idempotency-Key", opts.IdempotencyKey)
182190
}
183191

184-
resp, err := c.httpClient.Do(req)
192+
resp, err := c.provisionClient.Do(req)
185193
if err != nil {
186194
return nil, fmt.Errorf("instant: deploy request failed: %w", err)
187195
}

instant/mongodb.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (c *Client) ProvisionMongoDB(ctx context.Context, opts *ProvisionOpts) (*Pr
3434
}
3535

3636
var result ProvisionResult
37-
if err := c.postJSONWithHeaders(ctx, "/nosql/new", body, provisionHeaders(opts), &result); err != nil {
37+
if err := c.provisionJSONWithHeaders(ctx, "/nosql/new", body, provisionHeaders(opts), &result); err != nil {
3838
return nil, fmt.Errorf("ProvisionMongoDB: %w", err)
3939
}
4040
if result.Token == "" {

0 commit comments

Comments
 (0)