diff --git a/request-logger/README.md b/request-logger/README.md index d32a239..021ad5e 100644 --- a/request-logger/README.md +++ b/request-logger/README.md @@ -42,11 +42,12 @@ If you are not sure, pick "not sure". The tool still logs everything; it just shows you the raw JSON instead of a fully readable render, because it cannot safely guess a shape you did not tell it. See "What you get" below. -For **OpenCode** with an **OpenAI-compatible** custom target, and for **every** -**Pi** custom target, the wizard also tries the unauthenticated `/v1/models` -endpoint and offers any model IDs it finds. You can always enter an ID -manually, and the wizard falls back to manual entry if discovery fails. -Endpoints that require credentials for model listing are not supported. +For **OpenCode** with an **OpenAI-compatible** custom target, for **every** +**Pi** custom target, and for **every Junie** target, the wizard also tries +the unauthenticated `/v1/models` endpoint and offers any model IDs it finds. +You can always enter an ID manually, and the wizard falls back to manual +entry if discovery fails. Endpoints that require credentials for model +listing are not supported. - OpenCode's printed command uses a temporary `OPENCODE_CONFIG_CONTENT` provider for that one run, merged with your existing OpenCode configuration @@ -62,6 +63,10 @@ Endpoints that require credentials for model listing are not supported. endpoint, which an Anthropic-compatible server often does not expose. If discovery finds nothing there, that is expected, and typing the model ID by hand is the normal path, not a sign something is broken. +- Junie asks for a model on every wire format too, for a different reason + than Pi: Junie sends the model profile's `id` verbatim as the `model` field + of every request, so it has to be a real model your backend recognizes, not + a placeholder — see the Junie section below. To change a remembered answer: @@ -139,16 +144,33 @@ is not wired up yet — ask for it via the issue tracker if you hit this. ### Junie Junie is BYOK across several backends, with no fixed host of its own — the -same shape as OMP — so it goes through the base URL and wire format +same shape as OMP — so it goes through the base URL, wire format and model questions like a custom target does, picking a real Anthropic-compatible or -OpenAI-compatible template depending which you choose. It writes a proxy -entry to `~/.junie/config.json` (user scope; a project-scope file at -`/.junie/config.json` takes precedence if you have one — see -Junie's own docs), merged in alongside whatever else is already there. +OpenAI-compatible template depending which you choose. It writes a **custom +model profile** to `~/.junie/models/request-logger.json`, and the command it +prints selects that profile explicitly with `--model custom:request-logger` +— Junie only uses a custom model when asked for by name. + +The model you pick goes into the profile's `id` field, which Junie sends +verbatim as the `model` field of every request — it is not a label for this +tool to key off of. An earlier version of this profile hard-coded `id` to +`"request-logger"`, which is not a real model on any backend: Anthropic +rejected every request with a 404 (`model: request-logger`), confirmed +against a live proxy. Pick a model your backend actually serves (e.g. +`claude-sonnet-4-5` against the real Anthropic API). + +Junie's own docs also describe a different mechanism for this: a `proxies` +entry in `~/.junie/config.json` with a `kind` of `"Anthropic"` or `"OpenAI"`. +Do not use that instead. It was tried first, and driven against a real Junie +CLI install (build 1543.24) it does not work — Junie never consults it, +headless or interactive, no matter what `kind` is set to, and just keeps +talking to its normal authenticated backend with no error and an empty logs +folder. The custom-model route above is the one that was actually driven +end to end, with a real proxy in front of it, on both wire formats. Junie is the one agent here with no existing login for this tool to pass -through. Its custom-proxy mechanism bypasses JetBrains AI authentication -entirely, so the printed config carries a placeholder header line and you +through. Its custom-model mechanism bypasses JetBrains AI authentication +entirely, so the printed profile carries a placeholder header line and you paste in a real API key yourself — an Anthropic key on the Anthropic-compatible route, an OpenAI key on the OpenAI-compatible one. Treat that file the way you would any other file holding a real key: do not @@ -315,13 +337,19 @@ Be fair to the tool when you judge a failure. full course of the lesson. Claude Code on Google Vertex AI is in this group — verified against Anthropic's own Vertex documentation, not yet driven against a real Vertex project. -- **Junie is the least-verified entry in the catalogue.** Junie CLI is - closed source, so unlike every other agent here its entry was not checked - against real source, only against JetBrains' published Junie CLI docs - (`config.json`, custom proxies, and CLI reference). It has not been driven - against a real Junie install. If the proxy `kind`, the config file's - precise shape, or the header format is wrong, that is the likely reason, - and the fix is one line in `agents.ts`. +- **Junie was driven against a real install (build 1543.24)**, unlike every + other closed-source claim in this list. That test is exactly why this + entry no longer follows Junie's own documented `proxies`/`config.json` + mechanism: on that real install, it never took effect, regardless of + `kind`, with no error to say so. The custom-model route it uses instead + (`~/.junie/models/*.json` selected via `--model custom:`) was + confirmed to actually deliver requests to a local listener, on both wire + formats, with the correct endpoint path in each case. What is not verified + is everything upstream of that: Junie's task-completion behavior, its + interactive TUI, and whether a different install or a future release + changes any of this. If something here breaks, that is the likely reason, + and the fix is likely still one line in `agents.ts` — just possibly a + different line than the `kind` field this note used to point at. - **A custom base URL is only as tested as the agent it is attached to.** The base URL and wire format mechanism itself is tested directly (see `agents.test.ts`); a specific third-party server behind it has not diff --git a/request-logger/agents.test.ts b/request-logger/agents.test.ts index d121e60..61916fe 100644 --- a/request-logger/agents.test.ts +++ b/request-logger/agents.test.ts @@ -1432,52 +1432,108 @@ describe("resolveChoice — OMP", () => { // --------------------------------------------------------------------------- describe("resolveChoice — Junie", () => { - it("resolves Junie straight from a base URL and wire format, with no provider needed", () => { + it("resolves Junie straight from a base URL, wire format and model, with no provider needed", () => { const result = resolveChoice( { agent: "junie", customBaseUrl: "http://localhost:8787", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }, PORT ); expect(result.kind).toBe("custom-target"); }); - it("gives Junie its own bin, with no env vars", () => { + it("tells Junie which custom model to use, with the endpoint path folded into the base URL", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); - expect(result.command).toBe("junie"); + expect(result.command).toBe("junie --model custom:request-logger"); + expect(result.baseUrl).toBe("http://localhost:8787/v1/messages"); }); - it("writes an Anthropic-kind proxy entry for the anthropic-compatible route", () => { + it("uses the OpenAI chat/completions path for the openai-compatible route", () => { + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "openai", + customModel: "gpt-4.1", + }); + expect(result.baseUrl).toBe("http://localhost:8787/v1/chat/completions"); + }); + + it("writes an Anthropic model profile for the anthropic-compatible route", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect(result.setup).toHaveLength(1); - expect(result.setup[0].path).toBe("~/.junie/config.json"); + expect(result.setup[0].path).toBe("~/.junie/models/request-logger.json"); expect(result.setup[0].language).toBe("json"); - expect(result.setup[0].body).toContain('"kind": "Anthropic"'); + expect(result.setup[0].body).toContain('"apiType": "Anthropic"'); expect(result.setup[0].body).toContain( - '"api-url": "http://localhost:8787"' + '"baseUrl": "http://localhost:8787/v1/messages"' ); - expect(result.setup[0].body).toContain("x-api-key:"); - expect(result.setup[0].body).toContain('"provider": "request-logger"'); + expect(result.setup[0].body).toContain('"x-api-key":'); }); - it("writes an OpenAI-kind proxy entry for the openai-compatible route", () => { + it("writes the chosen model into the profile's id field, since Junie sends it verbatim as the model on every request", () => { + // An earlier version hard-coded "id" to "request-logger", which is not a + // real model on any backend -- Anthropic rejected it with a 404 ("model: + // request-logger"), confirmed against a live proxy. + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", + }); + expect(result.setup[0].body).toContain('"id": "claude-sonnet-4-5"'); + expect(result.setup[0].body).not.toContain("request-logger"); + }); + + it("rejects a Junie custom target with no model chosen", () => { + const result = resolveChoice( + { + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + }, + PORT + ); + expect(result.kind).toBe("error"); + }); + + it("does not set anthropic-version itself, since Junie already sends one", () => { + // Junie's Anthropic client always sends its own anthropic-version + // header. An extraHeaders entry with the same name does not replace + // that, it is appended as a second occurrence, and the two get joined + // with a comma on the wire (e.g. "2023-06-01,2023-06-01") -- which + // Anthropic rejects as an invalid version code. Confirmed against a + // live proxy: this is what was producing a 400 on every request. + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", + }); + expect(result.setup[0].body).not.toContain("anthropic-version"); + }); + + it("writes an OpenAICompletion model profile for the openai-compatible route", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "openai", + customModel: "gpt-4.1", }); - expect(result.setup[0].body).toContain('"kind": "OpenAI"'); - expect(result.setup[0].body).toContain("Authorization: Bearer"); + expect(result.setup[0].body).toContain('"apiType": "OpenAICompletion"'); + expect(result.setup[0].body).toContain('"Authorization": "Bearer'); }); it("defaults Junie's raw/not-sure custom target to the OpenAI-kind template", () => { @@ -1488,8 +1544,9 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "raw", + customModel: "some-model", }); - expect(result.setup[0].body).toContain('"kind": "OpenAI"'); + expect(result.setup[0].body).toContain('"apiType": "OpenAICompletion"'); }); it("never sends Junie's Anthropic-kind api-key header on the OpenAI route, or vice versa", () => { @@ -1497,34 +1554,50 @@ describe("resolveChoice — Junie", () => { agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); const openaiRoute = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "openai", + customModel: "gpt-4.1", }); - expect(anthropicRoute.setup[0].body).not.toContain("Authorization: Bearer"); - expect(openaiRoute.setup[0].body).not.toContain("x-api-key:"); + expect(anthropicRoute.setup[0].body).not.toContain('"Authorization":'); + expect(openaiRoute.setup[0].body).not.toContain('"x-api-key":'); }); - it("warns that Junie's proxy bypasses JetBrains AI authentication, unlike every login-based agent here", () => { + it("warns that Junie's model profile bypasses JetBrains AI authentication, unlike every login-based agent here", () => { const result = customTarget({ agent: "junie", customBaseUrl: "http://localhost:11434", customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", }); expect(result.notes.some((note) => note.includes("bypasses"))).toBe(true); }); + it("warns that Junie's documented config.json proxy route does not actually work", () => { + const result = customTarget({ + agent: "junie", + customBaseUrl: "http://localhost:11434", + customRenderer: "anthropic", + customModel: "claude-sonnet-4-5", + }); + expect( + result.notes.some((note) => note.includes("never consulted")) + ).toBe(true); + }); + it("still resolves Junie even when the saved provider field is stale", () => { // alwaysCustom agents ignore whatever is saved under `provider`; only - // the custom base URL and renderer matter. + // the custom base URL, renderer and model matter. const result = resolveChoice( { agent: "junie", provider: "whatever-was-saved-before", customBaseUrl: "http://localhost:11434", customRenderer: "raw", + customModel: "some-model", }, PORT ); diff --git a/request-logger/agents.ts b/request-logger/agents.ts index 9687138..fe97605 100644 --- a/request-logger/agents.ts +++ b/request-logger/agents.ts @@ -287,37 +287,68 @@ const OMP_NOTE = "every OMP setup goes through the base URL and wire format you chose."; /** - * Junie's custom-proxy override, in JSON, under ~/.junie/config.json (user - * scope; see Junie's own configuration-files docs for how a project-scope - * file layers on top). Unlike ompModels/piModels, this one genuinely differs - * by wire format: Junie's proxy entry declares a `kind` — the protocol Junie - * itself will speak on the wire — so a mismatched kind does not 404, it just - * sends the wrong shape of request. That is why Junie gets two catalogue - * providers below (tagged by customTemplateFor) instead of OMP's one: the - * kind, and the auth header format that goes with it, must track the - * renderer the student actually picked. + * Junie's custom-model override, in JSON, under + * ~/.junie/models/request-logger.json (see Junie's own Custom LLM models + * docs). Junie also documents a *custom-proxy* mechanism — a "proxies" entry + * in ~/.junie/config.json with a `kind` of "Anthropic" or "OpenAI" — and an + * earlier version of this catalogue entry used that instead. It does not + * work: driven against a real Junie CLI install (build 1543.24), that + * config.json entry is never consulted, headless or interactive, regardless + * of `kind` — Junie silently keeps using its normal authenticated backend + * (JetBrains AI, or whatever BYOK key it already had) and nothing reaches + * this tool. The custom-model route below is a different mechanism that was + * verified to actually work the same way: a JSON profile Junie discovers + * from `~/.junie/models/*.json` and that must be selected explicitly with + * `--model custom:request-logger` (see the Junie ProviderEntry below). * - * `authHeader` is a placeholder line, not a real credential — see JUNIE_NOTE. + * Unlike the proxy route's `api-url`, a model profile's `baseUrl` is the + * *full* endpoint path, not an origin — Junie posts to it verbatim with no + * path appended. Getting the suffix right (`/v1/messages` for Anthropic, + * `/v1/chat/completions` for OpenAI — see each ProviderEntry.suffix below) + * is what stands between a working capture and a silent 404. Confirmed by + * driving both wire formats against a live proxy. + * + * `extraHeaders` holds only the auth header. An earlier version also added + * `anthropic-version` here, on the assumption that Junie's Anthropic client + * would not set it itself. It does — Junie always sends its own + * `anthropic-version: 2023-06-01`, and an `extraHeaders` entry does not + * replace that, it is appended as a second occurrence of the same header + * name. The two get joined with a comma on the wire + * (`anthropic-version: 2023-06-01,2023-06-01`), and Anthropic rejects that + * as an invalid version code — a 400 from a request that looks, at a + * glance, correctly authenticated. Confirmed against a live proxy: removing + * the header here is what fixed it, so nothing here should re-add it. + * + * `authHeaderValue` is a placeholder line, not a real credential — see + * JUNIE_NOTE. + * + * `id` is not a label for this tool — Junie sends it verbatim as the + * `model` field of every request it makes on this profile. An earlier + * version hard-coded it to `"request-logger"`, which is not a model any + * real backend recognizes: Anthropic rejected it with a 404 ("model: + * request-logger"), confirmed against a live proxy. It must be a real + * model ID for whichever backend `baseUrl` points at, which is why + * customTargetNeedsModel (below) asks the student for one instead of this + * function inventing a name. */ -function junieConfig( +function junieModelConfig( baseUrl: string, - kind: "Anthropic" | "OpenAI", - authHeader: string + model: string, + apiType: "Anthropic" | "OpenAICompletion", + authHeaderName: string, + authHeaderValue: string ): SetupFile { return { - path: "~/.junie/config.json", + path: "~/.junie/models/request-logger.json", language: "json", body: [ "{", - ' "proxies": [', - " {", - ' "name": "request-logger",', - ` "kind": "${kind}",`, - ` "api-url": "${baseUrl}",`, - ` "headers": ["${authHeader}"]`, - " }", - " ],", - ' "provider": "request-logger"', + ` "baseUrl": "${baseUrl}",`, + ` "id": "${model}",`, + ` "apiType": "${apiType}",`, + ' "extraHeaders": {', + ` "${authHeaderName}": "${authHeaderValue}"`, + " }", "}", ].join("\n"), }; @@ -325,15 +356,17 @@ function junieConfig( const JUNIE_NOTE = "Junie has no existing login for this tool to pass through the way Claude " + - "Code or Codex do. Its custom proxy bypasses JetBrains AI authentication " + + "Code or Codex do. This model profile bypasses JetBrains AI authentication " + "entirely, so a real API key for whichever backend you are logging goes " + - "straight into config.json's headers array in plaintext. Do not commit " + - "this file."; + "straight into the file in plaintext. Do not commit it."; -const JUNIE_MERGE_NOTE = - "This is merged into ~/.junie/config.json, not a replacement for it — add " + - "the proxies entry and the provider key alongside whatever else is " + - "already in that file."; +const JUNIE_PROXY_DOES_NOT_WORK_NOTE = + "Junie's docs also describe a \"proxies\" entry in ~/.junie/config.json " + + "for this. Do not use it instead of the file below: verified against a " + + "real Junie CLI install (build 1543.24), that config is never consulted " + + "— Junie keeps using its normal authenticated backend no matter what " + + "`kind` is set to, with no error and an empty logs folder. The model " + + "profile below is the mechanism that was actually driven end to end."; const OPENCODE_NOTE = "The environment variable above works, but only by accident: OpenCode passes " + @@ -714,10 +747,13 @@ const AGENTS: AgentEntry[] = [ // own — the same shape as OMP — so every setup for it is custom too. See // AgentEntry.alwaysCustom. // - // Junie CLI is closed source, so unlike every other entry in this - // catalogue this one is verified against JetBrains' published docs only, - // not against real source or a real install. Testing status is recorded - // in the README's "How much this was tested" section, the same way every + // Junie CLI is closed source, so most of this catalogue's other entries + // cannot be checked against real source the way, say, Claude Code's can. + // This entry is the one exception that was actually driven against a + // real Junie CLI install (build 1543.24) rather than only against + // JetBrains' published docs — see junieModelConfig's comment above for + // what that testing found. Overall testing status is still recorded in + // the README's "How much this was tested" section, the same way every // other agent's is; it is not printed to the student, the same way no // other agent's is either. alwaysCustom: true, @@ -729,16 +765,23 @@ const AGENTS: AgentEntry[] = [ // path that would read this. See resolveCustomTarget. upstreamHost: "", renderer: "raw", + // A model profile's baseUrl is the full endpoint path, not an + // origin — see junieModelConfig's comment. + suffix: "/v1/messages", bin: "junie", + // Junie only looks at a custom model when it is asked for by name. + args: ["--model", "custom:request-logger"], customTemplateFor: ["anthropic"], setup: [ - junieConfig( + junieModelConfig( "{baseUrl}", + "{model}", "Anthropic", - "x-api-key: YOUR_ANTHROPIC_API_KEY" + "x-api-key", + "YOUR_ANTHROPIC_API_KEY" ), ], - notes: [JUNIE_NOTE, JUNIE_MERGE_NOTE], + notes: [JUNIE_NOTE, JUNIE_PROXY_DOES_NOT_WORK_NOTE], }, { id: "openai", @@ -748,19 +791,23 @@ const AGENTS: AgentEntry[] = [ // this. See resolveCustomTarget. upstreamHost: "", renderer: "raw", + suffix: "/v1/chat/completions", bin: "junie", + args: ["--model", "custom:request-logger"], // Also the catch-all for "raw"/not sure — see the OpenCode and Pi // entries above for why an OpenAI-compatible guess is the better // default for an unidentified custom server. customTemplateFor: ["openai", "raw"], setup: [ - junieConfig( + junieModelConfig( "{baseUrl}", - "OpenAI", - "Authorization: Bearer YOUR_OPENAI_API_KEY" + "{model}", + "OpenAICompletion", + "Authorization", + "Bearer YOUR_OPENAI_API_KEY" ), ], - notes: [JUNIE_NOTE, JUNIE_MERGE_NOTE], + notes: [JUNIE_NOTE, JUNIE_PROXY_DOES_NOT_WORK_NOTE], }, ], }, @@ -1024,12 +1071,17 @@ const RAW_WIRE_FORMAT_NOTE = * an existing built-in provider (openai or anthropic), and that * provider's built-in model names almost never exist on a self-hosted * backend, whichever wire format was chosen for rendering. + * - Junie needs one on every route: a model profile's `id` is not a label, + * Junie sends it verbatim as every request's `model` field (see + * junieModelConfig), so an invented placeholder there is rejected by the + * real backend with a 404 — confirmed against a live proxy. */ export function customTargetNeedsModel( agentId: string, renderer: RendererId ): boolean { if (agentId === "pi") return true; + if (agentId === "junie") return true; if (agentId === "opencode") return renderer === "openai"; return false; } @@ -1202,6 +1254,17 @@ function resolveCustomTarget( ]; if (renderer === "raw") notes.push(RAW_WIRE_FORMAT_NOTE); + // Junie is the only agent that reaches this generic branch and also needs + // a model declared up front — its setup file's {model} placeholder is + // filled in below the same way {baseUrl} always has been. See + // customTargetNeedsModel and junieModelConfig for why. + let model: string | undefined; + if (customTargetNeedsModel(agent.id, renderer)) { + const modelResult = resolveCustomModel(choice, `${agent.label}'s custom target`); + if (modelResult.kind === "error") return modelResult; + model = modelResult.model; + } + return { kind: "custom-target", agent: agent.id, @@ -1213,7 +1276,9 @@ function resolveCustomTarget( command: template ? buildCommand(template, baseUrl, platform) : agent.id, setup: (template?.setup ?? []).map((file) => ({ ...file, - body: file.body.replace(/\{baseUrl\}/g, baseUrl), + body: file.body + .replace(/\{baseUrl\}/g, baseUrl) + .replace(/\{model\}/g, model ?? ""), })), notes, warnings: template?.warnings ?? [], diff --git a/request-logger/config.ts b/request-logger/config.ts index c54b25e..857891e 100644 --- a/request-logger/config.ts +++ b/request-logger/config.ts @@ -178,9 +178,10 @@ async function askModelIdManually(): Promise { /** * Discover the model IDs a custom base URL serves, and ask which one to use * when there is a real choice — shared by every custom target that needs a - * model declared up front: OpenCode's custom OpenAI-compatible route, and - * every one of Pi's custom routes. Same shape both times: discover, offer a - * choice when there is more than one candidate, fall back to manual entry. + * model declared up front: OpenCode's custom OpenAI-compatible route, every + * one of Pi's custom routes, and every one of Junie's. Same shape every + * time: discover, offer a choice when there is more than one candidate, + * fall back to manual entry. */ async function askDiscoveredModel(baseUrl: string): Promise { const modelIds = await discoverModelIds(baseUrl); @@ -255,13 +256,18 @@ export async function askChoice(options: AskOptions): Promise { if (agent?.alwaysCustom) { // Every setup for this agent is a custom target — there is no fixed // provider to fall back to, so the provider question is skipped and the - // two custom questions are asked directly instead. + // custom questions are asked directly instead: base URL and wire format + // always, plus a model when customTargetNeedsModel says this agent + // needs one declared up front (Junie does, on every route). const custom = await askCustomTarget(); choice = { agent: agentId, provider: CUSTOM_ID, customBaseUrl: custom.baseUrl, customRenderer: custom.renderer, + customModel: customTargetNeedsModel(agentId, custom.renderer) + ? await askDiscoveredModel(custom.baseUrl) + : undefined, }; } else { // Every supported agent asks a provider question now, even one with a diff --git a/request-logger/proxy.test.ts b/request-logger/proxy.test.ts index b3fb492..520ce9b 100644 --- a/request-logger/proxy.test.ts +++ b/request-logger/proxy.test.ts @@ -6,6 +6,7 @@ import { BURST_THRESHOLD, BURST_WINDOW_MS, burstKey, + describeError, rejectUpgrade, trackBurst, upstreamConnection, @@ -128,6 +129,42 @@ describe("upstreamConnection", () => { }); }); +describe("describeError", () => { + it("uses the message on a plain Error", () => { + expect(describeError(new Error("connect ECONNREFUSED 127.0.0.1:8787"))).toBe( + "connect ECONNREFUSED 127.0.0.1:8787" + ); + }); + + it("falls back to the error code when the message is blank", () => { + const err = new Error(""); + (err as NodeJS.ErrnoException).code = "ECONNREFUSED"; + expect(describeError(err)).toBe("ECONNREFUSED"); + }); + + it("unpacks an AggregateError instead of printing its always-blank message", () => { + // Node throws exactly this shape connecting to a dual-stack host (e.g. + // "localhost") with nothing listening: the outer AggregateError.message + // is "", and the real reasons are one level down in .errors. This is + // the bug that made every failed upstream connection print + // "[request-logger] upstream error:" with nothing after the colon. + const err = new AggregateError( + [ + new Error("connect ECONNREFUSED 127.0.0.1:59999"), + new Error("connect ECONNREFUSED ::1:59999"), + ], + "" + ); + expect(describeError(err)).toBe( + "connect ECONNREFUSED 127.0.0.1:59999; connect ECONNREFUSED ::1:59999" + ); + }); + + it("falls back to String() for a non-Error throw", () => { + expect(describeError("socket hang up")).toBe("socket hang up"); + }); +}); + describe("rejectUpgrade", () => { let server: http.Server | undefined; diff --git a/request-logger/proxy.ts b/request-logger/proxy.ts index d3f4f62..6cb95f9 100644 --- a/request-logger/proxy.ts +++ b/request-logger/proxy.ts @@ -85,6 +85,30 @@ export function upstreamConnection(target: ProxyTarget): { }; } +/** + * A readable description of a failed upstream connection. + * + * Node's http/https client resolves `localhost` (and any other dual-stack + * host) by trying IPv4 and IPv6 in parallel. When both attempts fail — most + * commonly because nothing is listening at the configured upstream at all — + * Node throws an AggregateError whose own `.message` is always the empty + * string; the real reasons live one level down, in `.errors`. Printing + * `err.message` directly on that error prints nothing, which is exactly what + * was happening here: a student watching a wall of blank + * "[request-logger] upstream error:" lines with no way to tell what's wrong. + * This reaches into `.errors` (and falls back to `.code`, then `.message`) + * so the printed line always says why the connection actually failed. + */ +export function describeError(err: unknown): string { + if (err instanceof AggregateError && err.errors.length > 0) { + return err.errors.map((e) => describeError(e)).join("; "); + } + if (err instanceof Error) { + return err.message || (err as NodeJS.ErrnoException).code || err.name; + } + return String(err); +} + /** * Headers forwarded upstream. We strip hop-by-hop headers, and we ask for an * uncompressed response so the capture is readable, then recompute the length @@ -166,12 +190,31 @@ function handle( : http.request(requestOptions, onUpstreamResponse); upstreamReq.on("error", (err) => { - console.error(`[request-logger] upstream error: ${err.message}`); + const description = describeError(err); + // A dead upstream fails every retry identically and fast, the same + // shape of storm BURST_THRESHOLD exists for below — so this shares + // that guard rather than flooding the console on its own. There is no + // real status code here (the connection never got that far), so 0 + // stands in for one: burstKey only uses it to tell captures apart. + const burst = trackBurst( + burstState, + burstKey(req.method ?? "POST", reqPath, 0), + Date.now() + ); + burstState = burst.state; + if (!burst.suppressed) { + console.error(`[request-logger] upstream error: ${description}`); + } else if (burst.justDetected) { + console.error( + `[request-logger] upstream error: ${description} ` + + `(repeating fast — further failures of this exact call are now suppressed)` + ); + } if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); } res.end( - JSON.stringify({ error: `request-logger upstream error: ${err.message}` }) + JSON.stringify({ error: `request-logger upstream error: ${description}` }) ); });