mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 13:52:28 +03:00
* fix(sse): Anthropic OAuth 403 "Request not allowed" is a per-request refusal, not a ban
A single upstream 403 on the `claude` OAuth connection was classified
FORBIDDEN and written as the terminal `banned` connection state
(chatCore -> writeTerminalStatus). From then on every request to that
provider was short-circuited with "All 1 connection(s) banned by
upstream - please reconnect in the dashboard" without touching Anthropic,
until an operator reconnected.
Anthropic's OAuth surface answers a small fraction of otherwise-valid
requests with 403 {"type":"permission_error","message":"Request not
allowed"}. On the reporting install the same token returned 200 forty
seconds before the 403 and again right after the connection was
re-enabled; a revoked or expired token is a 401 authentication_error, not
this. It is a refusal of one request, not of the credential.
Classify it as the new non-terminal PROVIDER_ERROR_TYPES.REQUEST_REJECTED
(scoped to provider `claude` and the "Request not allowed" body) and list
that type in authTerminalStatus.isNonTerminalProviderError, mirroring the
Cloudflare FINGERPRINT_REJECTION precedent. The combo layer still falls
through to the next target for the failing request; the connection stays
active for the next one. Any other claude 403 keeps its previous
classification.
Tests: error-classifier.test.ts covers the Anthropic body, the
gateway-flattened "[403]: Request not allowed" message, the same body from
a non-Anthropic provider (still FORBIDDEN), other claude 403s (unchanged),
and the helpers; anthropic-request-not-allowed-not-a-ban.test.ts pins
resolveTerminalConnectionStatus() -> null for the new type even with a
`permanent` fallback verdict, and `banned` for a generic claude 403.
* fix(sse): cooldown with backoff and streak escalation for REQUEST_REJECTED (#12859)
Not "ignore the 403" either: if Anthropic ever made "Request not allowed"
systematic, re-sending every request into it would be the wrong thing to
do to an OAuth account. chatCore now handles REQUEST_REJECTED explicitly:
- exclude the connection via setConnectionRateLimitUntil for a growing
cooldown (5 -> 15 -> 45 min) so a sporadic refusal costs minutes, not a
reconnect, and a systematic one cannot become a stream of 403s;
- escalate to the terminal `banned` state only for 3 refusals within a
60-minute window (services/requestRejectedStreak.ts, in-memory per
connection; a restart forgets the streak, erring towards more cooldowns
rather than an operator-undone ban), with a last_error that says so;
- probe-origin failures record but never cool down or ban (#9817).
The existing "request not allowed" text rule (5 s) is unaffected:
markAccountUnavailable skips a connection that already has a future
rateLimitedUntil, so the minute-scale cooldown written here wins.
Tests: request-rejected-streak.test.ts pins the window/threshold/backoff
arithmetic; anthropic-request-not-allowed-cooldown-escalation.test.ts drives
the real chat route against a mocked 403 upstream on a `claude` OAuth
connection: 300 s cooldown, then 900 s, then banned on the third refusal;
a different claude 403 body still bans on the first response.
* chore(changelog): name the #12859 fragment after its PR (#12864)
* refactor(sse): move the REQUEST_REJECTED branch into a chatCore leaf; register its tests for mutation coverage
chatCore.ts is frozen at 5984 lines by the file-size ratchet; the branch
body now lives in open-sse/handlers/chatCore/requestRejectedFailure.ts
(chatCore: 5974 -> 5983). stryker.conf.json tap.testFiles gains the two new
DB-backed tests so their mutant kills count (check:mutation-test-coverage).
* fix(sse): count refusal episodes, reset on success, keep the dashboard honest (#12859 review)
Review findings on the first cut of the REQUEST_REJECTED handling:
- A burst of in-flight requests that all got the 403 within seconds
produced streak 1, 2, 3 and a ban from one upstream event. The streak
now counts cooldown *episodes*: a refusal that lands while the
connection is already excluded is the same event and is not counted.
- Nothing reset the streak on a healthy response, so sporadic refusals
on a busy install could still accumulate to a ban. chatHelpers'
onRequestSuccess now clears it (only a real success does - the recovery
tick's clearAccountError is an elapsed cooldown, not a success).
Clearing the cooldown by hand in the dashboard clears it too.
- The third rung of the ladder was unreachable (the third refusal
escalates): the ladder is now 5 -> 15 min, sourced from COOLDOWN_MS next
to the existing 5 s "request not allowed" rule, with a note on why that
rule is superseded for claude. The 60-min window becomes a 24 h
staleness bound - "consecutive" is defined by successes, not by time.
- Probe-origin refusals no longer touch the streak (#9817).
- The cooldown is written like every other connection-level cooldown:
ISO rateLimitedUntil + testStatus "unavailable" (+ lastErrorAt), so the
dashboard shows the countdown and the recovery tick restores "active".
- One refusal is re-seeded from the persisted row after a restart so a
crash loop cannot reset the count on every boot.
Docs: RESILIENCE_GUIDE terminal states + CODEBASE_DOCUMENTATION resilience
row mention the streak module. Tests cover the burst, the success reset,
the seed, and the ISO/unavailable shape end-to-end through the chat route.
* chore(sse): drop unrelated Prettier churn in auth.ts / providers route
* style(api): keep providers route Prettier-clean
* refactor(sse): share the "exclude connection for a cooldown" leaf between GEO_BLOCKED, GCP_PROJECT_REQUIRED and the new branch
The release tip moved chatCore.ts to its frozen 5984 lines, so the
REQUEST_REJECTED branch cannot add a single net line. The GEO_BLOCKED and
GCP_PROJECT_REQUIRED branches were the same eight statements with different
constants and log wording; both now call
open-sse/handlers/chatCore/connectionCooldown.ts::excludeConnectionForCooldown
(behaviour, probe guard and log lines preserved verbatim). chatCore.ts ends
9 lines below the base it branched from.
* chore(chatCore): tighten the cooldown comments to keep the file under its size ceiling
After merging release/v3.8.51, chatCore.ts sat at 6150 lines against a
frozen ceiling of 6146. Condense the explanatory comments this PR added
to the GEO_BLOCKED and GCP_PROJECT_REQUIRED branches; no code change.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: insoln <is@careerum.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
141 lines
5.2 KiB
TypeScript
141 lines
5.2 KiB
TypeScript
import { COOLDOWN_MS } from "../config/errorConfig.ts";
|
|
|
|
/**
|
|
* Per-connection streak of upstream per-request refusals (REQUEST_REJECTED —
|
|
* today Anthropic's OAuth `403 "Request not allowed"`, #12859).
|
|
*
|
|
* A single refusal is a hiccup: the same token serves the next request. A run
|
|
* of them is enforcement, and re-sending every request into it is the wrong
|
|
* thing to do to an OAuth account. So the connection is excluded for a short,
|
|
* growing cooldown after each refusal, and only a streak of *consecutive*
|
|
* refusals escalates to the terminal `banned` state that used to fire on the
|
|
* first one.
|
|
*
|
|
* What counts as one refusal: one cooldown *episode*. Responses that land while
|
|
* the connection is already excluded were in flight before the cooldown was
|
|
* written (parallel sessions, fan-out) — they are the same event, not new
|
|
* evidence, and are ignored. A successful response on the connection clears
|
|
* the streak (chatHelpers onRequestSuccess); the window below is only a
|
|
* staleness bound so an ancient refusal can never be the first of a streak.
|
|
*
|
|
* State is in-memory on purpose. A restart forgets the streak, which errs on
|
|
* the side of a few more cooldowns before escalation — never on the side of a
|
|
* ban the operator has to undo by hand. The caller may re-seed one refusal
|
|
* from the persisted row (`seedRequestRejectedStreak`) to bound a crash loop.
|
|
*/
|
|
|
|
/** A refusal older than this cannot start or continue a streak. */
|
|
export const REQUEST_REJECTED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
/** Consecutive refusals at which the connection is written as `banned`. */
|
|
export const REQUEST_REJECTED_ESCALATION_THRESHOLD = 3;
|
|
/** Cooldown after the 1st and 2nd refusal of a streak; the 3rd escalates. */
|
|
export const REQUEST_REJECTED_COOLDOWNS_MS = [
|
|
COOLDOWN_MS.requestRejected,
|
|
COOLDOWN_MS.requestRejectedRepeat,
|
|
];
|
|
|
|
export interface RequestRejectedVerdict {
|
|
/** 1-based position of this refusal in the current streak. */
|
|
streak: number;
|
|
/** How long to exclude the connection from selection; 0 when escalating. */
|
|
cooldownMs: number;
|
|
/** True when the streak reached the threshold — caller writes the terminal state. */
|
|
escalate: boolean;
|
|
/**
|
|
* False when the refusal landed while the connection was already in a
|
|
* cooldown written by an earlier refusal: same event, nothing to persist.
|
|
*/
|
|
counted: boolean;
|
|
windowMs: number;
|
|
threshold: number;
|
|
}
|
|
|
|
interface StreakState {
|
|
count: number;
|
|
lastRefusalAt: number;
|
|
cooldownUntil: number;
|
|
}
|
|
|
|
const streaks = new Map<string, StreakState>();
|
|
let clock: () => number = () => Date.now();
|
|
|
|
function cooldownForStreak(streak: number): number {
|
|
const step = Math.min(streak, REQUEST_REJECTED_COOLDOWNS_MS.length) - 1;
|
|
return REQUEST_REJECTED_COOLDOWNS_MS[step];
|
|
}
|
|
|
|
export function recordRequestRejected(
|
|
connectionId: string,
|
|
now: number = clock()
|
|
): RequestRejectedVerdict {
|
|
const existing = streaks.get(connectionId);
|
|
const base = {
|
|
windowMs: REQUEST_REJECTED_WINDOW_MS,
|
|
threshold: REQUEST_REJECTED_ESCALATION_THRESHOLD,
|
|
};
|
|
|
|
if (existing && now < existing.cooldownUntil) {
|
|
// In flight before the cooldown was written — same episode.
|
|
return {
|
|
streak: existing.count,
|
|
cooldownMs: Math.max(0, existing.cooldownUntil - now),
|
|
escalate: false,
|
|
counted: false,
|
|
...base,
|
|
};
|
|
}
|
|
|
|
const continues = !!existing && now - existing.lastRefusalAt < REQUEST_REJECTED_WINDOW_MS;
|
|
const streak = continues ? existing.count + 1 : 1;
|
|
const escalate = streak >= REQUEST_REJECTED_ESCALATION_THRESHOLD;
|
|
|
|
if (escalate) {
|
|
// The terminal state takes over; a reconnect starts from a clean slate.
|
|
streaks.delete(connectionId);
|
|
return { streak, cooldownMs: 0, escalate: true, counted: true, ...base };
|
|
}
|
|
|
|
const cooldownMs = cooldownForStreak(streak);
|
|
streaks.set(connectionId, { count: streak, lastRefusalAt: now, cooldownUntil: now + cooldownMs });
|
|
return { streak, cooldownMs, escalate: false, counted: true, ...base };
|
|
}
|
|
|
|
/**
|
|
* Restore one refusal that happened before this process started (read from
|
|
* the persisted connection row), so a crash loop cannot reset the count on
|
|
* every boot. No-op when the connection already has in-memory state or the
|
|
* refusal is outside the window.
|
|
*/
|
|
export function seedRequestRejectedStreak(
|
|
connectionId: string,
|
|
lastRefusalAt: number,
|
|
now: number = clock()
|
|
): boolean {
|
|
if (streaks.has(connectionId)) return false;
|
|
if (!Number.isFinite(lastRefusalAt) || now - lastRefusalAt >= REQUEST_REJECTED_WINDOW_MS) {
|
|
return false;
|
|
}
|
|
streaks.set(connectionId, { count: 1, lastRefusalAt, cooldownUntil: lastRefusalAt });
|
|
return true;
|
|
}
|
|
|
|
export function hasRequestRejectedStreak(connectionId: string): boolean {
|
|
return streaks.has(connectionId);
|
|
}
|
|
|
|
/** Forget a connection's streak — on a successful response or an operator reset. */
|
|
export function clearRequestRejectedStreak(connectionId: string): void {
|
|
streaks.delete(connectionId);
|
|
}
|
|
|
|
/** Test-only: wipe all streaks and restore the real clock. */
|
|
export function __resetRequestRejectedStreaksForTests(): void {
|
|
streaks.clear();
|
|
clock = () => Date.now();
|
|
}
|
|
|
|
/** Test-only: replace the clock the streak reads when no `now` is passed. */
|
|
export function __setRequestRejectedClockForTests(fn: (() => number) | null): void {
|
|
clock = fn ?? (() => Date.now());
|
|
}
|