← 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
M internal/generator/templates/cliutil_ratelimit.go.tmplM internal/generator/templates/cliutil_test.go.tmplM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cliutil/ratelimit.go
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 →