[object Object]

← back to Cli Printing Press

fix(cli): parse epoch Retry-After headers (#565)

e588bf7ed1d14dda8933a9774eb3af115331cf1f · 2026-05-03 22:01:34 -0700 · Trevin Chow

Files touched

Diff

commit e588bf7ed1d14dda8933a9774eb3af115331cf1f
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 3 22:01:34 2026 -0700

    fix(cli): parse epoch Retry-After headers (#565)
---
 .../generator/templates/cliutil_ratelimit.go.tmpl  | 54 ++++++++++++++++------
 internal/generator/templates/cliutil_test.go.tmpl  | 20 ++++++++
 .../internal/cliutil/ratelimit.go                  | 54 ++++++++++++++++------
 3 files changed, 102 insertions(+), 26 deletions(-)

diff --git a/internal/generator/templates/cliutil_ratelimit.go.tmpl b/internal/generator/templates/cliutil_ratelimit.go.tmpl
index e6de7d15..77f6fa63 100644
--- a/internal/generator/templates/cliutil_ratelimit.go.tmpl
+++ b/internal/generator/templates/cliutil_ratelimit.go.tmpl
@@ -121,25 +121,26 @@ func (e *RateLimitError) Error() string {
 // or hostile upstream cannot pin a CLI for hours.
 const MaxRetryWait = 60 * time.Second
 
+const (
+	defaultRetryWait               = 5 * time.Second
+	unixEpochSecondsThreshold      = 1_000_000_000
+	unixEpochMillisecondsThreshold = 1_000_000_000_000
+)
+
 // RetryAfter parses an HTTP Retry-After header (RFC 7231: delta-seconds or
-// HTTP-date), capped at MaxRetryWait. Returns 5s when missing or unparseable.
+// HTTP-date), plus common Unix epoch seconds/milliseconds variants emitted by
+// some APIs. Waits are capped at MaxRetryWait. Returns 5s when missing or
+// unparseable.
 func RetryAfter(resp *http.Response) time.Duration {
 	if resp == nil {
-		return 5 * time.Second
+		return defaultRetryWait
 	}
 	header := strings.TrimSpace(resp.Header.Get("Retry-After"))
 	if header == "" {
-		return 5 * time.Second
+		return defaultRetryWait
 	}
-	if seconds, err := strconv.Atoi(header); err == nil {
-		d := time.Duration(seconds) * time.Second
-		if d > MaxRetryWait {
-			return MaxRetryWait
-		}
-		if d <= 0 {
-			return 5 * time.Second
-		}
-		return d
+	if value, err := strconv.ParseInt(header, 10, 64); err == nil {
+		return retryAfterFromNumber(value)
 	}
 	if t, err := http.ParseTime(header); err == nil {
 		wait := time.Until(t)
@@ -150,7 +151,34 @@ func RetryAfter(resp *http.Response) time.Duration {
 			return wait
 		}
 	}
-	return 5 * time.Second
+	return defaultRetryWait
+}
+
+func retryAfterFromNumber(value int64) time.Duration {
+	if value <= 0 {
+		return defaultRetryWait
+	}
+	if value > int64(MaxRetryWait/time.Second) {
+		if wait := retryAfterEpochWait(value); wait > 0 {
+			if wait > MaxRetryWait {
+				return MaxRetryWait
+			}
+			return wait
+		}
+		return MaxRetryWait
+	}
+	return time.Duration(value) * time.Second
+}
+
+func retryAfterEpochWait(value int64) time.Duration {
+	switch {
+	case value >= unixEpochMillisecondsThreshold:
+		return time.Until(time.UnixMilli(value))
+	case value >= unixEpochSecondsThreshold:
+		return time.Until(time.Unix(value, 0))
+	default:
+		return 0
+	}
 }
 
 // MaxBackoff caps Backoff so tests stay bounded. Callers needing jitter
diff --git a/internal/generator/templates/cliutil_test.go.tmpl b/internal/generator/templates/cliutil_test.go.tmpl
index a2d3d253..a73037be 100644
--- a/internal/generator/templates/cliutil_test.go.tmpl
+++ b/internal/generator/templates/cliutil_test.go.tmpl
@@ -755,6 +755,26 @@ func TestRetryAfter_HTTPDate(t *testing.T) {
 	}
 }
 
+func TestRetryAfter_EpochSeconds(t *testing.T) {
+	future := time.Now().Add(7 * time.Second)
+	resp := &http.Response{Header: http.Header{}}
+	resp.Header.Set("Retry-After", fmt.Sprint(future.Unix()))
+	got := RetryAfter(resp)
+	if got < 5*time.Second || got > 8*time.Second {
+		t.Errorf("RetryAfter(epoch seconds 7s ahead) = %v, want ~7s", got)
+	}
+}
+
+func TestRetryAfter_EpochMilliseconds(t *testing.T) {
+	future := time.Now().Add(7 * time.Second)
+	resp := &http.Response{Header: http.Header{}}
+	resp.Header.Set("Retry-After", fmt.Sprint(future.UnixMilli()))
+	got := RetryAfter(resp)
+	if got < 5*time.Second || got > 8*time.Second {
+		t.Errorf("RetryAfter(epoch milliseconds 7s ahead) = %v, want ~7s", got)
+	}
+}
+
 func TestRetryAfter_Cap(t *testing.T) {
 	resp := &http.Response{Header: http.Header{}}
 	resp.Header.Set("Retry-After", "600")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/ratelimit.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/ratelimit.go
index 3f7cbf58..a6e74388 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/ratelimit.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/ratelimit.go
@@ -121,25 +121,26 @@ func (e *RateLimitError) Error() string {
 // or hostile upstream cannot pin a CLI for hours.
 const MaxRetryWait = 60 * time.Second
 
+const (
+	defaultRetryWait               = 5 * time.Second
+	unixEpochSecondsThreshold      = 1_000_000_000
+	unixEpochMillisecondsThreshold = 1_000_000_000_000
+)
+
 // RetryAfter parses an HTTP Retry-After header (RFC 7231: delta-seconds or
-// HTTP-date), capped at MaxRetryWait. Returns 5s when missing or unparseable.
+// HTTP-date), plus common Unix epoch seconds/milliseconds variants emitted by
+// some APIs. Waits are capped at MaxRetryWait. Returns 5s when missing or
+// unparseable.
 func RetryAfter(resp *http.Response) time.Duration {
 	if resp == nil {
-		return 5 * time.Second
+		return defaultRetryWait
 	}
 	header := strings.TrimSpace(resp.Header.Get("Retry-After"))
 	if header == "" {
-		return 5 * time.Second
+		return defaultRetryWait
 	}
-	if seconds, err := strconv.Atoi(header); err == nil {
-		d := time.Duration(seconds) * time.Second
-		if d > MaxRetryWait {
-			return MaxRetryWait
-		}
-		if d <= 0 {
-			return 5 * time.Second
-		}
-		return d
+	if value, err := strconv.ParseInt(header, 10, 64); err == nil {
+		return retryAfterFromNumber(value)
 	}
 	if t, err := http.ParseTime(header); err == nil {
 		wait := time.Until(t)
@@ -150,7 +151,34 @@ func RetryAfter(resp *http.Response) time.Duration {
 			return wait
 		}
 	}
-	return 5 * time.Second
+	return defaultRetryWait
+}
+
+func retryAfterFromNumber(value int64) time.Duration {
+	if value <= 0 {
+		return defaultRetryWait
+	}
+	if value > int64(MaxRetryWait/time.Second) {
+		if wait := retryAfterEpochWait(value); wait > 0 {
+			if wait > MaxRetryWait {
+				return MaxRetryWait
+			}
+			return wait
+		}
+		return MaxRetryWait
+	}
+	return time.Duration(value) * time.Second
+}
+
+func retryAfterEpochWait(value int64) time.Duration {
+	switch {
+	case value >= unixEpochMillisecondsThreshold:
+		return time.Until(time.UnixMilli(value))
+	case value >= unixEpochSecondsThreshold:
+		return time.Until(time.Unix(value, 0))
+	default:
+		return 0
+	}
 }
 
 // MaxBackoff caps Backoff so tests stay bounded. Callers needing jitter

← 34413e94 fix(cli): reclaim locks from dead owners (#564)  ·  back to Cli Printing Press  ·  fix(cli): preserve operation-routing path params (#567) 18542129 →