Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions open-sse/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export const HTTP_STATUS = {
REQUEST_TIMEOUT: 408,
GONE: 410,
RATE_LIMITED: 429,
PLAN_LIMIT_EXCEEDED: 432,
SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
Expand Down
26 changes: 26 additions & 0 deletions open-sse/handlers/search/searchProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,27 @@
import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../../utils/error.ts";
import { formatSearchProviderFailure } from "./providerFailure.ts";
import { HTTP_STATUS } from "../../config/constants.ts";
import { isSubscriptionQuotaText } from "../../services/quotaTextCooldowns.ts";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
import type { SearchResult } from "../search.ts";

const SEARCH_COOLDOWN_STATUSES = new Set([
HTTP_STATUS.PAYMENT_REQUIRED,
HTTP_STATUS.REQUEST_TIMEOUT,
HTTP_STATUS.RATE_LIMITED,
HTTP_STATUS.PLAN_LIMIT_EXCEEDED,
HTTP_STATUS.SERVER_ERROR,
HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.SERVICE_UNAVAILABLE,
HTTP_STATUS.GATEWAY_TIMEOUT,
]);

export function shouldCoolDownSearchConnection(status: number, errorText: string): boolean {
if (SEARCH_COOLDOWN_STATUSES.has(status)) return true;
return isSubscriptionQuotaText(errorText.toLowerCase());
}

/** Resolved proxy binding for a single provider attempt. */
export interface ResolvedSearchProxy {
proxy: unknown;
Expand Down Expand Up @@ -196,6 +214,14 @@ export async function executeProviderFetch(
if (log) {
log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`);
}
if (connectionId && shouldCoolDownSearchConnection(response.status, errorText)) {
try {
const { markAccountUnavailable } = await import("@/sse/services/auth.ts");
await markAccountUnavailable(connectionId, response.status, errorText, config.id, null);
} catch {
/* non-critical - background cooldown mark must not break search response */
}
}
logCall({
status: response.status,
duration: Date.now() - startTime,
Expand Down
21 changes: 20 additions & 1 deletion open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
buildSubscriptionQuotaFallback,
buildWeeklyQuotaFallback,
buildSessionQuotaFallback,
SUBSCRIPTION_QUOTA_COOLDOWN_MS,
} from "./quotaTextCooldowns.ts";
import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts";
import { evictLockoutOverflow } from "./accountFallback/lockoutEviction.ts";
Expand Down Expand Up @@ -1560,7 +1561,7 @@ export function classifyError(
if (status === HTTP_STATUS.UNAUTHORIZED || status === HTTP_STATUS.FORBIDDEN) {
return RateLimitReason.AUTH_ERROR;
}
if (status === HTTP_STATUS.PAYMENT_REQUIRED) {
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.PLAN_LIMIT_EXCEEDED) {
return RateLimitReason.QUOTA_EXHAUSTED;
}
if (status === HTTP_STATUS.RATE_LIMITED) {
Expand Down Expand Up @@ -2131,6 +2132,24 @@ export function checkFallbackError(
return buildRetryableFallback(RateLimitReason.SERVER_ERROR);
}

// 432 -- plan limit reached (e.g. Tavily, Context7, and search upstreams)
if (status === HTTP_STATUS.PLAN_LIMIT_EXCEEDED) {
const subResult = buildSubscriptionQuotaFallback(
errorStr,
() => getUpstreamRetryHint()?.retryAfterMs ?? null,
parseRetryFromErrorText,
provider
);
if (subResult) return subResult;
const cooldownMs = getUpstreamRetryHint()?.retryAfterMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS;
return {
shouldFallback: true,
cooldownMs,
baseCooldownMs: cooldownMs,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}

// 400 — context overflow / malformed request / model access denied
if (status === HTTP_STATUS.BAD_REQUEST) {
// Check structured error codes first (more reliable, no false positives)
Expand Down
7 changes: 6 additions & 1 deletion open-sse/services/quotaTextCooldowns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,19 @@ export function isSubscriptionQuotaText(lower: string, provider?: string | null)
lower.includes("claude pro usage limit") ||
lower.includes("you've reached your usage limit") ||
lower.includes("you have reached your usage limit") ||
lower.includes("exceeds your plan") ||
lower.includes("plan limit") ||
lower.includes("plan's set usage limit") ||
lower.includes("plan limit exceeded") ||
lower.includes("usage limit exceeded") ||
// Native Claude OAuth uses this otherwise-generic 429 wording for an
// exhausted subscription window. Keep it provider-scoped: other upstreams
// can use the same phrase for a short RPM throttle.
(provider === "claude" && lower.includes("this request would exceed your account's rate limit"))
);
}

const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
export const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour

/**
* Builds the QUOTA_EXHAUSTED fallback for the subscription-quota text above.
Expand Down
Loading
Loading