@@ -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.
5675type 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))
105151func 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.
191295func (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.
204332func (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).
215344func (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 {
0 commit comments