Skip to content

Commit b4adc40

Browse files
DinanathDashRawJat
andauthored
Add timeout flag to CLI commands for improved resilience (#194)
This pull request implements robust, configurable network retry logic for the Envault CLI, improving resilience to transient API/network failures. It introduces a unified `--timeout` flag (and `ENVAULT_RETRY_MAX_DURATION` env var) for `pull` and `run` commands, which controls the maximum duration for retrying failed API requests. The retry logic uses exponential backoff with jitter and is thoroughly tested for various scenarios. **Network Resilience & Retry Logic:** * Adds exponential backoff with jitter for retrying transient network and server (5xx) errors in the API client, up to a configurable maximum duration (`ENVAULT_RETRY_MAX_DURATION`, default 5 minutes). * Refactors API client to respect context cancellation and to avoid retrying on 4xx errors (except for 401, which triggers a token refresh). **CLI Command Improvements:** * Adds a `--timeout` flag to both `pull` and `run` commands, allowing users to override the maximum retry duration from the command line. * Both commands now use a unified timeout resolver, defaulting to 5 minutes if not set by flag or env var. **Testing & Documentation:** * Adds comprehensive tests for retry logic, including network errors, 5xx/4xx handling, token refresh, and timeout configuration. * Updates CLI documentation for `pull` and `run` commands, and adds a new section on network resilience and timeouts. --- Co-authored-by: Rajat Patra <113469515+RawJat@users.noreply.github.com>
2 parents 81038e7 + b2d764c commit b4adc40

9 files changed

Lines changed: 467 additions & 59 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ cli-go/envault
5454
cli-go/envault.json
5555
cli-go/out.txt
5656
cli-go/.env
57+
cli-go/.gocache
5758

5859
# Generated source files (fumadocs-mdx)
5960
.source/

cli-go/cmd/network_timeout.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"strings"
6+
"time"
7+
)
8+
9+
const defaultCLINetworkTimeout = 5 * time.Minute
10+
11+
func resolveCLINetworkTimeout(flagTimeout time.Duration) time.Duration {
12+
if flagTimeout > 0 {
13+
return flagTimeout
14+
}
15+
16+
raw := strings.TrimSpace(os.Getenv("ENVAULT_RETRY_MAX_DURATION"))
17+
if raw == "" {
18+
return defaultCLINetworkTimeout
19+
}
20+
21+
d, err := time.ParseDuration(raw)
22+
if err != nil || d <= 0 {
23+
return defaultCLINetworkTimeout
24+
}
25+
26+
return d
27+
}

cli-go/cmd/pull.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212
"strings"
1313
"syscall"
14+
"time"
1415

1516
"github.com/AlecAivazis/survey/v2"
1617
"github.com/DinanathDash/Envault/cli-go/internal/api"
@@ -40,14 +41,15 @@ type ProjectResponse struct {
4041
var forcePull bool
4142
var projectFlag string
4243
var fileFlag string
44+
var pullTimeoutFlag time.Duration
4345

4446
var pullCmd = &cobra.Command{
4547
Use: "pull",
4648
Short: "Fetch secrets and write to .env",
4749
Run: func(cmd *cobra.Command, args []string) {
4850
// Graceful cancellation: cancel the context on Ctrl+C / SIGTERM so that
4951
// in-flight HTTP requests are aborted cleanly.
50-
ctx, cancel := context.WithCancel(context.Background())
52+
ctx, cancel := context.WithTimeout(context.Background(), resolveCLINetworkTimeout(pullTimeoutFlag))
5153
defer cancel()
5254
sigCh := make(chan os.Signal, 1)
5355
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
@@ -335,4 +337,5 @@ func init() {
335337
pullCmd.Flags().BoolVarP(&forcePull, "force", "f", false, "Overwrite .env without confirmation")
336338
pullCmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project ID")
337339
pullCmd.Flags().StringVar(&fileFlag, "file", "", "Local .env file path override")
340+
pullCmd.Flags().DurationVar(&pullTimeoutFlag, "timeout", 0, "Maximum duration for API retry/wait behavior (e.g. 30s, 2m). Overrides ENVAULT_RETRY_MAX_DURATION.")
338341
}

cli-go/cmd/run.go

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package cmd
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/url"
78
"os"
89
"os/exec"
910
"runtime"
10-
"strconv"
1111
"strings"
1212
"time"
1313

@@ -18,6 +18,8 @@ import (
1818
"github.com/spf13/cobra"
1919
)
2020

21+
var runTimeoutFlag time.Duration
22+
2123
var runCmd = &cobra.Command{
2224
Use: "run -- <command>",
2325
Short: "Run a command with secrets injected from Envault",
@@ -50,12 +52,14 @@ var runCmd = &cobra.Command{
5052
}
5153
client := api.NewClient()
5254
path := fmt.Sprintf("/projects/%s/secrets?environment=%s", projectID, url.QueryEscape(targetEnv))
55+
ctx, cancel := context.WithTimeout(context.Background(), resolveCLINetworkTimeout(runTimeoutFlag))
56+
defer cancel()
5357

5458
var secretsResp SecretsResponse
5559
var envSecrets []offlinecache.Secret
5660
loader := ui.NewLoader(ui.LoaderThemeFetch, fmt.Sprintf("VaultPulse preparing runtime secrets (%s)...", targetEnv))
5761
loader.Start()
58-
respBytes, err := client.GetWithTimeout(path, resolveRunTimeout(client.BaseURL))
62+
respBytes, err := client.GetWithContext(ctx, path)
5963
loader.Stop()
6064

6165
usedOfflineCache := false
@@ -165,25 +169,6 @@ func humanizeDuration(d time.Duration) string {
165169
return d.Round(time.Minute).String()
166170
}
167171

168-
func resolveRunTimeout(baseURL string) time.Duration {
169-
defaultSeconds := 10
170-
if isLocalBaseURL(baseURL) {
171-
defaultSeconds = 20
172-
}
173-
174-
raw := strings.TrimSpace(os.Getenv("ENVAULT_RUN_TIMEOUT_SECONDS"))
175-
if raw == "" {
176-
return time.Duration(defaultSeconds) * time.Second
177-
}
178-
179-
seconds, err := strconv.Atoi(raw)
180-
if err != nil || seconds <= 0 {
181-
return time.Duration(defaultSeconds) * time.Second
182-
}
183-
184-
return time.Duration(seconds) * time.Second
185-
}
186-
187172
func isLocalBaseURL(baseURL string) bool {
188173
u, err := url.Parse(baseURL)
189174
if err != nil {
@@ -221,4 +206,5 @@ func isLikelyDevCommand(runTarget string, runArgs []string) bool {
221206
func init() {
222207
rootCmd.AddCommand(runCmd)
223208
runCmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project ID")
209+
runCmd.Flags().DurationVar(&runTimeoutFlag, "timeout", 0, "Maximum duration for API retry/wait behavior (e.g. 30s, 2m). Overrides ENVAULT_RETRY_MAX_DURATION.")
224210
}

cli-go/internal/api/client.go

Lines changed: 171 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"log"
11+
"math/rand"
1112
"net"
1213
"net/http"
1314
"net/url"
@@ -34,6 +35,15 @@ type Client struct {
3435
HTTP *http.Client
3536
}
3637

38+
var (
39+
defaultRetryMaxDuration = 5 * time.Minute
40+
retryBaseBackoff = 2 * time.Second
41+
retryMultiplier = 2.0
42+
sleepWithContextFn = sleepWithContext
43+
refreshTokenFn = func(c *Client, httpClient *http.Client) error { return c.refreshToken(httpClient) }
44+
nowFn = time.Now
45+
)
46+
3747
func NewClient() *Client {
3848
baseURL := os.Getenv("ENVAULT_CLI_URL")
3949
if baseURL == "" {
@@ -189,62 +199,187 @@ func (c *Client) doReqCtx(ctx context.Context, method, path string, body interfa
189199
httpClient = &http.Client{}
190200
}
191201

192-
var bodyReader io.Reader
193-
202+
var reqBody []byte
203+
var err error
194204
if body != nil {
195-
reqBody, err := json.Marshal(body)
205+
reqBody, err = json.Marshal(body)
196206
if err != nil {
197207
return nil, err
198208
}
199-
bodyReader = bytes.NewBuffer(reqBody)
200209
}
201210

202-
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader)
203-
if err != nil {
204-
return nil, err
211+
start := nowFn()
212+
attempt := 1
213+
tokenRefreshTried := false
214+
maxDuration := resolveRetryMaxDuration()
215+
216+
for {
217+
var bodyReader io.Reader
218+
if reqBody != nil {
219+
bodyReader = bytes.NewReader(reqBody)
220+
}
221+
222+
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader)
223+
if err != nil {
224+
return nil, err
225+
}
226+
227+
req.Header.Set("Content-Type", "application/json")
228+
if c.Token != "" {
229+
req.Header.Set("Authorization", "Bearer "+c.Token)
230+
}
231+
if actorSource := strings.TrimSpace(os.Getenv("ENVAULT_CLI_ACTOR_SOURCE")); actorSource != "" {
232+
req.Header.Set("X-Envault-Actor-Source", actorSource)
233+
}
234+
235+
resp, reqErr := httpClient.Do(req)
236+
if reqErr != nil {
237+
if !canRetry || !isRetryableRequestError(reqErr) {
238+
return nil, reqErr
239+
}
240+
wait, ok := computeRetryWait(start, attempt, maxDuration)
241+
if !ok {
242+
return nil, reqErr
243+
}
244+
logRetryWarning(method, path, attempt, reqErr, wait)
245+
if err := sleepWithContextFn(ctx, wait); err != nil {
246+
return nil, err
247+
}
248+
attempt++
249+
continue
250+
}
251+
252+
bodyBytes, readErr := io.ReadAll(resp.Body)
253+
_ = resp.Body.Close()
254+
if readErr != nil {
255+
return nil, readErr
256+
}
257+
258+
if resp.StatusCode == 401 && canRetry && !tokenRefreshTried {
259+
if c.Token != "" && !strings.HasPrefix(c.Token, "envault_svc_") {
260+
errRefresh := refreshTokenFn(c, httpClient)
261+
if errRefresh == nil {
262+
tokenRefreshTried = true
263+
continue
264+
}
265+
return nil, fmt.Errorf("Refresh Token Exchange Failed: %v | (Original Auth Error: %s)", errRefresh, string(bodyBytes))
266+
}
267+
}
268+
269+
if isRetryableStatusCode(resp.StatusCode) && canRetry {
270+
wait, ok := computeRetryWait(start, attempt, maxDuration)
271+
if !ok {
272+
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(bodyBytes)}
273+
}
274+
logRetryWarning(method, path, attempt, fmt.Errorf("server returned %s", resp.Status), wait)
275+
if err := sleepWithContextFn(ctx, wait); err != nil {
276+
return nil, err
277+
}
278+
attempt++
279+
continue
280+
}
281+
282+
if resp.StatusCode >= 400 {
283+
bodyStr := string(bodyBytes)
284+
contentType := resp.Header.Get("Content-Type")
285+
if strings.Contains(contentType, "text/html") {
286+
bodyStr = "Server returned an HTML page (" + resp.Status + "). Ensure the API server is running."
287+
}
288+
return nil, &APIError{StatusCode: resp.StatusCode, Body: bodyStr}
289+
}
290+
291+
contentType := resp.Header.Get("Content-Type")
292+
if strings.Contains(contentType, "text/html") {
293+
return nil, &APIError{StatusCode: resp.StatusCode, Body: "Server returned HTML instead of expected JSON API response. Ensure the API server is running."}
294+
}
295+
296+
return bodyBytes, nil
205297
}
298+
}
206299

207-
req.Header.Set("Content-Type", "application/json")
208-
if c.Token != "" {
209-
req.Header.Set("Authorization", "Bearer "+c.Token)
300+
func isRetryableStatusCode(statusCode int) bool {
301+
return statusCode >= 500 && statusCode <= 599
302+
}
303+
304+
func isRetryableRequestError(err error) bool {
305+
if err == nil {
306+
return false
210307
}
211-
if actorSource := strings.TrimSpace(os.Getenv("ENVAULT_CLI_ACTOR_SOURCE")); actorSource != "" {
212-
req.Header.Set("X-Envault-Actor-Source", actorSource)
308+
if errors.Is(err, context.Canceled) {
309+
return false
213310
}
311+
return IsFallbackEligible(err)
312+
}
214313

215-
resp, err := httpClient.Do(req)
216-
if err != nil {
217-
return nil, err
314+
func computeRetryWait(start time.Time, attempt int, maxDuration time.Duration) (time.Duration, bool) {
315+
if attempt < 1 {
316+
attempt = 1
218317
}
219-
defer resp.Body.Close()
220-
221-
if resp.StatusCode == 401 && canRetry {
222-
if c.Token != "" && !strings.HasPrefix(c.Token, "envault_svc_") {
223-
bodyBytes, _ := io.ReadAll(resp.Body)
224-
errRefresh := c.refreshToken(httpClient)
225-
if errRefresh == nil {
226-
return c.doReqCtx(ctx, method, path, body, false, httpClient)
227-
}
228-
return nil, fmt.Errorf("Refresh Token Exchange Failed: %v | (Original Auth Error: %s)", errRefresh, string(bodyBytes))
318+
exp := retryBaseBackoff
319+
for i := 1; i < attempt; i++ {
320+
next := time.Duration(float64(exp) * retryMultiplier)
321+
if next <= exp {
322+
break
229323
}
324+
exp = next
325+
}
326+
wait := jitterDuration(exp)
327+
if wait <= 0 {
328+
wait = retryBaseBackoff
230329
}
231330

232-
if resp.StatusCode >= 400 {
233-
bodyBytes, _ := io.ReadAll(resp.Body)
234-
bodyStr := string(bodyBytes)
235-
contentType := resp.Header.Get("Content-Type")
236-
if strings.Contains(contentType, "text/html") {
237-
bodyStr = "Server returned an HTML page (" + resp.Status + "). Ensure the API server is running."
331+
elapsed := nowFn().Sub(start)
332+
if elapsed >= maxDuration {
333+
return 0, false
334+
}
335+
if elapsed+wait > maxDuration {
336+
remaining := maxDuration - elapsed
337+
if remaining > time.Second {
338+
wait = remaining
339+
return wait, true
238340
}
239-
return nil, &APIError{StatusCode: resp.StatusCode, Body: bodyStr}
341+
return 0, false
342+
}
343+
return wait, true
344+
}
345+
346+
func resolveRetryMaxDuration() time.Duration {
347+
raw := strings.TrimSpace(os.Getenv("ENVAULT_RETRY_MAX_DURATION"))
348+
if raw == "" {
349+
return defaultRetryMaxDuration
240350
}
241351

242-
contentType := resp.Header.Get("Content-Type")
243-
if strings.Contains(contentType, "text/html") {
244-
return nil, &APIError{StatusCode: resp.StatusCode, Body: "Server returned HTML instead of expected JSON API response. Ensure the API server is running."}
352+
d, err := time.ParseDuration(raw)
353+
if err != nil || d <= 0 {
354+
return defaultRetryMaxDuration
245355
}
246356

247-
return io.ReadAll(resp.Body)
357+
return d
358+
}
359+
360+
func jitterDuration(base time.Duration) time.Duration {
361+
// Full-jitter style randomization in [base/2, base) to spread retries.
362+
half := base / 2
363+
if half <= 0 {
364+
return base
365+
}
366+
return half + time.Duration(rand.Int63n(int64(half)))
367+
}
368+
369+
func logRetryWarning(method, path string, attempt int, cause error, wait time.Duration) {
370+
fmt.Fprintf(os.Stderr, "Warning: Envault API transient failure (%s %s, attempt %d). Retrying in %s. Cause: %v\n",
371+
method, path, attempt, wait.Round(time.Millisecond), cause)
372+
}
373+
374+
func sleepWithContext(ctx context.Context, d time.Duration) error {
375+
timer := time.NewTimer(d)
376+
defer timer.Stop()
377+
select {
378+
case <-ctx.Done():
379+
return ctx.Err()
380+
case <-timer.C:
381+
return nil
382+
}
248383
}
249384

250385
func IsFallbackEligible(err error) bool {

0 commit comments

Comments
 (0)