diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..1d40cbf 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -48,6 +48,7 @@ var ( silent bool timeout time.Duration maxTime time.Duration + maxLatency string followRedirects bool maxRedirects int maxPages int @@ -213,6 +214,7 @@ Examples: rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "Suppress non-error diagnostic messages on stderr (warnings and notices)") rootCmd.PersistentFlags().DurationVarP(&timeout, "timeout", "t", defaults.Timeout, "Request timeout") rootCmd.PersistentFlags().DurationVar(&maxTime, "max-time", defaults.MaxTime, "Overall time budget across retries and pagination (0 disables the limit)") + rootCmd.PersistentFlags().StringVar(&maxLatency, "max-latency", "", "Fail with exit code 28 when a completed response took longer than this duration (e.g. 500ms, 2s). The body is still printed. Empty disables the check.") rootCmd.PersistentFlags().BoolVar(&followRedirects, "follow-redirects", defaults.FollowRedirects, "Follow HTTP redirects") rootCmd.PersistentFlags().IntVar(&maxRedirects, "max-redirects", defaults.MaxRedirects, "Maximum redirect hops") rootCmd.PersistentFlags().IntVar(&maxPages, "max-pages", defaults.MaxPages, "Maximum number of pages to fetch when paginating") @@ -288,6 +290,7 @@ func snapshotConfig() config.Config { Silent: silent, Timeout: timeout, MaxTime: maxTime, + MaxLatency: maxLatency, FollowRedirects: followRedirects, MaxRedirects: maxRedirects, MaxPages: maxPages, diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..b09096c 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -34,6 +34,7 @@ type Config struct { Silent bool Timeout time.Duration MaxTime time.Duration + MaxLatency string FollowRedirects bool MaxRedirects int MaxPages int diff --git a/cli/src/internal/service/maxlatency.go b/cli/src/internal/service/maxlatency.go new file mode 100644 index 0000000..75c9ffe --- /dev/null +++ b/cli/src/internal/service/maxlatency.go @@ -0,0 +1,53 @@ +package service + +import ( + "fmt" + "time" +) + +// maxLatencyExitCode is the process exit code returned when a response finishes +// but takes longer than the --max-latency budget. It matches curl's operation +// timeout exit code (28) so scripts can treat a slow response like a timeout. +const maxLatencyExitCode = 28 + +// maxLatencyConfigError signals that the --max-latency value could not be +// parsed as a positive duration. It reports exit code 2 (invalid configuration) +// through the ExitCoder contract so main exits before any request is made. +type maxLatencyConfigError struct{ value string } + +func (e *maxLatencyConfigError) Error() string { + return fmt.Sprintf("invalid --max-latency %q: use a positive duration such as 500ms or 2s", e.value) +} + +// ExitCode returns 2 for an invalid --max-latency value. +func (e *maxLatencyConfigError) ExitCode() int { return 2 } + +// maxLatencyExceededError signals that a response completed but its total time +// exceeded the --max-latency budget. The body is written before this error is +// returned, so it only changes the exit code. It reports exit code 28. +type maxLatencyExceededError struct { + budget time.Duration + actual time.Duration +} + +func (e *maxLatencyExceededError) Error() string { + return fmt.Sprintf("response took %s, over the --max-latency budget of %s", e.actual, e.budget) +} + +// ExitCode returns 28 for a response that overran the latency budget. +func (e *maxLatencyExceededError) ExitCode() int { return maxLatencyExitCode } + +// parseMaxLatency turns the raw --max-latency flag value into a duration budget. +// An empty value means the check is disabled and returns a zero budget. A value +// that does not parse, or that is zero or negative, is rejected so a typo never +// silently turns the gate off. +func parseMaxLatency(value string) (time.Duration, error) { + if value == "" { + return 0, nil + } + budget, err := time.ParseDuration(value) + if err != nil || budget <= 0 { + return 0, &maxLatencyConfigError{value: value} + } + return budget, nil +} diff --git a/cli/src/internal/service/maxlatency_test.go b/cli/src/internal/service/maxlatency_test.go new file mode 100644 index 0000000..58d336d --- /dev/null +++ b/cli/src/internal/service/maxlatency_test.go @@ -0,0 +1,118 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jongio/azd-rest/src/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// maxLatencyCoder mirrors the structural exit-code contract used by main so the +// tests can assert the exit code without importing the cmd package. +type maxLatencyCoder interface{ ExitCode() int } + +func TestParseMaxLatency(t *testing.T) { + tests := []struct { + name string + value string + want time.Duration + wantErr bool + }{ + {name: "empty disables", value: "", want: 0}, + {name: "milliseconds", value: "500ms", want: 500 * time.Millisecond}, + {name: "seconds", value: "2s", want: 2 * time.Second}, + {name: "unparseable", value: "later", wantErr: true}, + {name: "zero rejected", value: "0s", wantErr: true}, + {name: "negative rejected", value: "-1s", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMaxLatency(tt.value) + if tt.wantErr { + require.Error(t, err) + var coder maxLatencyCoder + require.True(t, errors.As(err, &coder), "config error should carry an exit code") + assert.Equal(t, 2, coder.ExitCode()) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExecute_MaxLatency_SlowResponseExits28(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(40 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.MaxLatency = "5ms" + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/slow") + require.Error(t, err) + + var coder maxLatencyCoder + require.True(t, errors.As(err, &coder), "slow response should carry an exit code") + assert.Equal(t, 28, coder.ExitCode()) + + // The body is written before the error is returned. + out, readErr := os.ReadFile(tmp) + require.NoError(t, readErr) + assert.Contains(t, string(out), "ok") +} + +func TestExecute_MaxLatency_FastResponseSucceeds(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = filepath.Join(t.TempDir(), "out.json") + cfg.MaxLatency = "1m" + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/fast") + require.NoError(t, err) +} + +func TestExecute_MaxLatency_InvalidExitsBeforeRequest(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = filepath.Join(t.TempDir(), "out.json") + cfg.MaxLatency = "nope" + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/never") + require.Error(t, err) + + var coder maxLatencyCoder + require.True(t, errors.As(err, &coder), "invalid value should carry an exit code") + assert.Equal(t, 2, coder.ExitCode()) + assert.False(t, called, "no request should be made when the budget is invalid") +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..8f48741 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -397,6 +397,13 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, return &rawOutputUsageError{msg: "--raw-output requires --query"} } + // --max-latency (#280): parse the budget up front so an invalid value exits + // with code 2 before any network call is made. A zero budget disables it. + maxLatencyBudget, err := parseMaxLatency(cfg.MaxLatency) + if err != nil { + return err + } + // Echo the correlation ID so it can be quoted in an Azure support request. if cfg.ClientRequestID != "" { fmt.Fprintf(os.Stderr, "%s: %s\n", clientRequestIDHeader, cfg.ClientRequestID) @@ -466,6 +473,14 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, return &httpFailError{status: resp.StatusCode} } + // --max-latency (#280): the response has been written, so only the exit code + // changes. A request slower than the budget exits 28, letting CI gate on + // performance without aborting the request mid-flight. + if maxLatencyBudget > 0 && resp.Duration > maxLatencyBudget { + writeDiagnostic(os.Stderr, cfg.Silent, "> response took %s, over the --max-latency budget of %s\n", resp.Duration, maxLatencyBudget) + return &maxLatencyExceededError{budget: maxLatencyBudget, actual: resp.Duration} + } + return nil } diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..0c5318b 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -83,6 +83,7 @@ const base = import.meta.env.BASE_URL;
--json-field-rawkey:=json. Dotted keys nest.--timeout-t30s, 5m, 1h--max-time0 disables the limit.--max-latency28 when a completed response took longer than this duration (e.g. 500ms, 2s). The body is still printed. Empty disables the check.--insecure-k1222--fail was set28--max-latency budget