fix(server): keep the connection health policy of master for stream failures

Master keeps a remote MCP connection healthy after a tool call timeout,
and its tests check this. The event stream change marked the connection
degraded after a timeout and unhealthy after a transport failure. This
commit removes these two health writes, so a timeout or a peer reset
fails only that call, as on master. Authorization failures still mark
the connection unhealthy.

The call evidence now records the response status, content type and
request id before the body is read. A body that fails partway records
the bytes received so far as its size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pavel.guseynovandClaude Opus 5.5 committed 2026-11-07 11:52:17 +05:00
1 parent 6742569933
commit 717e6c77cd
2 files changed
+14 -16

No files matched your search

+1 -1
View File
@@ -4629,7 +4629,7 @@ rl.on("line", (line) => {
} else { } else {
const timeout = scenario === "default deadline" || scenario === "explicit deadline"; const timeout = scenario === "default deadline" || scenario === "explicit deadline";
expectGatewayError(result.error, timeout ? 504 : 502, timeout ? "tool_timeout" : "mcp_remote_fetch_failed"); expectGatewayError(result.error, timeout ? 504 : 502, timeout ? "tool_timeout" : "mcp_remote_fetch_failed");
expect(stored.healthStatus).toBe(timeout ? "degraded" : "error"); expect(stored.healthStatus).toBe("ok");
expect(event.metadata).toMatchObject({ execution: { expect(event.metadata).toMatchObject({ execution: {
failureKind: timeout ? "invocation_timeout" : "transport_failure", failureKind: timeout ? "invocation_timeout" : "transport_failure",
deadline: { timeoutMs: scenario === "explicit deadline" ? 1500 : 30_000, aborted: timeout }, deadline: { timeoutMs: scenario === "explicit deadline" ? 1500 : 30_000, aborted: timeout },
+13 -15
View File
@@ -6521,6 +6521,17 @@ export function createToolGatewayService(
// automatically: the failed call may have changed app data. // automatically: the failed call may have changed app data.
if (mcpSession) forgetMcpHttpSession(mcpSession); if (mcpSession) forgetMcpHttpSession(mcpSession);
} }
// Record the response headers before the body is read, so a body that
// fails partway still has its status and content type in the evidence.
execution.response = {
httpStatus: response.status,
contentType: response.headers.get("content-type"),
bodySizeBytes: 0,
upstreamRequestId:
response.headers.get("x-request-id") ??
response.headers.get("x-zapier-request-id") ??
response.headers.get("traceparent"),
};
const body = response.ok const body = response.ok
? JSON.stringify(await readMcpHttpResponse(response, requestId, { ? JSON.stringify(await readMcpHttpResponse(response, requestId, {
maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES, maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES,
@@ -6542,15 +6553,7 @@ export function createToolGatewayService(
}, },
})) }))
: await readBoundedRemoteResponse(response); : await readBoundedRemoteResponse(response);
execution.response = { execution.response.bodySizeBytes = Buffer.byteLength(body, "utf8");
httpStatus: response.status,
contentType: response.headers.get("content-type"),
bodySizeBytes: Buffer.byteLength(body, "utf8"),
upstreamRequestId:
response.headers.get("x-request-id") ??
response.headers.get("x-zapier-request-id") ??
response.headers.get("traceparent"),
};
if (isInsufficientConnectionScope(response, body)) { if (isInsufficientConnectionScope(response, body)) {
await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", {
@@ -6653,13 +6656,10 @@ export function createToolGatewayService(
} catch (error) { } catch (error) {
execution.error = remoteHttpErrorEvidence(error); execution.error = remoteHttpErrorEvidence(error);
execution.deadline = { timeoutMs: ms, aborted: controller.signal.aborted }; execution.deadline = { timeoutMs: ms, aborted: controller.signal.aborted };
if (execution.response && execution.stream) execution.response.bodySizeBytes = execution.stream.bytesReceived;
if (controller.signal.aborted || (error instanceof Error && error.name === "AbortError")) { if (controller.signal.aborted || (error instanceof Error && error.name === "AbortError")) {
if (mcpSession) forgetMcpHttpSession(mcpSession); if (mcpSession) forgetMcpHttpSession(mcpSession);
execution.failureKind = "invocation_timeout"; execution.failureKind = "invocation_timeout";
await markCallHealth(
"degraded",
"Remote MCP tool call timed out; transport remains viable.",
);
throw new ToolGatewayHttpError(504, "Remote MCP tool call timed out", "tool_timeout", { throw new ToolGatewayHttpError(504, "Remote MCP tool call timed out", "tool_timeout", {
connectionId: connection.id, catalogEntryId: entry.id, execution, failureKind: "invocation_timeout", connectionId: connection.id, catalogEntryId: entry.id, execution, failureKind: "invocation_timeout",
}); });
@@ -6696,8 +6696,6 @@ export function createToolGatewayService(
execution.failureKind = "transport_failure"; execution.failureKind = "transport_failure";
if (error instanceof McpHttpInitializationError && (error.status === 401 || error.status === 403)) { if (error instanceof McpHttpInitializationError && (error.status === 401 || error.status === 403)) {
await markCallHealth("error", "Remote MCP session authorization failed."); await markCallHealth("error", "Remote MCP session authorization failed.");
} else {
await markCallHealth("error", "Remote MCP tool call failed.");
} }
if (mcpSession) forgetMcpHttpSession(mcpSession); if (mcpSession) forgetMcpHttpSession(mcpSession);
throw new ToolGatewayHttpError( throw new ToolGatewayHttpError(