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) +}