mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
feat(server): add the HTTP/2 bridge admission operator configuration chain
Let an operator set both HTTP/2 bridge admission caps through PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS and PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. The new env helper runs each per-field resolver with a bound reporter before the joint budget check, so a per-field rejection is never silent. The host logs a refused cap pair at error level with the refused total and the reserved budget, and never keeps one operator value and one default from a refused pair. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
41e6d9823b
commit
ffb5bcde53
3 files changed
+277
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
|
||||
DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
|
||||
} from "@paperclipai/adapter-utils/http2-bridge-admission";
|
||||
import {
|
||||
resolveHttp2BridgeAdmissionCapsFromEnv,
|
||||
resolveMaxLiveHttp2BridgeSessionsFromEnv,
|
||||
resolveMaxParallelHttp2BridgeRequestsFromEnv,
|
||||
} from "./http2-bridge-admission-env.js";
|
||||
|
||||
describe("resolveMaxParallelHttp2BridgeRequestsFromEnv", () => {
|
||||
it("an absent variable uses the default", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv(undefined, onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a blank or whitespace-only value is invalid and reports a rejection", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv(" ", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS);
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
// The reporter receives only the parsed number, never the raw string.
|
||||
const reportedValue = onRejectedOverride.mock.calls[0]?.[0];
|
||||
expect(typeof reportedValue).toBe("number");
|
||||
});
|
||||
|
||||
it("a value above the hard range is invalid and reports a rejection", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv("65", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS);
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a valid positive integer inside the range passes through unchanged", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv("32", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(32);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMaxLiveHttp2BridgeSessionsFromEnv", () => {
|
||||
it("an absent variable uses the default", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv(undefined, onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a blank or whitespace-only value is invalid and reports a rejection", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS);
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a value above the hard range is invalid and reports a rejection", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("65", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS);
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a valid positive integer inside the range passes through unchanged", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("4", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(4);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHttp2BridgeAdmissionCapsFromEnv", () => {
|
||||
it("an absent pair uses both defaults", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
const onRejectedPair = vi.fn();
|
||||
|
||||
const resolved = resolveHttp2BridgeAdmissionCapsFromEnv(
|
||||
undefined,
|
||||
undefined,
|
||||
onRejectedOverride,
|
||||
onRejectedPair,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
|
||||
maxSessions: DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
|
||||
});
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
expect(onRejectedPair).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a rejected per-field override calls the override reporter exactly once", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
const onRejectedPair = vi.fn();
|
||||
|
||||
const resolved = resolveHttp2BridgeAdmissionCapsFromEnv(
|
||||
"not-a-number",
|
||||
"4",
|
||||
onRejectedOverride,
|
||||
onRejectedPair,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
|
||||
maxSessions: 4,
|
||||
});
|
||||
// The pair resolver reruns each per-field check with no reporter bound, so
|
||||
// only the env helper's own per-field pass reports the rejection.
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
expect(onRejectedPair).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an over-budget environment pair of 64 and 64 resolves to both defaults and reports an error", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
const onRejectedPair = vi.fn();
|
||||
|
||||
const resolved = resolveHttp2BridgeAdmissionCapsFromEnv("64", "64", onRejectedOverride, onRejectedPair);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
|
||||
maxSessions: DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
|
||||
});
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
expect(onRejectedPair).toHaveBeenCalledTimes(1);
|
||||
const rejection = onRejectedPair.mock.calls[0]?.[0];
|
||||
expect(rejection).toEqual({ maxParallel: 64, maxSessions: 64, totalBytes: expect.any(Number) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
resolveHttp2BridgeAdmissionCaps,
|
||||
resolveMaxLiveHttp2BridgeSessions,
|
||||
resolveMaxParallelHttp2BridgeRequests,
|
||||
type Http2BridgeAdmissionCapOverrideRejectionReporter,
|
||||
type Http2BridgeAdmissionCaps,
|
||||
type Http2BridgeAdmissionPairRejectionReporter,
|
||||
} from "@paperclipai/adapter-utils/http2-bridge-admission";
|
||||
|
||||
/**
|
||||
* Resolve the parallel-stream admission cap from the raw operator override
|
||||
* string. The host reads the override from
|
||||
* PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS.
|
||||
*
|
||||
* This helper distinguishes an absent variable from a present blank value. An
|
||||
* absent variable is `undefined`. The helper then uses the documented default
|
||||
* and does not report a rejection. A present variable is a string, even when
|
||||
* the string holds only whitespace. The helper sends every present string
|
||||
* through `Number`, so a blank or whitespace-only value becomes `0`. The
|
||||
* numeric validation in {@link resolveMaxParallelHttp2BridgeRequests} rejects
|
||||
* `0`, reports it through `onRejectedOverride`, and returns the safe default.
|
||||
* So a present blank value is visible as invalid instead of silent as absent.
|
||||
*
|
||||
* `Number` ignores surrounding whitespace, so a valid value with surrounding
|
||||
* spaces still parses. The reporter receives only the parsed number, never
|
||||
* the raw string, so no operator-supplied text reaches a log line.
|
||||
*/
|
||||
export function resolveMaxParallelHttp2BridgeRequestsFromEnv(
|
||||
rawOverride: string | undefined,
|
||||
onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter,
|
||||
): number {
|
||||
return resolveMaxParallelHttp2BridgeRequests(
|
||||
rawOverride === undefined ? undefined : Number(rawOverride),
|
||||
onRejectedOverride,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the live-session admission cap from the raw operator override
|
||||
* string. The host reads the override from
|
||||
* PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. Same present-versus-absent
|
||||
* behavior as {@link resolveMaxParallelHttp2BridgeRequestsFromEnv}.
|
||||
*/
|
||||
export function resolveMaxLiveHttp2BridgeSessionsFromEnv(
|
||||
rawOverride: string | undefined,
|
||||
onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter,
|
||||
): number {
|
||||
return resolveMaxLiveHttp2BridgeSessions(
|
||||
rawOverride === undefined ? undefined : Number(rawOverride),
|
||||
onRejectedOverride,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one admission cap pair from the two raw operator override strings.
|
||||
*
|
||||
* {@link resolveHttp2BridgeAdmissionCaps} runs the two per-field resolvers
|
||||
* without a rejection reporter, so a per-field rejection would stay silent if
|
||||
* this helper only called that function. This helper runs each per-field
|
||||
* resolver itself first, with `onRejectedOverride` bound, so a rejected
|
||||
* PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS or
|
||||
* PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS value always reports through
|
||||
* `onRejectedOverride` exactly once. It then passes the two resolved numbers
|
||||
* into {@link resolveHttp2BridgeAdmissionCaps}, which reruns the same
|
||||
* per-field checks on two already-valid numbers (harmless) and then applies
|
||||
* the joint budget rule. A pair that fails the joint rule reports through
|
||||
* `onRejectedPair` and returns both defaults; the host never keeps one
|
||||
* operator value and one default from a refused pair.
|
||||
*/
|
||||
export function resolveHttp2BridgeAdmissionCapsFromEnv(
|
||||
rawParallel: string | undefined,
|
||||
rawSessions: string | undefined,
|
||||
onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter,
|
||||
onRejectedPair?: Http2BridgeAdmissionPairRejectionReporter,
|
||||
): Http2BridgeAdmissionCaps {
|
||||
const maxParallel = resolveMaxParallelHttp2BridgeRequestsFromEnv(rawParallel, onRejectedOverride);
|
||||
const maxSessions = resolveMaxLiveHttp2BridgeSessionsFromEnv(rawSessions, onRejectedOverride);
|
||||
return resolveHttp2BridgeAdmissionCaps({ maxParallel, maxSessions }, onRejectedPair);
|
||||
}
|
||||
@@ -92,6 +92,12 @@ import {
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
import { resolveDuplexAggregateCeilingBytesFromEnv } from "./duplex-aggregate-ceiling-env.js";
|
||||
import { DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL } from "@paperclipai/adapter-utils/duplex-observability";
|
||||
import {
|
||||
configureHttp2BridgeAdmissionGate,
|
||||
http2BridgeAdmissionBudgetBytes,
|
||||
HTTP2_BRIDGE_ADMISSION_RESERVED_BUDGET_BYTES,
|
||||
} from "@paperclipai/adapter-utils/http2-bridge-admission";
|
||||
import { resolveHttp2BridgeAdmissionCapsFromEnv } from "./http2-bridge-admission-env.js";
|
||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||
import { printStartupBanner } from "./startup-banner.js";
|
||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||
@@ -792,6 +798,51 @@ export async function startServer(): Promise<StartedServer> {
|
||||
);
|
||||
},
|
||||
);
|
||||
// The process-wide admission gate for the host HTTP/2 sandbox bridge. Two
|
||||
// optional operator overrides read PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS
|
||||
// and PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. An absent variable uses the
|
||||
// documented default. A present invalid, blank, whitespace-only, non-finite,
|
||||
// zero, negative, non-integer, unsafe, or over-maximum value does not fail
|
||||
// startup: the resolver rejects it, reports it at error level, and uses the
|
||||
// safe default. Each per-field cap can pass its own range and the pair can
|
||||
// still spend more than the reserved memory budget, so the resolver also
|
||||
// checks the joint rule on the resolved pair. A pair that fails the joint
|
||||
// rule gives both defaults and one error log line; the host never keeps one
|
||||
// operator value and one default from a refused pair. Every log line carries
|
||||
// only the parsed number, never the raw operator string.
|
||||
const http2BridgeAdmissionCaps = resolveHttp2BridgeAdmissionCapsFromEnv(
|
||||
process.env.PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
|
||||
process.env.PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
|
||||
(rejectedValue) => {
|
||||
logger.error(
|
||||
{ rejectedValue },
|
||||
"HTTP/2 bridge admission cap override rejected; using the safe default",
|
||||
);
|
||||
},
|
||||
(rejection) => {
|
||||
logger.error(
|
||||
{
|
||||
maxParallel: rejection.maxParallel,
|
||||
maxSessions: rejection.maxSessions,
|
||||
totalBytes: rejection.totalBytes,
|
||||
reservedBudgetBytes: HTTP2_BRIDGE_ADMISSION_RESERVED_BUDGET_BYTES,
|
||||
},
|
||||
"HTTP/2 bridge admission cap pair refused; using the safe default pair",
|
||||
);
|
||||
},
|
||||
);
|
||||
configureHttp2BridgeAdmissionGate(http2BridgeAdmissionCaps);
|
||||
logger.info(
|
||||
{
|
||||
maxParallel: http2BridgeAdmissionCaps.maxParallel,
|
||||
maxSessions: http2BridgeAdmissionCaps.maxSessions,
|
||||
totalBytes: http2BridgeAdmissionBudgetBytes(
|
||||
http2BridgeAdmissionCaps.maxParallel,
|
||||
http2BridgeAdmissionCaps.maxSessions,
|
||||
),
|
||||
},
|
||||
"HTTP/2 bridge admission gate configured",
|
||||
);
|
||||
// The server has no process metric pipeline yet, so the ledger telemetry maps to
|
||||
// the structured logger. The gauge logs at debug. A reservation rejection logs at
|
||||
// warn, because it marks an availability limit hit. An accounting-underflow defect
|
||||
|
||||
Reference in new issue
Block a user