mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 10:12:11 +03:00
feat(sse): add connection backpressure for chat handler (#6590)
Add checkConnectionCapacity guard with 429 + Retry-After in handleChat(). Introduce OMNI_MAX_CONCURRENT_CONNECTIONS env-bound cap, disabled (0) by default so existing deployments are unaffected until an operator opts in. Reconstructed from PR #6590, isolating only the backpressure change — the original branch also carried unrelated headroom/docker/perf work from the author's separate #6572 branch. Co-authored-by: oyi77 <oyi77@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -132,6 +132,7 @@ import {
|
||||
waitForCooldownAwareRetry,
|
||||
} from "../services/cooldownAwareRetry";
|
||||
import { constrainConnectionsToQuota, resolveQuotaKeyScope } from "../../lib/quota/quotaKey";
|
||||
import { checkConnectionCapacity } from "../utils/backpressure";
|
||||
|
||||
registerCodexQuotaFetcher();
|
||||
|
||||
@@ -222,6 +223,12 @@ export async function handleChat(
|
||||
const reqId = correlationId || generateRequestId();
|
||||
const telemetry = new RequestTelemetry(reqId);
|
||||
|
||||
const backpressure = checkConnectionCapacity();
|
||||
if (backpressure.shouldReject) {
|
||||
log.warn("BACKPRESSURE", "Rejecting request: at connection limit");
|
||||
return backpressure.response;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
telemetry.startPhase("parse");
|
||||
|
||||
67
src/sse/utils/backpressure.ts
Normal file
67
src/sse/utils/backpressure.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { getActiveSessionCount } from "@omniroute/open-sse/services/sessionManager.ts";
|
||||
|
||||
/**
|
||||
* Connection back-pressure for SSE / streaming endpoints.
|
||||
*
|
||||
* Caps in-flight requests to prevent memory exhaustion. Reads the live
|
||||
* session count from `sessionManager` — the same source the health endpoint
|
||||
* uses to report `activeConnections`.
|
||||
*
|
||||
* Set `OMNI_MAX_CONCURRENT_CONNECTIONS` to a positive integer to enable.
|
||||
* Default is 0 (disabled) so existing deployments are unaffected until
|
||||
* an operator explicitly opts in.
|
||||
*/
|
||||
|
||||
type CapacityOk = { shouldReject: false };
|
||||
type CapacityExceeded = { shouldReject: true; response: Response };
|
||||
export type CapacityResult = CapacityOk | CapacityExceeded;
|
||||
|
||||
/**
|
||||
* Pure capacity evaluator — given an active count and a cap, returns the
|
||||
* appropriate result. Cap ≤ 0 means disabled (never reject).
|
||||
*
|
||||
* Exported for unit testing without mocking any module boundaries.
|
||||
*/
|
||||
export function evalCapacity(active: number, cap: number): CapacityResult {
|
||||
if (cap <= 0 || active < cap) {
|
||||
return { shouldReject: false };
|
||||
}
|
||||
const retryAfter = Math.max(1, Math.ceil((active / cap) * 30));
|
||||
return {
|
||||
shouldReject: true,
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Server busy — ${active} active connections (limit ${cap}). Retry after ${retryAfter}s.`,
|
||||
type: "rate_limit",
|
||||
retry_after: retryAfter,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(retryAfter),
|
||||
"X-RateLimit-Limit": String(cap),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read cap from env on every call so tests can mutate process.env freely. */
|
||||
function readCap(): number {
|
||||
const raw = parseInt(process.env.OMNI_MAX_CONCURRENT_CONNECTIONS ?? "0", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the server is over its configured connection cap.
|
||||
*
|
||||
* Returns `{ shouldReject: true, response }` when at limit, or
|
||||
* `{ shouldReject: false }` when a slot is free (or the cap is disabled).
|
||||
*/
|
||||
export function checkConnectionCapacity(): CapacityResult {
|
||||
return evalCapacity(getActiveSessionCount(), readCap());
|
||||
}
|
||||
109
tests/unit/backpressure.test.ts
Normal file
109
tests/unit/backpressure.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { test, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { evalCapacity } from "../../src/sse/utils/backpressure.ts";
|
||||
import type { CapacityResult } from "../../src/sse/utils/backpressure.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evalCapacity — pure function, no mocking required
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OMNI_MAX_CONCURRENT_CONNECTIONS;
|
||||
});
|
||||
|
||||
test("backpressure: cap=0 never rejects regardless of active count", () => {
|
||||
const r: CapacityResult = evalCapacity(9999, 0);
|
||||
assert.equal(r.shouldReject, false);
|
||||
});
|
||||
|
||||
test("backpressure: negative cap never rejects", () => {
|
||||
assert.equal(evalCapacity(100, -1).shouldReject, false);
|
||||
});
|
||||
|
||||
test("backpressure: passes when active is zero", () => {
|
||||
assert.equal(evalCapacity(0, 10).shouldReject, false);
|
||||
});
|
||||
|
||||
test("backpressure: passes when active is below cap", () => {
|
||||
assert.equal(evalCapacity(5, 10).shouldReject, false);
|
||||
});
|
||||
|
||||
test("backpressure: passes when active is exactly cap minus one", () => {
|
||||
assert.equal(evalCapacity(9, 10).shouldReject, false);
|
||||
});
|
||||
|
||||
test("backpressure: rejects when active equals cap", () => {
|
||||
const r = evalCapacity(10, 10);
|
||||
assert.equal(r.shouldReject, true);
|
||||
});
|
||||
|
||||
test("backpressure: rejects when active exceeds cap", () => {
|
||||
assert.equal(evalCapacity(100, 10).shouldReject, true);
|
||||
});
|
||||
|
||||
test("backpressure: rejection returns HTTP 429", async () => {
|
||||
const r = evalCapacity(10, 10);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
assert.equal(r.response.status, 429);
|
||||
});
|
||||
|
||||
test("backpressure: rejection includes Retry-After header >= 1", async () => {
|
||||
const r = evalCapacity(10, 10);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
const val = Number(r.response.headers.get("Retry-After"));
|
||||
assert.ok(val >= 1, `Retry-After must be >= 1, got ${val}`);
|
||||
});
|
||||
|
||||
test("backpressure: X-RateLimit-Limit matches the cap", async () => {
|
||||
const r = evalCapacity(7, 5);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
assert.equal(r.response.headers.get("X-RateLimit-Limit"), "5");
|
||||
assert.equal(r.response.headers.get("X-RateLimit-Remaining"), "0");
|
||||
});
|
||||
|
||||
test("backpressure: rejection body is valid JSON with error.type=rate_limit", async () => {
|
||||
const r = evalCapacity(3, 3);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
const body = await r.response.json();
|
||||
assert.equal(body?.error?.type, "rate_limit");
|
||||
assert.ok(typeof body.error.message === "string");
|
||||
assert.ok(typeof body.error.retry_after === "number");
|
||||
});
|
||||
|
||||
test("backpressure: rejection message contains active and limit counts", async () => {
|
||||
const r = evalCapacity(8, 5);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
const body = await r.response.json();
|
||||
assert.ok(body.error.message.includes("8"), "message should reference active count");
|
||||
assert.ok(body.error.message.includes("5"), "message should reference cap");
|
||||
});
|
||||
|
||||
test("backpressure: retry-after formula — at cap gives ceil(1*30)=30", async () => {
|
||||
const r = evalCapacity(10, 10);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
assert.equal(Number(r.response.headers.get("Retry-After")), 30);
|
||||
});
|
||||
|
||||
test("backpressure: retry-after increases proportionally under higher load", async () => {
|
||||
const r1 = evalCapacity(10, 10); // factor 1.0 → 30s
|
||||
const r2 = evalCapacity(20, 10); // factor 2.0 → 60s
|
||||
assert.equal(r1.shouldReject, true);
|
||||
assert.equal(r2.shouldReject, true);
|
||||
if (!r1.shouldReject || !r2.shouldReject) throw new Error("unreachable");
|
||||
const ra1 = Number(r1.response.headers.get("Retry-After"));
|
||||
const ra2 = Number(r2.response.headers.get("Retry-After"));
|
||||
assert.ok(ra2 > ra1, `retry-after should grow with load factor (${ra1} → ${ra2})`);
|
||||
});
|
||||
|
||||
test("backpressure: Content-Type is application/json", async () => {
|
||||
const r = evalCapacity(5, 5);
|
||||
assert.equal(r.shouldReject, true);
|
||||
if (!r.shouldReject) throw new Error("unreachable");
|
||||
assert.ok(r.response.headers.get("Content-Type")?.includes("application/json"));
|
||||
});
|
||||
Reference in New Issue
Block a user