mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Harden OpenClaw plugin proxy routing (#1074)
## Description Hardens the bundled OpenClaw plugin so configured proxy routing is fail-closed and `autoStart` is opt-in. Closes: N/A This follow-up is intentionally separate from the ContentRouter cache fix because it changes plugin/gateway behavior rather than core compression routing. The plugin should not mutate upstream provider routing unless a configured proxy URL is reachable and looks like Headroom. It should also avoid unhandled startup promise rejections when proxy startup is fire-and-forget. Why this shape: - `autoStart: false` by default matches deployments where Headroom is supervised externally, for example by systemd. The plugin should not silently start or assume ownership of a proxy unless the operator opted in. - Provider routing is fail-closed: a configured URL must first respond like Headroom, not merely expose a generic liveness endpoint. This prevents accidentally routing model traffic through the wrong local service. - `/readyz` is treated as liveness, not identity. Identity comes from Headroom-shaped stats endpoints (`/v1/retrieve/stats` or `/stats`) because those are harder for unrelated services to satisfy by accident. - Startup remains asynchronous, but errors are captured and exposed instead of becoming unhandled promise rejections. - This is a separate PR because the core cache fix is about compression correctness, while this patch is about integration safety around OpenClaw gateway routing. ## Type of Change - [x] Bug fix (non-breaking change fixes issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Make proxy `autoStart` opt-in (`default: false`). - Probe configured `proxyUrl` before applying provider routing. - Treat `/readyz` as liveness only; require Headroom-shaped `/v1/retrieve/stats` or `/stats` for identity. - Observe fire-and-forget startup promise rejection and expose startup error for callers. - Isolate proxy-ready listener failures. - Keep provider routing deferred when no active/probed Headroom proxy exists. - Register retrieve tool with explicit `headroom_retrieve` name. - Extend plugin/unit tests for configured proxy failures, generic non-Headroom endpoints, path collisions, and routing behavior. Changed files: - `plugins/openclaw/README.md` - `plugins/openclaw/openclaw.plugin.json` - `plugins/openclaw/src/engine.ts` - `plugins/openclaw/src/plugin/index.ts` - `plugins/openclaw/src/proxy-manager.ts` - `plugins/openclaw/test/engine.test.ts` - `plugins/openclaw/test/gateway-config.test.ts` - `plugins/openclaw/test/plugin-runtime-routing.test.ts` - `plugins/openclaw/test/proxy-manager.test.ts` ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added new functionality - [x] Manual testing performed ### Test Output ```text $ npm test Test Files 6 passed (6) Tests 74 passed (74) $ npm run typecheck tsc --noEmit $ npm run build tsup && node prepare-dist.mjs Build success ``` ## Real Behavior Proof - Environment: local OpenClaw plugin package in the Headroom repo. - Exact command / steps: - Run plugin test suite. - Run TypeScript typecheck. - Run plugin build. - Observed result: - Tests passed: `74/74`. - Typecheck passed. - Build passed. - Not tested: - Full OpenClaw Gateway integration as part of this standalone PR prep. ## Review Readiness - [x] I performed self-review - [x] This PR ready for human review ## Checklist - [x] My code follows project's style guidelines - [x] I performed self-review my code - [ ] I commented my code, particularly in hard-to-understand areas - [x] I made corresponding changes documentation - [x] My changes generate no new warnings - [x] I added tests prove fix is effective or feature works - [x] New and existing unit tests pass locally my changes - [ ] I updated CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Checklist items left unchecked intentionally: - No CHANGELOG update included. - No extra comments were needed beyond existing code structure. Co-authored-by: Björn-Christian Bönkost <bjoern@v2202603344248440850.hotsrv.de>
This commit is contained in:
parent
723b80c091
commit
6d116b15f1
9 changed files with 690 additions and 64 deletions
|
|
@ -77,11 +77,12 @@ Install automatically selects the `contextEngine` slot for `headroom` on current
|
|||
- `http://127.0.0.1:<proxyPort>`
|
||||
- `http://localhost:<proxyPort>`
|
||||
|
||||
Default `proxyPort` is `8787`.
|
||||
Default `proxyPort` is `8787`. Auto-start is opt-in; in production, prefer an externally
|
||||
managed proxy such as systemd with `proxyUrl` set and `autoStart: false`.
|
||||
|
||||
### Upstream gateway routing
|
||||
|
||||
By default, the plugin also rewrites the built-in `openai-codex` provider base URL to the active Headroom proxy at runtime. That means Codex provider traffic flows through Headroom, so `/stats` can observe real upstream request and cache activity instead of only local context compression.
|
||||
By default, the plugin also rewrites the built-in `openai-codex` provider base URL to a verified active Headroom proxy at runtime. That means Codex provider traffic flows through Headroom, so `/stats` can observe real upstream request and cache activity instead of only local context compression.
|
||||
|
||||
This does not replace Headroom's existing Codex routing rules. The proxy already decides between `api.openai.com` and `chatgpt.com/backend-api/codex/responses` based on ChatGPT auth. The plugin change only points OpenClaw's provider config at the active proxy in memory and preserves the rest of the provider config.
|
||||
|
||||
|
|
@ -199,10 +200,10 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `proxyUrl` | auto-detected | Optional URL of a Headroom proxy. Local addresses (`http://127.0.0.1:<port>`, `http://localhost:<port>`) enable auto-start; remote URLs (`https://headroom.example.com`) are connect-only. |
|
||||
| `proxyPort` | `8787` | Port used for default auto-detect/auto-start when `proxyUrl` is not set. |
|
||||
| `proxyUrl` | auto-detected | Optional URL of a Headroom proxy. Configured URLs are probe-gated before provider routing. Remote URLs (`https://headroom.example.com`) are connect-only. |
|
||||
| `proxyPort` | `8787` | Port used for default auto-detect and optional local auto-start when `proxyUrl` is not set. |
|
||||
| `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
|
||||
| `autoStart` | `true` | Auto-start a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies) |
|
||||
| `autoStart` | `false` | Opt-in auto-start for a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies). Keep `false` when systemd owns the proxy. |
|
||||
| `startupTimeoutMs` | `20000` | Time to wait for auto-started proxy to become healthy |
|
||||
| `routeCodexViaProxy` | `true` | Rewrite OpenClaw's built-in `openai-codex` provider to use the active Headroom proxy in memory so upstream Codex requests pass through Headroom. |
|
||||
| `gatewayProviderIds` | `[]` | Optional explicit list of OpenClaw provider ids to route through the active Headroom proxy in memory. Friendly aliases `codex`, `claude`, `copilot`, and `gemini` are also accepted. When set, this overrides the default `openai-codex` routing list. |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"uiHints": {
|
||||
"proxyUrl": {
|
||||
"label": "Proxy URL",
|
||||
"help": "Optional. URL of a Headroom proxy (example: http://127.0.0.1:8787 or https://headroom.example.com). If omitted, plugin auto-detects on localhost. Auto-start only works for local addresses."
|
||||
"help": "Optional. URL Headroom proxy (example: http://127.0.0.1:8787 or https://headroom.example.com). Configured URLs probe-gated before provider routing. Auto-start opt-in only works local addresses."
|
||||
},
|
||||
"proxyPort": {
|
||||
"label": "Proxy Port",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
},
|
||||
"autoStart": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
"default": false
|
||||
},
|
||||
"startupTimeoutMs": {
|
||||
"type": "integer",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export class HeadroomContextEngine {
|
|||
private logger: ProxyManagerLogger;
|
||||
private proxyReadyListeners = new Set<(proxyUrl: string) => void | Promise<void>>();
|
||||
private proxyStartupPromise: Promise<string> | null = null;
|
||||
private proxyStartupError: unknown = null;
|
||||
private stats = {
|
||||
totalCompressions: 0,
|
||||
totalTokensSaved: 0,
|
||||
|
|
@ -235,26 +236,38 @@ export class HeadroomContextEngine {
|
|||
return this.proxyUrl;
|
||||
}
|
||||
|
||||
getProxyStartupError(): unknown {
|
||||
return this.proxyStartupError;
|
||||
}
|
||||
|
||||
ensureProxyStarted(): void {
|
||||
if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.proxyStartupError = null;
|
||||
this.proxyStartupPromise = this.proxyManager
|
||||
.start()
|
||||
.then(async (proxyUrl) => {
|
||||
this.proxyUrl = proxyUrl;
|
||||
this.proxyStartupError = null;
|
||||
await this.notifyProxyReady(proxyUrl);
|
||||
this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
|
||||
return proxyUrl;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.proxyStartupError = error;
|
||||
this.logger.warn(`Headroom proxy unavailable: ${error}`);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.proxyStartupPromise = null;
|
||||
});
|
||||
|
||||
// Fire-and-forget lifecycle callers intentionally do not await this promise.
|
||||
// Keep the promise rejectable for ensureProxyUrl(), but mark it observed so
|
||||
// a missing proxy cannot become a process-level unhandled rejection.
|
||||
void this.proxyStartupPromise.catch(() => {});
|
||||
}
|
||||
|
||||
onProxyReady(listener: (proxyUrl: string) => void | Promise<void>): () => void {
|
||||
|
|
@ -278,7 +291,11 @@ export class HeadroomContextEngine {
|
|||
|
||||
private async notifyProxyReady(proxyUrl: string): Promise<void> {
|
||||
for (const listener of this.proxyReadyListeners) {
|
||||
await listener(proxyUrl);
|
||||
try {
|
||||
await listener(proxyUrl);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
applyGatewayProviderBaseUrlsInPlace,
|
||||
resolveGatewayProviderIds,
|
||||
} from "../gateway-config.js";
|
||||
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
|
||||
import { normalizeAndValidateProxyUrl, probeHeadroomProxy } from "../proxy-manager.js";
|
||||
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +48,8 @@ function headroomPlugin(api: any) {
|
|||
debug: (m: string) => logger.debug?.(m),
|
||||
});
|
||||
const gatewayProviderIds = resolveGatewayProviderIds(config);
|
||||
let validatedConfiguredProxyUrl: string | null = null;
|
||||
let configuredProxyProbePromise: Promise<string | null> | null = null;
|
||||
|
||||
const applyGatewayRouting = async (activeProxyUrl: string) => {
|
||||
if (gatewayProviderIds.length === 0) {
|
||||
|
|
@ -71,11 +73,46 @@ function headroomPlugin(api: any) {
|
|||
}
|
||||
};
|
||||
|
||||
const getConfiguredRoutingProxyUrl = async (): Promise<string | null> => {
|
||||
if (!proxyUrl) {
|
||||
return null;
|
||||
}
|
||||
if (validatedConfiguredProxyUrl === proxyUrl) {
|
||||
return validatedConfiguredProxyUrl;
|
||||
}
|
||||
if (!configuredProxyProbePromise) {
|
||||
configuredProxyProbePromise = probeHeadroomProxy(proxyUrl)
|
||||
.then((probe) => {
|
||||
if (probe.reachable && probe.isHeadroom) {
|
||||
validatedConfiguredProxyUrl = proxyUrl;
|
||||
return proxyUrl;
|
||||
}
|
||||
logger.warn(
|
||||
`[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` +
|
||||
(probe.reason ? ` (${probe.reason})` : ""),
|
||||
);
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
`[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
configuredProxyProbePromise = null;
|
||||
});
|
||||
}
|
||||
return configuredProxyProbePromise;
|
||||
};
|
||||
|
||||
const ensureGatewayRouting = async () => {
|
||||
const activeProxyUrl = engine.getProxyUrl();
|
||||
if (gatewayProviderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const activeProxyUrl = engine.getProxyUrl() ?? (await getConfiguredRoutingProxyUrl());
|
||||
if (!activeProxyUrl) {
|
||||
logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
|
||||
engine.ensureProxyStarted();
|
||||
return;
|
||||
}
|
||||
await applyGatewayRouting(activeProxyUrl);
|
||||
|
|
@ -93,7 +130,7 @@ function headroomPlugin(api: any) {
|
|||
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
|
||||
if (!activeProxyUrl) return null;
|
||||
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
|
||||
});
|
||||
}, { names: ["headroom_retrieve"] });
|
||||
|
||||
api.on("gateway_start", async () => {
|
||||
await ensureGatewayRouting();
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export class ProxyManager {
|
|||
}
|
||||
|
||||
// Auto-start is only available for local proxies
|
||||
if (this.config.autoStart !== false) {
|
||||
if (this.config.autoStart === true) {
|
||||
const startupUrl = explicitUrl ?? defaultCandidates[0];
|
||||
const startupProbe = probeByUrl.get(startupUrl);
|
||||
if (startupProbe?.reachable && !startupProbe.isHeadroom) {
|
||||
|
|
@ -431,36 +431,71 @@ function withDefaultPort(proxyUrl: string, defaultPort: number): string {
|
|||
*/
|
||||
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
|
||||
const origin = normalizeAndValidateProxyUrl(proxyUrl);
|
||||
|
||||
try {
|
||||
const health = await fetch(`${origin}/health`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
if (!health.ok) {
|
||||
return { reachable: false, isHeadroom: false, reason: `health HTTP ${health.status}` };
|
||||
const probeEndpoint = async (
|
||||
path: string,
|
||||
options: { readBody?: boolean } = {},
|
||||
): Promise<{ reachable: boolean; ok: boolean; status?: number; body?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
const body =
|
||||
response.ok && options.readBody
|
||||
? await response.text().catch(() => undefined)
|
||||
: undefined;
|
||||
return { reachable: true, ok: response.ok, status: response.status, body };
|
||||
} catch {
|
||||
return { reachable: false, ok: false };
|
||||
}
|
||||
} catch {
|
||||
return { reachable: false, isHeadroom: false, reason: "health check failed" };
|
||||
};
|
||||
|
||||
const ready = await probeEndpoint("/readyz");
|
||||
const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
|
||||
if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
|
||||
return { reachable: true, isHeadroom: true };
|
||||
}
|
||||
|
||||
const stats = await probeEndpoint("/stats", { readBody: true });
|
||||
if (stats.ok && hasHeadroomStatsShape(stats.body)) {
|
||||
return { reachable: true, isHeadroom: true };
|
||||
}
|
||||
|
||||
const health = await probeEndpoint("/health");
|
||||
const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
|
||||
if (!anyReachable) {
|
||||
return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
|
||||
}
|
||||
|
||||
const reasons = [
|
||||
ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
|
||||
retrieveStats.reachable
|
||||
? `retrieve stats HTTP ${retrieveStats.status}`
|
||||
: "retrieve stats endpoint unavailable",
|
||||
stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
|
||||
health.reachable ? `health HTTP ${health.status}` : "health check failed",
|
||||
];
|
||||
return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
|
||||
}
|
||||
|
||||
function hasHeadroomStatsShape(body: string | undefined): boolean {
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const retrieveStats = await fetch(`${origin}/v1/retrieve/stats`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
if (retrieveStats.ok) {
|
||||
return { reachable: true, isHeadroom: true };
|
||||
}
|
||||
return {
|
||||
reachable: true,
|
||||
isHeadroom: false,
|
||||
reason: `retrieve stats HTTP ${retrieveStats.status}`,
|
||||
};
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
return (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
(Object.hasOwn(parsed, "proxy_inbound") ||
|
||||
Object.hasOwn(parsed, "api_requests") ||
|
||||
Object.hasOwn(parsed, "provider_tokens") ||
|
||||
Object.hasOwn(parsed, "proxy_compression_saved") ||
|
||||
Object.hasOwn(parsed, "store") ||
|
||||
Object.hasOwn(parsed, "recent_retrievals"))
|
||||
);
|
||||
} catch {
|
||||
return {
|
||||
reachable: true,
|
||||
isHeadroom: false,
|
||||
reason: "retrieve stats endpoint unavailable",
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,104 @@ describe("HeadroomContextEngine proxy startup helpers", () => {
|
|||
expect(mocked.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit an unhandledRejection when fire-and-forget startup fails", async () => {
|
||||
mocked.start.mockReset();
|
||||
mocked.start.mockRejectedValue(new Error("proxy boom"));
|
||||
|
||||
const engine = new HeadroomContextEngine();
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
try {
|
||||
// Fire-and-forget: caller intentionally does not await.
|
||||
engine.ensureProxyStarted();
|
||||
// Let the startup promise settle and any microtasks/macrotasks flush.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(unhandled).toEqual([]);
|
||||
expect(mocked.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Headroom proxy unavailable"),
|
||||
);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores the startup failure in getProxyStartupError()", async () => {
|
||||
const failure = new Error("proxy boom");
|
||||
mocked.start.mockReset();
|
||||
mocked.start.mockRejectedValue(failure);
|
||||
|
||||
const engine = new HeadroomContextEngine();
|
||||
expect(engine.getProxyStartupError()).toBeNull();
|
||||
|
||||
engine.ensureProxyStarted();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(engine.getProxyStartupError()).toBe(failure);
|
||||
});
|
||||
|
||||
it("allows retrying startup after a failure", async () => {
|
||||
mocked.start.mockReset();
|
||||
mocked.start
|
||||
.mockRejectedValueOnce(new Error("proxy boom"))
|
||||
.mockResolvedValueOnce("http://127.0.0.1:8787");
|
||||
|
||||
const engine = new HeadroomContextEngine();
|
||||
|
||||
engine.ensureProxyStarted();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(engine.getProxyStartupError()).toBeInstanceOf(Error);
|
||||
|
||||
// A second attempt is possible once the failed promise has cleared.
|
||||
const url = await engine.ensureProxyUrl();
|
||||
expect(url).toBe("http://127.0.0.1:8787");
|
||||
expect(engine.getProxyStartupError()).toBeNull();
|
||||
expect(mocked.start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ensureProxyUrl rejects cleanly on startup failure without unhandledRejection", async () => {
|
||||
const failure = new Error("proxy boom");
|
||||
mocked.start.mockReset();
|
||||
mocked.start.mockRejectedValue(failure);
|
||||
|
||||
const engine = new HeadroomContextEngine();
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
try {
|
||||
await expect(engine.ensureProxyUrl()).rejects.toBe(failure);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates and logs proxy-ready listener rejections", async () => {
|
||||
const engine = new HeadroomContextEngine();
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error("listener boom");
|
||||
});
|
||||
const healthy = vi.fn();
|
||||
|
||||
engine.onProxyReady(failing);
|
||||
engine.onProxyReady(healthy);
|
||||
|
||||
engine.ensureProxyStarted();
|
||||
// ensureProxyUrl must still resolve despite the listener throwing.
|
||||
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
|
||||
|
||||
expect(failing).toHaveBeenCalled();
|
||||
expect(healthy).toHaveBeenCalledWith("http://127.0.0.1:8787");
|
||||
expect(mocked.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Headroom proxy ready listener failed"),
|
||||
);
|
||||
expect(engine.getProxyStartupError()).toBeNull();
|
||||
});
|
||||
|
||||
it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
|
||||
const engine = new HeadroomContextEngine();
|
||||
const messages = [{ role: "user", content: "hello" }];
|
||||
|
|
|
|||
|
|
@ -228,6 +228,65 @@ describe("applyGatewayProviderBaseUrls", () => {
|
|||
expect(result.changed).toBe(false);
|
||||
expect((result.config as any).models?.providers?.["github-copilot"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("documents the Gate-D risk: anthropic without an explicit baseUrl routes to the bare proxy origin", () => {
|
||||
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["anthropic"]);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect((result.config as any).models.providers.anthropic).toEqual({
|
||||
baseUrl: "http://127.0.0.1:8787",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("documents the multi-provider risk: providers sharing /v1 collapse to the same proxy path", () => {
|
||||
const result = applyGatewayProviderBaseUrls(
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
},
|
||||
"github-copilot": {
|
||||
baseUrl: "https://api.githubcopilot.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"http://127.0.0.1:8787",
|
||||
["openai", "github-copilot"],
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect((result.config as any).models.providers.openai.baseUrl).toBe(
|
||||
"http://127.0.0.1:8787/v1",
|
||||
);
|
||||
expect((result.config as any).models.providers["github-copilot"].baseUrl).toBe(
|
||||
"http://127.0.0.1:8787/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-points an already routed provider to a new proxy origin without duplicating paths", () => {
|
||||
const result = applyGatewayProviderBaseUrls(
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "http://127.0.0.1:8787/backend-api",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"http://localhost:8787",
|
||||
["openai-codex"],
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect((result.config as any).models.providers["openai-codex"]).toEqual({
|
||||
baseUrl: "http://localhost:8787/backend-api",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyGatewayProviderBaseUrlsInPlace", () => {
|
||||
|
|
|
|||
|
|
@ -28,14 +28,48 @@ vi.mock("../src/tools/headroom-retrieve.js", () => ({
|
|||
import headroomPlugin from "../src/plugin/index.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mocked.ensureProxyUrl.mockClear();
|
||||
mocked.ensureProxyStarted.mockClear();
|
||||
mocked.getProxyUrl.mockClear();
|
||||
mocked.getProxyUrl.mockReset();
|
||||
mocked.getProxyUrl.mockReturnValue(null);
|
||||
mocked.createHeadroomRetrieveTool.mockClear();
|
||||
proxyReadyListeners.length = 0;
|
||||
});
|
||||
|
||||
describe("headroomPlugin runtime routing", () => {
|
||||
function stubConfiguredProxyProbe(response: "headroom" | "non-headroom" | "down") {
|
||||
if (response === "down") {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||
return;
|
||||
}
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
if (url.endsWith("/readyz")) {
|
||||
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("") });
|
||||
}
|
||||
if (url.endsWith("/v1/retrieve/stats")) {
|
||||
return Promise.resolve({
|
||||
ok: response === "headroom",
|
||||
status: response === "headroom" ? 200 : 404,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/stats")) {
|
||||
return Promise.resolve({
|
||||
ok: response === "headroom",
|
||||
status: response === "headroom" ? 200 : 200,
|
||||
text: () =>
|
||||
Promise.resolve(response === "headroom" ? JSON.stringify({ proxy_inbound: { total: 1 } }) : "{}"),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve("") });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("routes configured providers in memory once the proxy becomes available", async () => {
|
||||
const gatewayHandlers = new Map<string, () => Promise<void>>();
|
||||
const writeConfigFile = vi.fn();
|
||||
|
|
@ -100,8 +134,10 @@ describe("headroomPlugin runtime routing", () => {
|
|||
headroomPlugin(api);
|
||||
await Promise.resolve();
|
||||
|
||||
// With no active or configured proxy URL, initial routing defers without
|
||||
// auto-starting the proxy or mutating providers.
|
||||
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
|
||||
expect(mocked.ensureProxyStarted).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(loadConfig).not.toHaveBeenCalled();
|
||||
expect(api.config.models.providers["openai-codex"]).toBeUndefined();
|
||||
|
|
@ -132,10 +168,218 @@ describe("headroomPlugin runtime routing", () => {
|
|||
|
||||
const gatewayStart = gatewayHandlers.get("gateway_start");
|
||||
expect(gatewayStart).toBeTypeOf("function");
|
||||
// getProxyUrl now reports the active proxy so gateway_start re-routes in
|
||||
// memory without ever auto-starting or awaiting the proxy.
|
||||
mocked.getProxyUrl.mockReturnValue("http://127.0.0.1:8787");
|
||||
await gatewayStart?.();
|
||||
expect(mocked.ensureProxyStarted).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
|
||||
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(loadConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-start on gateway_start when no proxy URL is available", async () => {
|
||||
const gatewayHandlers = new Map<string, () => Promise<void>>();
|
||||
|
||||
const api: any = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
headroom: {
|
||||
config: { gatewayProviderIds: ["claude"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
registerContextEngine: vi.fn(),
|
||||
registerTool: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => Promise<void>) => {
|
||||
gatewayHandlers.set(event, handler);
|
||||
}),
|
||||
};
|
||||
|
||||
headroomPlugin(api);
|
||||
await Promise.resolve();
|
||||
|
||||
await gatewayHandlers.get("gateway_start")?.();
|
||||
|
||||
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
|
||||
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
|
||||
expect(api.config.models.providers.anthropic).toEqual({
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes configured proxyUrl only after it probes as Headroom", async () => {
|
||||
const gatewayHandlers = new Map<string, () => Promise<void>>();
|
||||
stubConfiguredProxyProbe("headroom");
|
||||
|
||||
const api: any = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
headroom: {
|
||||
config: {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
gatewayProviderIds: ["claude"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
registerContextEngine: vi.fn(),
|
||||
registerTool: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => Promise<void>) => {
|
||||
gatewayHandlers.set(event, handler);
|
||||
}),
|
||||
};
|
||||
|
||||
headroomPlugin(api);
|
||||
await gatewayHandlers.get("gateway_start")?.();
|
||||
|
||||
// Configured proxyUrl is probe-gated before provider mutation.
|
||||
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
|
||||
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
|
||||
expect(api.config.models.providers.anthropic).toEqual({
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "http://127.0.0.1:8787",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not route configured proxyUrl when the proxy is unavailable", async () => {
|
||||
const gatewayHandlers = new Map<string, () => Promise<void>>();
|
||||
stubConfiguredProxyProbe("down");
|
||||
|
||||
const api: any = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
headroom: {
|
||||
config: {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
gatewayProviderIds: ["claude"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
registerContextEngine: vi.fn(),
|
||||
registerTool: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => Promise<void>) => {
|
||||
gatewayHandlers.set(event, handler);
|
||||
}),
|
||||
};
|
||||
|
||||
headroomPlugin(api);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await gatewayHandlers.get("gateway_start")?.();
|
||||
|
||||
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
|
||||
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
|
||||
expect(api.config.models.providers.anthropic).toEqual({
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
expect(api.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Skipping upstream gateway routing"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not route configured proxyUrl when only generic liveness endpoints respond", async () => {
|
||||
const gatewayHandlers = new Map<string, () => Promise<void>>();
|
||||
stubConfiguredProxyProbe("non-headroom");
|
||||
|
||||
const api: any = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
headroom: {
|
||||
config: {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
gatewayProviderIds: ["claude"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
registerContextEngine: vi.fn(),
|
||||
registerTool: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => Promise<void>) => {
|
||||
gatewayHandlers.set(event, handler);
|
||||
}),
|
||||
};
|
||||
|
||||
headroomPlugin(api);
|
||||
await gatewayHandlers.get("gateway_start")?.();
|
||||
|
||||
expect(api.config.models.providers.anthropic).toEqual({
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
expect(api.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("configured proxyUrl is not a ready Headroom proxy"),
|
||||
);
|
||||
});
|
||||
|
||||
it("documents that the retrieve tool can be created from configured proxyUrl before routing is validated", () => {
|
||||
stubConfiguredProxyProbe("down");
|
||||
|
||||
const api: any = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
headroom: {
|
||||
config: {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
gatewayProviderIds: ["codex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {},
|
||||
},
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
registerContextEngine: vi.fn(),
|
||||
registerTool: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
headroomPlugin(api);
|
||||
const [toolFactory] = api.registerTool.mock.calls[0];
|
||||
const tool = toolFactory({});
|
||||
|
||||
expect(tool).toEqual({ proxyUrl: "http://127.0.0.1:8787" });
|
||||
expect(mocked.createHeadroomRetrieveTool).toHaveBeenCalledWith({
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,23 +6,29 @@ import {
|
|||
probeHeadroomProxy,
|
||||
} from "../src/proxy-manager.js";
|
||||
|
||||
const retrieveStatsBody = JSON.stringify({ store: { entry_count: 0 }, recent_retrievals: [] });
|
||||
const proxyStatsBody = JSON.stringify({ proxy_inbound: { total: 1 } });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Stub fetch with a sequence of health/retrieve probe outcomes. */
|
||||
function stubProbeSuccess() {
|
||||
const mock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // /health
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }); // /v1/retrieve/stats
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }) // /readyz
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(retrieveStatsBody),
|
||||
}); // /v1/retrieve/stats
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function stubProbeNonHeadroom() {
|
||||
const mock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // /health OK
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }); // /v1/retrieve/stats 404
|
||||
// Every endpoint reachable but non-OK => reachable, non-Headroom (occupied port).
|
||||
const mock = vi.fn().mockResolvedValue({ ok: false, status: 404 });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return mock;
|
||||
}
|
||||
|
|
@ -72,13 +78,114 @@ describe("isLocalProxyUrl", () => {
|
|||
});
|
||||
|
||||
describe("probeHeadroomProxy", () => {
|
||||
it("returns reachable+isHeadroom when both endpoints succeed", async () => {
|
||||
stubProbeSuccess();
|
||||
/**
|
||||
* Resolve fetch outcomes by request path so tests express the new probe order
|
||||
* (/readyz, /v1/retrieve/stats, /stats, /health) without depending on call
|
||||
* sequencing. Unlisted paths reject (treated as unreachable).
|
||||
*/
|
||||
function stubByPath(byPath: Record<string, { ok: boolean; status: number; body?: string }>) {
|
||||
const mock = vi.fn((url: string) => {
|
||||
for (const [path, response] of Object.entries(byPath)) {
|
||||
if (url.endsWith(path)) {
|
||||
return Promise.resolve({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
text: () => Promise.resolve(response.body ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error("ECONNREFUSED"));
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
it("does not treat /readyz success alone as Headroom identity", async () => {
|
||||
stubByPath({
|
||||
"/readyz": { ok: true, status: 200 },
|
||||
"/v1/retrieve/stats": { ok: false, status: 404 },
|
||||
"/stats": { ok: false, status: 404 },
|
||||
"/health": { ok: false, status: 404 },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(result.isHeadroom).toBe(false);
|
||||
});
|
||||
|
||||
it("treats Headroom-shaped /v1/retrieve/stats 200 as Headroom even when /readyz is OK and /health is 503", async () => {
|
||||
stubByPath({
|
||||
"/health": { ok: false, status: 503 },
|
||||
"/readyz": { ok: true, status: 200 },
|
||||
"/v1/retrieve/stats": { ok: true, status: 200, body: retrieveStatsBody },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result).toEqual({ reachable: true, isHeadroom: true });
|
||||
});
|
||||
|
||||
it("returns reachable but non-headroom when retrieve endpoint fails", async () => {
|
||||
it("treats Headroom-shaped /v1/retrieve/stats 200 as Headroom even when /health is 503", async () => {
|
||||
stubByPath({
|
||||
"/health": { ok: false, status: 503 },
|
||||
"/readyz": { ok: false, status: 404 },
|
||||
"/v1/retrieve/stats": { ok: true, status: 200, body: retrieveStatsBody },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result).toEqual({ reachable: true, isHeadroom: true });
|
||||
});
|
||||
|
||||
it("does not treat generic /v1/retrieve/stats 200 as Headroom identity", async () => {
|
||||
stubByPath({
|
||||
"/readyz": { ok: true, status: 200 },
|
||||
"/v1/retrieve/stats": { ok: true, status: 200, body: JSON.stringify({ ok: true }) },
|
||||
"/stats": { ok: false, status: 404 },
|
||||
"/health": { ok: true, status: 200 },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(result.isHeadroom).toBe(false);
|
||||
});
|
||||
|
||||
it("falls through from auth-gated /v1/retrieve/stats to Headroom-shaped /stats", async () => {
|
||||
stubByPath({
|
||||
"/readyz": { ok: true, status: 200 },
|
||||
"/v1/retrieve/stats": { ok: false, status: 403 },
|
||||
"/stats": { ok: true, status: 200, body: proxyStatsBody },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result).toEqual({ reachable: true, isHeadroom: true });
|
||||
});
|
||||
|
||||
it("continues probing when one endpoint is unreachable", async () => {
|
||||
stubByPath({
|
||||
"/readyz": { ok: true, status: 200 },
|
||||
// /v1/retrieve/stats rejects because it is not listed.
|
||||
"/stats": { ok: true, status: 200, body: proxyStatsBody },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result).toEqual({ reachable: true, isHeadroom: true });
|
||||
});
|
||||
|
||||
it("falls back to /stats only when the response has a Headroom stats shape", async () => {
|
||||
stubByPath({
|
||||
// /readyz and /v1/retrieve/stats unavailable (reject), /stats answers.
|
||||
"/stats": { ok: true, status: 200, body: proxyStatsBody },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result).toEqual({ reachable: true, isHeadroom: true });
|
||||
});
|
||||
|
||||
it("does not treat generic /stats 200 as Headroom identity", async () => {
|
||||
stubByPath({
|
||||
"/readyz": { ok: false, status: 404 },
|
||||
"/v1/retrieve/stats": { ok: false, status: 404 },
|
||||
"/stats": { ok: true, status: 200, body: JSON.stringify({ uptime: 123 }) },
|
||||
"/health": { ok: true, status: 200 },
|
||||
});
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(result.isHeadroom).toBe(false);
|
||||
});
|
||||
|
||||
it("returns reachable but non-headroom when identity endpoints are non-OK", async () => {
|
||||
stubProbeNonHeadroom();
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result.reachable).toBe(true);
|
||||
|
|
@ -86,7 +193,7 @@ describe("probeHeadroomProxy", () => {
|
|||
expect(result.reason).toMatch(/retrieve stats HTTP 404/);
|
||||
});
|
||||
|
||||
it("returns unreachable when health check fails", async () => {
|
||||
it("returns unreachable when no endpoint responds", async () => {
|
||||
stubProbeUnreachable();
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result.reachable).toBe(false);
|
||||
|
|
@ -98,12 +205,20 @@ describe("ProxyManager.start", () => {
|
|||
it("auto-detects running proxy on default candidates", async () => {
|
||||
const manager = new ProxyManager({});
|
||||
|
||||
// Candidate 1: health fail. Candidate 2: health+retrieve succeed.
|
||||
// Candidate 1 (127.0.0.1): all four probes fail.
|
||||
// Candidate 2 (localhost): /v1/retrieve/stats succeeds.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /readyz
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /v1/retrieve/stats
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /stats
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /health
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // localhost /readyz
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(retrieveStatsBody),
|
||||
}); // localhost /v1/retrieve/stats
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy");
|
||||
|
|
@ -136,11 +251,20 @@ describe("ProxyManager.start", () => {
|
|||
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1", autoStart: true });
|
||||
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
|
||||
|
||||
// Initial probe of the single candidate fails on all four endpoints, then
|
||||
// after auto-start the identity probe succeeds on /v1/retrieve/stats.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
.mockRejectedValueOnce(new Error("down")) // /readyz
|
||||
.mockRejectedValueOnce(new Error("down")) // /v1/retrieve/stats
|
||||
.mockRejectedValueOnce(new Error("down")) // /stats
|
||||
.mockRejectedValueOnce(new Error("down")) // /health
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // post-start /readyz
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(retrieveStatsBody),
|
||||
}); // post-start /v1/retrieve/stats
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const url = await manager.start();
|
||||
|
|
@ -179,13 +303,24 @@ describe("ProxyManager.start", () => {
|
|||
const manager = new ProxyManager({ autoStart: true });
|
||||
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
|
||||
|
||||
// First two candidate probes fail (health only), then waitForHealthy probe succeeds.
|
||||
// Both candidates fail all four probes, then the post-start identity probe
|
||||
// succeeds on /v1/retrieve/stats.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /readyz
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /v1/retrieve/stats
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /stats
|
||||
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /health
|
||||
.mockRejectedValueOnce(new Error("down")) // localhost /readyz
|
||||
.mockRejectedValueOnce(new Error("down")) // localhost /v1/retrieve/stats
|
||||
.mockRejectedValueOnce(new Error("down")) // localhost /stats
|
||||
.mockRejectedValueOnce(new Error("down")) // localhost /health
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // post-start /readyz
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(retrieveStatsBody),
|
||||
}); // post-start /v1/retrieve/stats
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const url = await manager.start();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue