From a6089de4658642014ab7163a45a54b19c8a4f20a Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Tue, 26 May 2026 22:13:56 +0200 Subject: [PATCH] fix(frontend): meaningful serverless metadata errors --- .../app/forms/serverless-endpoint-health.tsx | 29 +--- .../serverless-connection-check.stories.tsx | 150 ++++++++++++++++++ .../src/app/serverless-connection-check.tsx | 36 ++--- .../src/app/serverless-health-error.test.ts | 100 ++++++++++++ frontend/src/app/serverless-health-error.ts | 60 +++++++ 5 files changed, 328 insertions(+), 47 deletions(-) create mode 100644 frontend/src/app/serverless-connection-check.stories.tsx create mode 100644 frontend/src/app/serverless-health-error.test.ts create mode 100644 frontend/src/app/serverless-health-error.ts diff --git a/frontend/src/app/forms/serverless-endpoint-health.tsx b/frontend/src/app/forms/serverless-endpoint-health.tsx index 9ba062dd63..9de27c387c 100644 --- a/frontend/src/app/forms/serverless-endpoint-health.tsx +++ b/frontend/src/app/forms/serverless-endpoint-health.tsx @@ -20,7 +20,11 @@ import { useDebounceValue } from "usehooks-ts"; import z from "zod"; import { WithTooltip } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; -import { endpointSchema } from "@/app/serverless-connection-check"; +import { + endpointSchema, + formatServerlessMetadataError, + HEALTH_CHECK_FALLBACK_ERROR, +} from "@/app/serverless-connection-check"; type Status = "idle" | "loading" | "success" | "error"; @@ -182,34 +186,17 @@ export function EndpointHealthIndicator({ const failureSchema = z.object({ failure: z.object({ - error: z.object({ - message: z.string().optional(), - details: z.string().optional(), - metadata: z - .object({ - kind: z.string().optional(), - status_code: z.number().optional(), - }) - .partial() - .optional(), - }), + error: z.unknown(), }), }); function extractErrorMessage(data: unknown, error: unknown): string { - const fallback = "Health check failed. Verify the endpoint is reachable."; const parsed = failureSchema.safeParse(data); if (parsed.success) { - const { message, details, metadata } = parsed.data.failure.error; - if (message) { - return details ? `${message} (${details})` : message; - } - if (metadata?.kind && metadata.status_code) { - return `${metadata.kind.replace(/_/g, " ")} (HTTP ${metadata.status_code})`; - } + return formatServerlessMetadataError(parsed.data.failure.error); } if (error instanceof Error && error.message) { return error.message.slice(0, 200); } - return fallback; + return HEALTH_CHECK_FALLBACK_ERROR; } diff --git a/frontend/src/app/serverless-connection-check.stories.tsx b/frontend/src/app/serverless-connection-check.stories.tsx new file mode 100644 index 0000000000..092733ffa2 --- /dev/null +++ b/frontend/src/app/serverless-connection-check.stories.tsx @@ -0,0 +1,150 @@ +import type { Story } from "@ladle/react"; +import type { Rivet } from "@rivetkit/engine-api-full"; +import "../../.ladle/ladle.css"; +import { HealthCheckFailure } from "./serverless-connection-check"; + +type Failure = Rivet.RunnerConfigsServerlessHealthCheckResponseFailure["failure"]; + +// Each fixture mirrors the `{message, details, metadata}` envelope the engine +// emits for a `ServerlessMetadataError` variant (see +// `engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs`). Before the +// fix every one of these rendered as "Unknown error" because the formatter +// matched an obsolete discriminated-union shape. + +const INVALID_REQUEST: Failure = { + error: { + message: "invalid serverless metadata request", + metadata: { kind: "invalid_request" }, + }, +}; + +const REQUEST_FAILED: Failure = { + error: { + message: "failed to reach serverless endpoint", + metadata: { kind: "request_failed" }, + }, +}; + +const REQUEST_TIMED_OUT: Failure = { + error: { + message: "serverless metadata request timed out", + metadata: { kind: "request_timed_out" }, + }, +}; + +const NON_SUCCESS_STATUS: Failure = { + error: { + message: "serverless metadata request returned status 502", + metadata: { + kind: "non_success_status", + status_code: 502, + body: "Bad Gateway: upstream connection refused", + }, + }, +}; + +const INVALID_RESPONSE_JSON: Failure = { + error: { + message: "serverless metadata response is not valid JSON", + metadata: { + kind: "invalid_response_json", + body: "
504 Gateway Timeout", + parse_error: "expected value at line 1 column 1", + }, + }, +}; + +const INVALID_RESPONSE_SCHEMA: Failure = { + error: { + message: "serverless runtime express version 0.1.0 is unsupported", + metadata: { + kind: "invalid_response_schema", + runtime: "express", + version: "0.1.0", + }, + }, +}; + +const INVALID_ENVOY_PROTOCOL_VERSION: Failure = { + error: { + message: + "envoy protocol version 5 is not supported (max supported: 4)", + metadata: { + kind: "invalid_envoy_protocol_version", + envoy_protocol_version: 5, + max_supported_envoy_protocol_version: 4, + }, + }, +}; + +// Unrecognized envelope: the server message still surfaces even when the +// `kind` is one the frontend has never seen. +const UNKNOWN_KIND: Failure = { + error: { + message: "something new went wrong", + metadata: { kind: "some_future_variant", extra: "context" }, + }, +}; + +function Frame({ children }: { children: React.ReactNode }) { + return ( +Health check failed: {formatMetadataError(error.error)}
; + return ( +Health check failed: {formatServerlessMetadataError(error.error)}
+ ); } diff --git a/frontend/src/app/serverless-health-error.test.ts b/frontend/src/app/serverless-health-error.test.ts new file mode 100644 index 0000000000..f260003ea3 --- /dev/null +++ b/frontend/src/app/serverless-health-error.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + formatServerlessMetadataError, + HEALTH_CHECK_FALLBACK_ERROR, +} from "./serverless-health-error"; + +// Mirrors the `{message, details, metadata}` envelopes emitted by +// `ServerlessMetadataError` in +// `engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs`. +describe("formatServerlessMetadataError", () => { + it("surfaces the server message for simple variants", () => { + expect( + formatServerlessMetadataError({ + message: "failed to reach serverless endpoint", + metadata: { kind: "request_failed" }, + }), + ).toBe("failed to reach serverless endpoint"); + }); + + it("appends the response body for non_success_status", () => { + expect( + formatServerlessMetadataError({ + message: "serverless metadata request returned status 502", + metadata: { + kind: "non_success_status", + status_code: 502, + body: "Bad Gateway", + }, + }), + ).toBe("serverless metadata request returned status 502: Bad Gateway"); + }); + + it("appends the parse error for invalid_response_json", () => { + expect( + formatServerlessMetadataError({ + message: "serverless metadata response is not valid JSON", + metadata: { + kind: "invalid_response_json", + body: "", + parse_error: "expected value at line 1 column 1", + }, + }), + ).toBe( + "serverless metadata response is not valid JSON: expected value at line 1 column 1", + ); + }); + + it("surfaces the full message for invalid_envoy_protocol_version (the reported bug)", () => { + expect( + formatServerlessMetadataError({ + message: + "envoy protocol version 5 is not supported (max supported: 4)", + metadata: { + kind: "invalid_envoy_protocol_version", + envoy_protocol_version: 5, + max_supported_envoy_protocol_version: 4, + }, + }), + ).toBe("envoy protocol version 5 is not supported (max supported: 4)"); + }); + + it("appends details when present", () => { + expect( + formatServerlessMetadataError({ + message: "something failed", + details: "extra context", + metadata: { kind: "request_failed" }, + }), + ).toBe("something failed (extra context)"); + }); + + it("still surfaces the message for an unknown future kind", () => { + expect( + formatServerlessMetadataError({ + message: "something new went wrong", + metadata: { kind: "some_future_variant" }, + }), + ).toBe("something new went wrong"); + }); + + it("derives a readable string from kind when message is absent", () => { + expect( + formatServerlessMetadataError({ + metadata: { kind: "request_timed_out" }, + }), + ).toBe("request timed out"); + }); + + it("falls back when given an unparseable value", () => { + expect(formatServerlessMetadataError(undefined)).toBe( + HEALTH_CHECK_FALLBACK_ERROR, + ); + expect(formatServerlessMetadataError(null)).toBe( + HEALTH_CHECK_FALLBACK_ERROR, + ); + expect(formatServerlessMetadataError("a string")).toBe( + HEALTH_CHECK_FALLBACK_ERROR, + ); + }); +}); diff --git a/frontend/src/app/serverless-health-error.ts b/frontend/src/app/serverless-health-error.ts new file mode 100644 index 0000000000..87bd880fa9 --- /dev/null +++ b/frontend/src/app/serverless-health-error.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +export const HEALTH_CHECK_FALLBACK_ERROR = + "Health check failed. Verify the endpoint is reachable."; + +const metadataSchema = z + .object({ + kind: z.string().optional(), + status_code: z.number().optional(), + body: z.string().optional(), + parse_error: z.string().optional(), + runtime: z.string().optional(), + version: z.union([z.string(), z.number()]).optional(), + envoy_protocol_version: z.number().optional(), + max_supported_envoy_protocol_version: z.number().optional(), + }) + .partial(); + +const metadataErrorSchema = z.object({ + message: z.string().optional(), + details: z.string().optional(), + metadata: metadataSchema.optional(), +}); + +/** + * Formats a serverless health check error envelope into a display string. + * + * The engine surfaces every failure variant as a stable + * `{message, details, metadata}` envelope where `metadata.kind` discriminates + * the variant (see `ServerlessMetadataErrorEnvelope` in + * `engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs`). The server + * message is already human-readable for every variant, so we start with it and + * append the extra context the message itself omits (response body, JSON parse + * error). Falls back to a generic message only when nothing parseable is present. + */ +export function formatServerlessMetadataError(error: unknown): string { + const parsed = metadataErrorSchema.safeParse(error); + if (!parsed.success) { + return HEALTH_CHECK_FALLBACK_ERROR; + } + + const { message, details, metadata } = parsed.data; + const kind = metadata?.kind; + + let result = message ?? ""; + + if (kind === "non_success_status") { + const body = metadata?.body?.trim(); + if (body) result = result ? `${result}: ${body}` : body; + } else if (kind === "invalid_response_json") { + const extra = metadata?.parse_error?.trim() || metadata?.body?.trim(); + if (extra) result = result ? `${result}: ${extra}` : extra; + } + + if (details) result = result ? `${result} (${details})` : details; + + if (!result && kind) result = kind.replace(/_/g, " "); + + return result || HEALTH_CHECK_FALLBACK_ERROR; +}