Skip to content
Closed
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
16 changes: 10 additions & 6 deletions cmd/relayfile-cli/commandspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,10 @@ func relayfileCommands() []cliCommandSpec {
{Flags: "--reason <text>", Description: "free-form reason recorded server-side", DefaultValue: "manual"},
serverFlagOption,
tokenFlagOption,
{Flags: "--no-retry", Description: "do not retry transient provider-status read failures", DefaultValue: false},
},
dispatch: func(inv cliInvocation) error {
return runPull(inv.args, inv.stdout)
return runPull(inv.args, inv.stdout, inv.stderr)
},
},
{
Expand Down Expand Up @@ -680,9 +681,10 @@ func relayfileCommands() []cliCommandSpec {
{Flags: "--path <path>", Description: "remote path to list", DefaultValue: "/"},
{Flags: "--depth <n>", Description: "tree depth", DefaultValue: 1},
{Flags: "--json", Description: "print the raw JSON response", DefaultValue: false},
{Flags: "--no-retry", Description: "do not retry transient read failures", DefaultValue: false},
},
dispatch: func(inv cliInvocation) error {
return runTree(inv.args, inv.stdout)
return runTree(inv.args, inv.stdout, inv.stderr)
},
},
{
Expand All @@ -703,9 +705,10 @@ func relayfileCommands() []cliCommandSpec {
tokenFlagOption,
{Flags: "--output <file>", Description: "output file path or - for stdout", DefaultValue: "-"},
{Flags: "--json", Description: "print the raw JSON response", DefaultValue: false},
{Flags: "--no-retry", Description: "do not retry transient read failures", DefaultValue: false},
},
dispatch: func(inv cliInvocation) error {
return runRead(inv.args, inv.stdout)
return runRead(inv.args, inv.stdout, inv.stderr)
},
},
{
Expand All @@ -731,19 +734,20 @@ func relayfileCommands() []cliCommandSpec {
tokenFlagOption,
{Flags: "--format <format>", Description: "export format: tar, json, or patch", DefaultValue: "json"},
{Flags: "--output <file>", Description: "output file path or - for stdout", DefaultValue: "-"},
{Flags: "--no-retry", Description: "do not retry transient read failures", DefaultValue: false},
},
dispatch: func(inv cliInvocation) error {
return runExport(inv.args, inv.stdout)
return runExport(inv.args, inv.stdout, inv.stderr)
},
},
{
Name: "status",
Description: "Show sync status and local mirror state for a workspace",
flagSource: "runStatus",
Args: []cliArgSpec{workspaceArg},
Options: []cliOptionSpec{serverFlagOption, tokenFlagOption, jsonFlagOption},
Options: []cliOptionSpec{serverFlagOption, tokenFlagOption, jsonFlagOption, {Flags: "--no-retry", Description: "do not retry transient read failures", DefaultValue: false}},
dispatch: func(inv cliInvocation) error {
return runStatus(inv.args, inv.stdout)
return runStatus(inv.args, inv.stdout, inv.stderr)
},
},
{
Expand Down
69 changes: 40 additions & 29 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6311,20 +6311,22 @@ func runOpsReplay(args []string, stdin io.Reader, stdout io.Writer) error {
return nil
}

func runPull(args []string, stdout io.Writer) error {
func runPull(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("pull", flag.ContinueOnError)
fs.SetOutput(io.Discard)
workspaceName := fs.String("workspace", "", "workspace name or id")
provider := fs.String("provider", "", "provider id (default: refresh all connected providers)")
reason := fs.String("reason", "manual", "free-form reason recorded server-side")
server := fs.String("server", "", "relayfile server URL override")
tokenOverride := fs.String("token", "", "relayfile token override")
noRetry := fs.Bool("no-retry", false, "do not retry transient provider-status read failures")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"workspace": true,
"provider": true,
"reason": true,
"server": true,
"token": true,
"no-retry": false,
})); err != nil {
return err
}
Expand All @@ -6337,7 +6339,7 @@ func runPull(args []string, stdout io.Writer) error {
return err
}

providers, err := resolvePullProviders(commandClient, strings.TrimSpace(*provider))
providers, err := resolvePullProviders(commandClient, strings.TrimSpace(*provider), defaultOneShotGETRetryPolicy(!*noRetry, stderr))
if err != nil {
return err
}
Expand Down Expand Up @@ -6370,12 +6372,12 @@ func runPull(args []string, stdout io.Writer) error {
return nil
}

func resolvePullProviders(commandClient *workspaceCommandClient, requested string) ([]string, error) {
func resolvePullProviders(commandClient *workspaceCommandClient, requested string, retryPolicy oneShotGETRetryPolicy) ([]string, error) {
if requested != "" {
return []string{normalizeProviderID(requested)}, nil
}
var status syncStatusResponse
err := commandClient.getWorkspaceJSON(context.Background(), func(workspaceID string) string {
err := retryPolicy.getWorkspaceJSON(context.Background(), commandClient, func(workspaceID string) string {
return fmt.Sprintf("/v1/workspaces/%s/sync/status", url.PathEscape(workspaceID))
}, &status)
if err != nil {
Expand Down Expand Up @@ -9402,20 +9404,22 @@ func plistEscapeXML(s string) string {
return s
}

func runTree(args []string, stdout io.Writer) error {
func runTree(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("tree", flag.ContinueOnError)
fs.SetOutput(io.Discard)
server := fs.String("server", "", "relayfile server URL override")
token := fs.String("token", "", "relayfile token override")
pathFlag := fs.String("path", "/", "remote path to list")
depth := fs.Int("depth", 1, "tree depth")
jsonOutput := fs.Bool("json", false, "print the raw JSON response")
noRetry := fs.Bool("no-retry", false, "do not retry transient read failures")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"server": true,
"token": true,
"path": true,
"depth": true,
"json": false,
"server": true,
"token": true,
"path": true,
"depth": true,
"json": false,
"no-retry": false,
})); err != nil {
return err
}
Expand Down Expand Up @@ -9452,7 +9456,7 @@ func runTree(args []string, stdout io.Writer) error {
query := url.Values{}
query.Set("path", remotePath)
query.Set("depth", strconv.Itoa(*depth))
body, _, err := commandClient.getWorkspaceBytes(context.Background(), func(workspaceID string) string {
body, _, err := defaultOneShotGETRetryPolicy(!*noRetry, stderr).getWorkspaceBytes(context.Background(), commandClient, func(workspaceID string) string {
return fmt.Sprintf("/v1/workspaces/%s/fs/tree?%s", url.PathEscape(workspaceID), query.Encode())
})
if err != nil {
Expand Down Expand Up @@ -9552,18 +9556,20 @@ func knownRemoteRootSegment(segment string) bool {
}
}

func runRead(args []string, stdout io.Writer) error {
func runRead(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("read", flag.ContinueOnError)
fs.SetOutput(io.Discard)
server := fs.String("server", "", "relayfile server URL override")
token := fs.String("token", "", "relayfile token override")
output := fs.String("output", "-", "output file path or - for stdout")
jsonOutput := fs.Bool("json", false, "print the raw JSON response")
noRetry := fs.Bool("no-retry", false, "do not retry transient read failures")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"server": true,
"token": true,
"output": true,
"json": false,
"server": true,
"token": true,
"output": true,
"json": false,
"no-retry": false,
})); err != nil {
return err
}
Expand All @@ -9589,7 +9595,7 @@ func runRead(args []string, stdout io.Writer) error {

query := url.Values{}
query.Set("path", remotePath)
body, _, err := commandClient.getWorkspaceBytes(context.Background(), func(workspaceID string) string {
body, _, err := defaultOneShotGETRetryPolicy(!*noRetry, stderr).getWorkspaceBytes(context.Background(), commandClient, func(workspaceID string) string {
return fmt.Sprintf("/v1/workspaces/%s/fs/file?%s", url.PathEscape(workspaceID), query.Encode())
})
if err != nil {
Expand Down Expand Up @@ -9696,18 +9702,20 @@ func runSeed(args []string, stdout io.Writer) error {
return nil
}

func runExport(args []string, stdout io.Writer) error {
func runExport(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("export", flag.ContinueOnError)
fs.SetOutput(io.Discard)
server := fs.String("server", "", "relayfile server URL override")
token := fs.String("token", "", "relayfile token override")
format := fs.String("format", "json", "export format: tar, json, or patch")
output := fs.String("output", "-", "output file path or - for stdout")
noRetry := fs.Bool("no-retry", false, "do not retry transient read failures")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"server": true,
"token": true,
"format": true,
"output": true,
"server": true,
"token": true,
"format": true,
"output": true,
"no-retry": false,
})); err != nil {
return err
}
Expand All @@ -9724,7 +9732,7 @@ func runExport(args []string, stdout io.Writer) error {
return err
}
exportFormat := url.QueryEscape(strings.ToLower(strings.TrimSpace(*format)))
body, _, err := commandClient.getWorkspaceBytes(context.Background(), func(workspaceID string) string {
body, _, err := defaultOneShotGETRetryPolicy(!*noRetry, stderr).getWorkspaceBytes(context.Background(), commandClient, func(workspaceID string) string {
return fmt.Sprintf("/v1/workspaces/%s/fs/export?format=%s", url.PathEscape(workspaceID), exportFormat)
})
if err != nil {
Expand All @@ -9743,16 +9751,18 @@ func runExport(args []string, stdout io.Writer) error {
return os.WriteFile(*output, body, 0o644)
}

func runStatus(args []string, stdout io.Writer) error {
func runStatus(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError)
fs.SetOutput(io.Discard)
server := fs.String("server", "", "relayfile server URL override")
token := fs.String("token", "", "relayfile token override")
jsonOutput := fs.Bool("json", false, "emit JSON")
noRetry := fs.Bool("no-retry", false, "do not retry transient read failures")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"server": true,
"token": true,
"json": false,
"server": true,
"token": true,
"json": false,
"no-retry": false,
})); err != nil {
return err
}
Expand All @@ -9769,7 +9779,8 @@ func runStatus(args []string, stdout io.Writer) error {
return err
}
var status syncStatusResponse
err = commandClient.getWorkspaceJSON(context.Background(), func(workspaceID string) string {
retryPolicy := defaultOneShotGETRetryPolicy(!*noRetry, stderr)
err = retryPolicy.getWorkspaceJSON(context.Background(), commandClient, func(workspaceID string) string {
return fmt.Sprintf("/v1/workspaces/%s/sync/status", url.PathEscape(workspaceID))
}, &status)
if err != nil {
Expand All @@ -9781,7 +9792,7 @@ func runStatus(args []string, stdout io.Writer) error {
var ingress *syncIngressStatusResponse
if statusNeedsIngressDiagnostics(status) {
var ingressStatus syncIngressStatusResponse
if err := commandClient.getWorkspaceJSON(context.Background(), func(workspaceID string) string {
if err := retryPolicy.getWorkspaceJSON(context.Background(), commandClient, func(workspaceID string) string {
return fmt.Sprintf("/v1/workspaces/%s/sync/ingress", url.PathEscape(workspaceID))
}, &ingressStatus); err == nil {
ingress = &ingressStatus
Expand Down
111 changes: 111 additions & 0 deletions cmd/relayfile-cli/one_shot_retry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
)

const (
oneShotGETMaxAttempts = 3
oneShotGETMinDelay = time.Second
oneShotGETMaxDelay = 60 * time.Second
oneShotGETJitter = 0.2
)

type oneShotGETRetryPolicy struct {
enabled bool
stderr io.Writer
opts politeOpts
jitter func(time.Duration, float64) time.Duration
}

func defaultOneShotGETRetryPolicy(enabled bool, stderr io.Writer) oneShotGETRetryPolicy {
return oneShotGETRetryPolicy{
enabled: enabled,
stderr: stderr,
opts: politeOpts{
minInterval: oneShotGETMinDelay,
maxInterval: oneShotGETMaxDelay,
jitterFraction: oneShotGETJitter,
hardMaxPerSecond: 1,
},
}
}

func (p oneShotGETRetryPolicy) run(ctx context.Context, get func(context.Context) error) error {
if !p.enabled {
return get(ctx)
}

opts := p.opts.withDefaults()
jitter := p.jitter
if jitter == nil {
jitter = applyJitter
}
for attempt := 1; ; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
err := get(ctx)
if err == nil || attempt >= oneShotGETMaxAttempts || !isRetryableOneShotGETError(err) {
return err
}

var apiErr *apiError
_ = errors.As(err, &apiErr)
baseDelay := backoffFor(attempt, opts.minInterval, opts.maxInterval)
if apiErr.RetryAfter > 0 {
baseDelay = clampDuration(apiErr.RetryAfter, opts.minInterval, opts.maxInterval)
}
delay := jitter(baseDelay, opts.jitterFraction)
// Retry-After is a lower bound. Symmetric jitter must never make us
// retry before the server's advertised delay has elapsed.
if apiErr.RetryAfter > 0 && delay < baseDelay {
delay = baseDelay
}
delay = clampDuration(delay, time.Millisecond, opts.maxInterval)
if p.stderr != nil {
fmt.Fprintf(p.stderr, "%s, retrying in %s\n", oneShotRetryLabel(err), delay.Round(time.Millisecond))
}
if err := opts.sleep(ctx, delay); err != nil {
return err
}
}
}

func isRetryableOneShotGETError(err error) bool {
var apiErr *apiError
return errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode == http.StatusServiceUnavailable)
}

func oneShotRetryLabel(err error) string {
var apiErr *apiError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests && apiErr.Code == "workspace_busy" {
return "workspace busy"
}
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests {
return "rate limited"
}
return "service unavailable"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rate limits mislabeled as outages

When a 429 rate_limited response triggers a retry, oneShotRetryLabel prints “service unavailable.” Users see an outage notice instead of the rate limit that blocked their read.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

func (p oneShotGETRetryPolicy) getWorkspaceBytes(ctx context.Context, client *workspaceCommandClient, pathForWorkspace func(string) string) ([]byte, string, error) {
var body []byte
var contentType string
err := p.run(ctx, func(ctx context.Context) error {
var err error
body, contentType, err = client.getWorkspaceBytes(ctx, pathForWorkspace)
return err
})
return body, contentType, err
}

func (p oneShotGETRetryPolicy) getWorkspaceJSON(ctx context.Context, client *workspaceCommandClient, pathForWorkspace func(string) string, out any) error {
return p.run(ctx, func(ctx context.Context) error {
return client.getWorkspaceJSON(ctx, pathForWorkspace, out)
})
}
Loading
Loading