From 89ae7e5fa83659dba7e6b044b65e01dbade9eceb Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Sun, 23 Aug 2026 13:08:14 +0200 Subject: [PATCH] fix --- apps/api/internal/processing/openai.go | 4 +- .../internal/processing/openai_api_error.go | 38 +++++++++++++++++-- .../processing/openai_api_error_test.go | 30 +++++++++++++++ apps/api/internal/processing/sanitize.go | 9 ++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/apps/api/internal/processing/openai.go b/apps/api/internal/processing/openai.go index 6568fad..b931da9 100644 --- a/apps/api/internal/processing/openai.go +++ b/apps/api/internal/processing/openai.go @@ -507,7 +507,9 @@ func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temp msg := formatOpenAIHTTPError(res.StatusCode, parsed.Error) httpErr := errors.New(msg) stopWait(httpErr) - return Completion{}, true, httpErr + // insufficient_quota also arrives as HTTP 429 — do not burn MaxRetries. + retryable := res.StatusCode >= 500 || openAIErrorIsRetryableRateLimit(parsed.Error) + return Completion{}, retryable, httpErr } if res.StatusCode >= 400 { msg := formatOpenAIHTTPError(res.StatusCode, parsed.Error) diff --git a/apps/api/internal/processing/openai_api_error.go b/apps/api/internal/processing/openai_api_error.go index 8e9da74..1af6457 100644 --- a/apps/api/internal/processing/openai_api_error.go +++ b/apps/api/internal/processing/openai_api_error.go @@ -50,6 +50,7 @@ func formatOpenAIHTTPError(status int, errObj *openAIErrorBody) string { lower := strings.ToLower(msg) codeLower := strings.ToLower(code) paramLower := strings.ToLower(param) + typLower := strings.ToLower(typ) switch { case status == 401 || codeLower == "invalid_api_key" || @@ -58,11 +59,20 @@ func formatOpenAIHTTPError(status int, errObj *openAIErrorBody) string { return "AI provider rejected the API key" case status == 403: return "AI provider forbidden the request" - case status == 429 || codeLower == "rate_limit_exceeded": + // OpenAI returns HTTP 429 for BOTH true rate limits and missing prepaid + // credits (insufficient_quota) — check quota BEFORE treating 429 as RPM. + case codeLower == "insufficient_quota" || typLower == "insufficient_quota" || + strings.Contains(lower, "insufficient_quota") || + strings.Contains(lower, "exceeded your current quota") || + strings.Contains(lower, "check your plan and billing"): + return "API billing/quota empty — add prepaid API credits in Platform billing (not ChatGPT Plus)" + case codeLower == "rate_limit_exceeded" || + strings.Contains(lower, "rate limit reached") || + strings.Contains(lower, "rate_limit_exceeded"): return "AI provider rate limited — retry later" - case codeLower == "insufficient_quota" || strings.Contains(lower, "insufficient_quota") || - strings.Contains(lower, "exceeded your current quota"): - return "OpenAI quota exceeded — check billing" + case status == 429: + // Bare 429 with no body fields — still often quota on new accounts. + return "API HTTP 429 — usually empty Platform billing/credits (not ChatGPT Plus); add prepaid credits then retry" case strings.Contains(lower, "does not exist") || strings.Contains(lower, "do not have access") || codeLower == "model_not_found": return "model not found or API key lacks access — check model name and project permissions" @@ -108,3 +118,23 @@ func ProbeCompleteOptions() CompleteOptions { ReasoningEffort: "none", } } + +// openAIErrorIsRetryableRateLimit is true only for genuine RPM/TPM limits. +// HTTP 429 + insufficient_quota (empty prepaid billing) must not be retried. +func openAIErrorIsRetryableRateLimit(errObj *openAIErrorBody) bool { + if errObj == nil { + return false + } + code := strings.ToLower(errObj.codeString()) + typ := strings.ToLower(strings.TrimSpace(errObj.Type)) + msg := strings.ToLower(strings.TrimSpace(errObj.Message)) + if code == "insufficient_quota" || typ == "insufficient_quota" || + strings.Contains(msg, "insufficient_quota") || + strings.Contains(msg, "exceeded your current quota") || + strings.Contains(msg, "check your plan and billing") { + return false + } + return code == "rate_limit_exceeded" || + strings.Contains(msg, "rate limit reached") || + strings.Contains(msg, "rate_limit_exceeded") +} diff --git a/apps/api/internal/processing/openai_api_error_test.go b/apps/api/internal/processing/openai_api_error_test.go index c013698..7eedb74 100644 --- a/apps/api/internal/processing/openai_api_error_test.go +++ b/apps/api/internal/processing/openai_api_error_test.go @@ -47,6 +47,23 @@ func TestFormatOpenAIHTTPError_preservesUsefulHints(t *testing.T) { }, want: "model rejects legacy token cap", }, + { + status: 429, + err: &openAIErrorBody{ + Message: "You exceeded your current quota, please check your plan and billing details.", + Type: "insufficient_quota", + Code: "insufficient_quota", + }, + want: "API billing/quota empty", + }, + { + status: 429, + err: &openAIErrorBody{ + Message: "Rate limit reached for requests", + Code: "rate_limit_exceeded", + }, + want: "AI provider rate limited", + }, { status: 401, err: &openAIErrorBody{ @@ -81,6 +98,19 @@ func TestFormatOpenAIHTTPError_preservesUsefulHints(t *testing.T) { } } +func TestOpenAIErrorIsRetryableRateLimit(t *testing.T) { + t.Parallel() + if openAIErrorIsRetryableRateLimit(&openAIErrorBody{Code: "insufficient_quota", Type: "insufficient_quota"}) { + t.Fatal("quota must not retry") + } + if !openAIErrorIsRetryableRateLimit(&openAIErrorBody{Code: "rate_limit_exceeded"}) { + t.Fatal("true rate limit should retry") + } + if openAIErrorIsRetryableRateLimit(nil) { + t.Fatal("nil 429 body must not auto-retry") + } +} + func TestProbeCompleteOptions(t *testing.T) { t.Parallel() opts := ProbeCompleteOptions() diff --git a/apps/api/internal/processing/sanitize.go b/apps/api/internal/processing/sanitize.go index ca52b8f..bae7ff0 100644 --- a/apps/api/internal/processing/sanitize.go +++ b/apps/api/internal/processing/sanitize.go @@ -247,8 +247,13 @@ func classifyProviderError(msg string) string { strings.Contains(lower, "rate limited —"), strings.Contains(lower, "rate limited -"): return "AI provider rate limited — retry later" - case strings.Contains(lower, "quota exceeded"): - return "OpenAI quota exceeded — check billing" + case strings.Contains(lower, "quota exceeded"), + strings.Contains(lower, "insufficient_quota"), + strings.Contains(lower, "billing/quota empty"), + strings.Contains(lower, "prepaid api credits"), + strings.Contains(lower, "check your plan and billing"), + strings.Contains(lower, "http 429 — usually empty"): + return "API billing/quota empty — add prepaid API credits in Platform billing (not ChatGPT Plus)" case strings.Contains(lower, "model not found"), strings.Contains(lower, "lacks access"): return "model not found or API key lacks access — check model name and project permissions"