Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ var (
silent bool
timeout time.Duration
maxTime time.Duration
maxLatency string
followRedirects bool
maxRedirects int
maxPages int
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -288,6 +290,7 @@ func snapshotConfig() config.Config {
Silent: silent,
Timeout: timeout,
MaxTime: maxTime,
MaxLatency: maxLatency,
FollowRedirects: followRedirects,
MaxRedirects: maxRedirects,
MaxPages: maxPages,
Expand Down
1 change: 1 addition & 0 deletions cli/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Config struct {
Silent bool
Timeout time.Duration
MaxTime time.Duration
MaxLatency string
FollowRedirects bool
MaxRedirects int
MaxPages int
Expand Down
53 changes: 53 additions & 0 deletions cli/src/internal/service/maxlatency.go
Original file line number Diff line number Diff line change
@@ -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
}
118 changes: 118 additions & 0 deletions cli/src/internal/service/maxlatency_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
15 changes: 15 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const base = import.meta.env.BASE_URL;
<tr><td><code>--json-field-raw</code></td><td></td><td>string[]</td><td>[]</td><td>Add a raw JSON field to a JSON request body (repeatable). Format: <code>key:=json</code>. Dotted keys nest.</td></tr>
<tr><td><code>--timeout</code></td><td><code>-t</code></td><td>duration</td><td>30s</td><td>Request timeout for a single attempt. Examples: <code>30s</code>, <code>5m</code>, <code>1h</code></td></tr>
<tr><td><code>--max-time</code></td><td></td><td>duration</td><td>0</td><td>Overall time budget across retries and pagination. <code>0</code> disables the limit.</td></tr>
<tr><td><code>--max-latency</code></td><td></td><td>duration</td><td>""</td><td>Fail with exit code <code>28</code> when a completed response took longer than this duration (e.g. <code>500ms</code>, <code>2s</code>). The body is still printed. Empty disables the check.</td></tr>
<tr><td><code>--insecure</code></td><td><code>-k</code></td><td>bool</td><td>false</td><td>Skip TLS certificate verification</td></tr>
</tbody>
</table>
Expand Down Expand Up @@ -311,6 +312,7 @@ azd rest get https://api.example.com/resource --retry 0`} language="bash" />
<tr><td><code>1</code></td><td>Request failed (network error, invalid response)</td></tr>
<tr><td><code>2</code></td><td>Invalid arguments or configuration</td></tr>
<tr><td><code>22</code></td><td>Response status was 400 or higher and <code>--fail</code> was set</td></tr>
<tr><td><code>28</code></td><td>Response completed but took longer than the <code>--max-latency</code> budget</td></tr>
</tbody>
</table>
</div>
Expand Down
Loading