Compare commits

...
2 Commits
Author SHA1 Message Date
Sai-karthik ea8f4c3df3 surface matchmaking agent failures 2026-07-17 07:50:25 +00:00
Sai-karthik 6d2eb6e22a fix matchmaking gateway failures 2026-07-17 07:40:07 +00:00
2 changed files with 63 additions and 12 deletions
+18 -2
View File
@@ -1,11 +1,18 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
import { config } from "../src/config.js";
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const calls: Array<{ url: string; body: Record<string, unknown>; headers: Headers }> = [];
globalThis.fetch = (async (input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
calls.push({ url: String(input), body });
calls.push({ url: String(input), body, headers: new Headers(init?.headers) });
if (body.action === "force_forbidden") {
return new Response(JSON.stringify({ detail: "Invalid A2A auth token" }), { status: 403, headers: { "content-type": "application/json" } });
}
if (body.action === "agent_error") {
return new Response(JSON.stringify({ task_id: "a2a-task", status: "completed", messages: [{ type: "agent_error", action: "search_failed", data: { code: "job_boards_unavailable" } }] }), { status: 200, headers: { "content-type": "application/json" } });
}
return new Response(JSON.stringify({ task_id: "a2a-task", status: "completed", messages: [] }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
@@ -24,6 +31,15 @@ try {
const matchmakingResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "mark_applied", params: { opportunity_id: "job-1" } }) });
assert.equal(matchmakingResponse.status, 200);
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
assert.equal(calls[0]?.headers.get("authorization"), config.a2aAllowedKey ? `Bearer ${config.a2aAllowedKey}` : null);
const forbiddenResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "force_forbidden" }) });
assert.equal(forbiddenResponse.status, 502);
assert.match(await forbiddenResponse.text(), /matchmaking service authentication failed/);
const agentErrorResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "agent_error" }) });
assert.equal(agentErrorResponse.status, 502);
assert.match(await agentErrorResponse.text(), /could not complete the search/);
} finally {
globalThis.fetch = originalFetch;
}
+45 -10
View File
@@ -389,17 +389,48 @@ async function callMatchmakingA2a(body: Record<string, unknown>, userId: string)
action: action === "get_feed" ? "get_scout_feed" : action,
user_id: getString(body.user_id) ?? userId,
};
const res = await fetch(target, {
method: "POST",
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
},
body: JSON.stringify(payload),
});
const startedAt = Date.now();
let res: Response;
try {
res = await fetch(target, {
method: "POST",
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
},
body: JSON.stringify(payload),
});
} catch (err) {
log.warn({ err, service: "matchmaking", action, userId, durationMs: Date.now() - startedAt }, "matchmaking gateway request failed");
throw new HTTPException(502, { message: "matchmaking service is unavailable" });
}
const text = await res.text();
const result = text ? JSON.parse(text) as Record<string, unknown> : {};
if (!res.ok) throw new HTTPException(res.status as never, { message: text || "matchmaking request failed" });
let result: Record<string, unknown> = {};
if (text) {
try {
const parsed = JSON.parse(text);
result = isRecord(parsed) ? parsed : { value: parsed };
} catch {
result = { detail: text };
}
}
if (!res.ok) {
const upstreamAuthFailure = res.status === 401 || res.status === 403;
log.warn({
service: "matchmaking",
action,
userId,
upstreamStatus: res.status,
durationMs: Date.now() - startedAt,
}, "matchmaking gateway upstream rejected request");
// A downstream service credential failure is infrastructure, not the
// browser user's auth. Returning 502 prevents an unnecessary Clerk token
// refresh/retry that would simply repeat the same rejected request.
throw new HTTPException((upstreamAuthFailure ? 502 : res.status) as never, {
message: upstreamAuthFailure ? "matchmaking service authentication failed" : text || "matchmaking request failed",
});
}
log.info({ service: "matchmaking", action, userId, upstreamStatus: res.status, durationMs: Date.now() - startedAt }, "matchmaking gateway request completed");
return { action: String(payload.action || action), result };
}
@@ -1227,6 +1258,10 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>().catch((): JsonObject => ({}));
const { result } = await callMatchmakingA2a({ ...body, user_id: userId }, userId);
if (resultHasAgentError(result)) {
log.warn({ service: "matchmaking", action: body.action, userId }, "matchmaking agent reported task failure");
throw new HTTPException(502, { message: "matchmaking service could not complete the search" });
}
// matchmaking-service emits canonical GrowEvents itself. The gateway is a
// transport proxy only, avoiding a second event for the same action.
return c.json(result);