From f5d46b9ca25a836857dec658b07775dfd874c24b Mon Sep 17 00:00:00 2001 From: lyd123qw2008 <326643467@qq.com> Date: Sat, 21 Feb 2026 13:50:23 +0800 Subject: [PATCH 1/2] fix(codex): honor usage_limit_reached resets_at for retry_after --- .../api/handlers/management/auth_files.go | 3 + internal/runtime/executor/codex_executor.go | 36 +++++++++++- .../executor/codex_executor_retry_test.go | 58 +++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 internal/runtime/executor/codex_executor_retry_test.go diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 7f7fad15..159bc21a 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -406,6 +406,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { if !auth.LastRefreshedAt.IsZero() { entry["last_refresh"] = auth.LastRefreshedAt } + if !auth.NextRetryAfter.IsZero() { + entry["next_retry_after"] = auth.NextRetryAfter + } if path != "" { entry["path"] = path entry["source"] = "file" diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 01de8f97..34dcad56 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -156,7 +156,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re b, _ := io.ReadAll(httpResp.Body) appendAPIResponseChunk(ctx, e.cfg, b) logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) - err = statusErr{code: httpResp.StatusCode, msg: string(b)} + err = newCodexStatusErr(httpResp.StatusCode, b) return resp, err } data, err := io.ReadAll(httpResp.Body) @@ -260,7 +260,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A b, _ := io.ReadAll(httpResp.Body) appendAPIResponseChunk(ctx, e.cfg, b) logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) - err = statusErr{code: httpResp.StatusCode, msg: string(b)} + err = newCodexStatusErr(httpResp.StatusCode, b) return resp, err } data, err := io.ReadAll(httpResp.Body) @@ -358,7 +358,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } appendAPIResponseChunk(ctx, e.cfg, data) logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) - err = statusErr{code: httpResp.StatusCode, msg: string(data)} + err = newCodexStatusErr(httpResp.StatusCode, data) return nil, err } out := make(chan cliproxyexecutor.StreamChunk) @@ -673,6 +673,36 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s util.ApplyCustomHeadersFromAttrs(r, attrs) } +func newCodexStatusErr(statusCode int, body []byte) statusErr { + err := statusErr{code: statusCode, msg: string(body)} + if retryAfter := parseCodexRetryAfter(statusCode, body); retryAfter != nil { + err.retryAfter = retryAfter + } + return err +} + +func parseCodexRetryAfter(statusCode int, errorBody []byte) *time.Duration { + if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 { + return nil + } + if strings.TrimSpace(gjson.GetBytes(errorBody, "error.type").String()) != "usage_limit_reached" { + return nil + } + now := time.Now() + if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 { + resetAtTime := time.Unix(resetsAt, 0) + if resetAtTime.After(now) { + retryAfter := resetAtTime.Sub(now) + return &retryAfter + } + } + if resetsInSeconds := gjson.GetBytes(errorBody, "error.resets_in_seconds").Int(); resetsInSeconds > 0 { + retryAfter := time.Duration(resetsInSeconds) * time.Second + return &retryAfter + } + return nil +} + func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", "" diff --git a/internal/runtime/executor/codex_executor_retry_test.go b/internal/runtime/executor/codex_executor_retry_test.go new file mode 100644 index 00000000..4a47796d --- /dev/null +++ b/internal/runtime/executor/codex_executor_retry_test.go @@ -0,0 +1,58 @@ +package executor + +import ( + "net/http" + "strconv" + "testing" + "time" +) + +func TestParseCodexRetryAfter_ResetsInSeconds(t *testing.T) { + body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":123}}`) + retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body) + if retryAfter == nil { + t.Fatalf("expected retryAfter, got nil") + } + if *retryAfter != 123*time.Second { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 123*time.Second) + } +} + +func TestParseCodexRetryAfter_PrefersResetsAt(t *testing.T) { + resetAt := time.Now().Add(5 * time.Minute).Unix() + body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":1}}`) + retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body) + if retryAfter == nil { + t.Fatalf("expected retryAfter, got nil") + } + if *retryAfter < 4*time.Minute || *retryAfter > 6*time.Minute { + t.Fatalf("retryAfter = %v, want around 5m", *retryAfter) + } +} + +func TestParseCodexRetryAfter_FallbackWhenResetsAtPast(t *testing.T) { + resetAt := time.Now().Add(-1 * time.Minute).Unix() + body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":77}}`) + retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body) + if retryAfter == nil { + t.Fatalf("expected retryAfter, got nil") + } + if *retryAfter != 77*time.Second { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 77*time.Second) + } +} + +func TestParseCodexRetryAfter_NonApplicableReturnsNil(t *testing.T) { + body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`) + if got := parseCodexRetryAfter(http.StatusBadRequest, body); got != nil { + t.Fatalf("expected nil for non-429, got %v", *got) + } + body = []byte(`{"error":{"type":"server_error","resets_in_seconds":30}}`) + if got := parseCodexRetryAfter(http.StatusTooManyRequests, body); got != nil { + t.Fatalf("expected nil for non-usage_limit_reached, got %v", *got) + } +} + +func itoa(v int64) string { + return strconv.FormatInt(v, 10) +} From a99522224f670d5db3de5c05c2661574cbca6d58 Mon Sep 17 00:00:00 2001 From: lyd123qw2008 <326643467@qq.com> Date: Sat, 21 Feb 2026 14:13:38 +0800 Subject: [PATCH 2/2] refactor(codex): make retry-after parsing deterministic for tests --- internal/runtime/executor/codex_executor.go | 5 +- .../executor/codex_executor_retry_test.go | 89 ++++++++++--------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 34dcad56..a0cbc0d5 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -675,20 +675,19 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s func newCodexStatusErr(statusCode int, body []byte) statusErr { err := statusErr{code: statusCode, msg: string(body)} - if retryAfter := parseCodexRetryAfter(statusCode, body); retryAfter != nil { + if retryAfter := parseCodexRetryAfter(statusCode, body, time.Now()); retryAfter != nil { err.retryAfter = retryAfter } return err } -func parseCodexRetryAfter(statusCode int, errorBody []byte) *time.Duration { +func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration { if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 { return nil } if strings.TrimSpace(gjson.GetBytes(errorBody, "error.type").String()) != "usage_limit_reached" { return nil } - now := time.Now() if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 { resetAtTime := time.Unix(resetsAt, 0) if resetAtTime.After(now) { diff --git a/internal/runtime/executor/codex_executor_retry_test.go b/internal/runtime/executor/codex_executor_retry_test.go index 4a47796d..3e54ae7c 100644 --- a/internal/runtime/executor/codex_executor_retry_test.go +++ b/internal/runtime/executor/codex_executor_retry_test.go @@ -7,50 +7,57 @@ import ( "time" ) -func TestParseCodexRetryAfter_ResetsInSeconds(t *testing.T) { - body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":123}}`) - retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body) - if retryAfter == nil { - t.Fatalf("expected retryAfter, got nil") - } - if *retryAfter != 123*time.Second { - t.Fatalf("retryAfter = %v, want %v", *retryAfter, 123*time.Second) - } -} +func TestParseCodexRetryAfter(t *testing.T) { + now := time.Unix(1_700_000_000, 0) -func TestParseCodexRetryAfter_PrefersResetsAt(t *testing.T) { - resetAt := time.Now().Add(5 * time.Minute).Unix() - body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":1}}`) - retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body) - if retryAfter == nil { - t.Fatalf("expected retryAfter, got nil") - } - if *retryAfter < 4*time.Minute || *retryAfter > 6*time.Minute { - t.Fatalf("retryAfter = %v, want around 5m", *retryAfter) - } -} + t.Run("resets_in_seconds", func(t *testing.T) { + body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":123}}`) + retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now) + if retryAfter == nil { + t.Fatalf("expected retryAfter, got nil") + } + if *retryAfter != 123*time.Second { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 123*time.Second) + } + }) -func TestParseCodexRetryAfter_FallbackWhenResetsAtPast(t *testing.T) { - resetAt := time.Now().Add(-1 * time.Minute).Unix() - body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":77}}`) - retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body) - if retryAfter == nil { - t.Fatalf("expected retryAfter, got nil") - } - if *retryAfter != 77*time.Second { - t.Fatalf("retryAfter = %v, want %v", *retryAfter, 77*time.Second) - } -} + t.Run("prefers resets_at", func(t *testing.T) { + resetAt := now.Add(5 * time.Minute).Unix() + body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":1}}`) + retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now) + if retryAfter == nil { + t.Fatalf("expected retryAfter, got nil") + } + if *retryAfter != 5*time.Minute { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 5*time.Minute) + } + }) -func TestParseCodexRetryAfter_NonApplicableReturnsNil(t *testing.T) { - body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`) - if got := parseCodexRetryAfter(http.StatusBadRequest, body); got != nil { - t.Fatalf("expected nil for non-429, got %v", *got) - } - body = []byte(`{"error":{"type":"server_error","resets_in_seconds":30}}`) - if got := parseCodexRetryAfter(http.StatusTooManyRequests, body); got != nil { - t.Fatalf("expected nil for non-usage_limit_reached, got %v", *got) - } + t.Run("fallback when resets_at is past", func(t *testing.T) { + resetAt := now.Add(-1 * time.Minute).Unix() + body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":77}}`) + retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now) + if retryAfter == nil { + t.Fatalf("expected retryAfter, got nil") + } + if *retryAfter != 77*time.Second { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 77*time.Second) + } + }) + + t.Run("non-429 status code", func(t *testing.T) { + body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`) + if got := parseCodexRetryAfter(http.StatusBadRequest, body, now); got != nil { + t.Fatalf("expected nil for non-429, got %v", *got) + } + }) + + t.Run("non usage_limit_reached error type", func(t *testing.T) { + body := []byte(`{"error":{"type":"server_error","resets_in_seconds":30}}`) + if got := parseCodexRetryAfter(http.StatusTooManyRequests, body, now); got != nil { + t.Fatalf("expected nil for non-usage_limit_reached, got %v", *got) + } + }) } func itoa(v int64) string {