From d1d1d46d483d524a37435a779101b0b330e8a174 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Thu, 24 Sep 2026 11:04:46 +0200 Subject: [PATCH] feat(server): structured error contract for the RPC API Every error leaving /rpc now carries a google.rpc.ErrorInfo detail with a stable reason, the request id and a docs link, matching the v1 envelope. Plan limits map to permission_denied on every path and expose limit/max/current; the docs link follows the v1 reason so 402 and 429 no longer share a section. Adds the API errors reference page, the SDK error-handling docs, and a redirect for the old per-code error links. --- apps/server/src/libs/errors/rpc.test.ts | 325 +++++++++ apps/server/src/libs/errors/rpc.ts | 226 ++++++ apps/server/src/libs/errors/utils.ts | 13 +- .../src/libs/middlewares/concurrency.test.ts | 26 +- .../src/libs/middlewares/rate-limit.test.ts | 5 +- apps/server/src/libs/middlewares/shed.ts | 33 +- .../src/routes/rpc/__tests__/adapter.test.ts | 130 ++++ .../routes/rpc/__tests__/errors.e2e.test.ts | 153 ++++ apps/server/src/routes/rpc/adapter.ts | 98 ++- apps/server/src/routes/rpc/errors.ts | 80 +++ .../routes/rpc/handlers/maintenance/errors.ts | 111 +-- .../routes/rpc/handlers/maintenance/index.ts | 18 +- .../handlers/monitor/converters/assertions.ts | 15 +- .../src/routes/rpc/handlers/monitor/errors.ts | 174 ++--- .../src/routes/rpc/handlers/monitor/limits.ts | 29 +- .../routes/rpc/handlers/monitor/validators.ts | 19 +- .../rpc/handlers/notification/converters.ts | 16 +- .../rpc/handlers/notification/errors.ts | 171 +---- .../rpc/handlers/notification/limits.ts | 2 +- .../__tests__/private-location.test.ts | 11 +- .../rpc/handlers/private-location/errors.ts | 62 +- .../rpc/handlers/private-location/limits.ts | 7 +- .../status-page/__tests__/status-page.test.ts | 2 +- .../routes/rpc/handlers/status-page/errors.ts | 275 ++------ .../routes/rpc/handlers/status-page/index.ts | 147 ++-- .../routes/rpc/handlers/status-page/limits.ts | 49 +- .../rpc/handlers/status-report/converters.ts | 18 +- .../rpc/handlers/status-report/errors.ts | 165 +---- .../rpc/handlers/status-report/index.ts | 18 +- apps/server/src/routes/rpc/index.ts | 34 +- .../rpc/interceptors/__tests__/error.test.ts | 129 +++- .../src/routes/rpc/interceptors/auth.ts | 28 +- .../src/routes/rpc/interceptors/context.ts | 13 +- .../src/routes/rpc/interceptors/error.ts | 59 +- .../src/routes/rpc/interceptors/validation.ts | 35 +- apps/server/static/openapi-v1.json | 26 +- apps/web/next.config.ts | 7 + apps/web/src/content/docs.config.ts | 1 + .../pages/docs/reference/api-errors.mdx | 201 ++++++ .../pages/docs/reference/api-rate-limits.mdx | 13 +- .../content/pages/docs/reference/overview.mdx | 1 + .../pages/docs/sdk/nodejs/error-handling.mdx | 31 + packages/error/src/utils.ts | 5 + .../gen/ts/google/rpc/error_details_pb.ts | 662 ++++++++++++++++++ packages/proto/gen/ts/google/rpc/index.ts | 2 + packages/proto/package.json | 6 +- packages/services/src/errors.ts | 2 +- 47 files changed, 2626 insertions(+), 1027 deletions(-) create mode 100644 apps/server/src/libs/errors/rpc.test.ts create mode 100644 apps/server/src/libs/errors/rpc.ts create mode 100644 apps/server/src/routes/rpc/__tests__/adapter.test.ts create mode 100644 apps/server/src/routes/rpc/__tests__/errors.e2e.test.ts create mode 100644 apps/server/src/routes/rpc/errors.ts create mode 100644 apps/web/src/content/pages/docs/reference/api-errors.mdx create mode 100644 packages/proto/gen/ts/google/rpc/error_details_pb.ts create mode 100644 packages/proto/gen/ts/google/rpc/index.ts diff --git a/apps/server/src/libs/errors/rpc.test.ts b/apps/server/src/libs/errors/rpc.test.ts new file mode 100644 index 00000000..8ac572e5 --- /dev/null +++ b/apps/server/src/libs/errors/rpc.test.ts @@ -0,0 +1,325 @@ +import type { JsonValue } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { errorFromJson } from "@connectrpc/connect/protocol-connect"; +import { ErrorCodes, errorDocsUrl } from "@openstatus/error"; +import { + BadRequestSchema, + ErrorInfoSchema, + RetryInfoSchema, +} from "@openstatus/proto/google/rpc"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + ERROR_CODE_TO_CONNECT, + ERROR_DOMAIN, + ErrorReason, + connectCodeToErrorCode, + connectErrorToJson, + rpcError, + withErrorInfo, +} from "./rpc"; + +const info = (err: ConnectError) => err.findDetails(ErrorInfoSchema)[0]; + +describe("errorDocsUrl", () => { + test("anchors on the lowercase, hyphenated code", () => { + expect(errorDocsUrl("SERVICE_UNAVAILABLE")).toBe( + "https://www.openstatus.dev/docs/reference/api-errors#service-unavailable", + ); + expect(errorDocsUrl("NOT_FOUND")).toBe( + "https://www.openstatus.dev/docs/reference/api-errors#not-found", + ); + }); + + test("every v1 code resolves to a distinct anchor", () => { + const anchors = new Set(ErrorCodes.map((c) => errorDocsUrl(c))); + expect(anchors.size).toBe(ErrorCodes.length); + }); +}); + +describe("rpcError", () => { + test("attaches google.rpc.ErrorInfo with reason, domain and metadata", () => { + const err = rpcError({ + code: Code.NotFound, + reason: ErrorReason.MONITOR_NOT_FOUND, + message: "Monitor not found", + metadata: { monitorId: "42" }, + }); + expect(err).toBeInstanceOf(ConnectError); + expect(err.code).toBe(Code.NotFound); + expect(err.rawMessage).toBe("Monitor not found"); + expect(info(err)).toMatchObject({ + reason: "MONITOR_NOT_FOUND", + domain: ERROR_DOMAIN, + metadata: { monitorId: "42" }, + }); + }); + + test("keeps the cause off the wire but on the error", () => { + const cause = new Error("db exploded"); + const err = rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: "Internal", + cause, + }); + expect(err.cause).toBe(cause); + expect(connectErrorToJson(err)).not.toHaveProperty("cause"); + }); + + test("retryAfterSeconds adds google.rpc.RetryInfo, never below one second", () => { + const err = rpcError({ + code: Code.Unavailable, + reason: ErrorReason.SERVICE_UNAVAILABLE, + message: "busy", + retryAfterSeconds: 0.2, + }); + const [retry] = err.findDetails(RetryInfoSchema); + expect(retry.retryDelay?.seconds).toBe(1n); + + const five = rpcError({ + code: Code.Unavailable, + reason: ErrorReason.SERVICE_UNAVAILABLE, + message: "busy", + retryAfterSeconds: 5, + }); + expect(five.findDetails(RetryInfoSchema)[0].retryDelay?.seconds).toBe(5n); + }); + + test("fieldViolations add google.rpc.BadRequest", () => { + const err = rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: "bad", + fieldViolations: [{ field: "url", description: "must be a URL" }], + }); + const [bad] = err.findDetails(BadRequestSchema); + expect(bad.fieldViolations).toHaveLength(1); + expect(bad.fieldViolations[0]).toMatchObject({ + field: "url", + description: "must be a URL", + }); + expect(err.findDetails(RetryInfoSchema)).toHaveLength(0); + }); + + test("omits optional details when not asked for", () => { + const err = rpcError({ + code: Code.NotFound, + reason: ErrorReason.NOT_FOUND, + message: "x", + }); + expect(err.details).toHaveLength(1); + }); +}); + +describe("withErrorInfo", () => { + test("adds a generic reason, docs and requestId to a bare ConnectError", () => { + const bare = new ConnectError("nope", Code.NotFound); + const err = withErrorInfo(bare, "req-1"); + expect(err).toBe(bare); + expect(info(err)).toMatchObject({ + reason: "NOT_FOUND", + domain: ERROR_DOMAIN, + metadata: { requestId: "req-1", docs: errorDocsUrl("NOT_FOUND") }, + }); + }); + + test("keeps a specific reason and merges metadata", () => { + const err = withErrorInfo( + rpcError({ + code: Code.NotFound, + reason: ErrorReason.MONITOR_NOT_FOUND, + message: "x", + metadata: { monitorId: "7" }, + }), + "req-2", + ); + expect(err.findDetails(ErrorInfoSchema)).toHaveLength(1); + expect(info(err)).toMatchObject({ + reason: "MONITOR_NOT_FOUND", + metadata: { + monitorId: "7", + requestId: "req-2", + docs: errorDocsUrl("NOT_FOUND"), + }, + }); + }); + + test("a v1 reason picks its own docs section over the Connect code", () => { + const err = withErrorInfo( + rpcError({ + code: Code.ResourceExhausted, + reason: ErrorReason.PAYMENT_REQUIRED, + message: "x", + }), + ); + expect(info(err).metadata.docs).toBe(errorDocsUrl("PAYMENT_REQUIRED")); + }); + + test("an explicit docs entry wins over the derived one", () => { + const err = withErrorInfo( + rpcError({ + code: Code.NotFound, + reason: ErrorReason.NOT_FOUND, + message: "x", + metadata: { docs: "https://example.com/custom" }, + }), + "req-3", + ); + expect(info(err).metadata.docs).toBe("https://example.com/custom"); + }); + + test("leaves requestId out when there is none", () => { + const err = withErrorInfo(new ConnectError("x", Code.Internal)); + expect(info(err).metadata).not.toHaveProperty("requestId"); + expect(info(err).metadata.docs).toBe(errorDocsUrl("INTERNAL_SERVER_ERROR")); + }); + + test("is idempotent", () => { + const err = withErrorInfo(withErrorInfo(new ConnectError("x"), "a"), "b"); + expect(err.findDetails(ErrorInfoSchema)).toHaveLength(1); + expect(info(err).metadata.requestId).toBe("a"); + }); +}); + +describe("code mapping", () => { + test("every v1 code has a Connect code and a docs section", () => { + for (const code of ErrorCodes) { + const connect = ERROR_CODE_TO_CONNECT[code]; + expect(typeof connect).toBe("number"); + expect(ErrorCodes).toContain(connectCodeToErrorCode(connect)); + } + }); + + test("round-trips the codes that have a one-to-one twin", () => { + for (const code of [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_ALLOWED", + "CONFLICT", + "TOO_MANY_REQUESTS", + "INTERNAL_SERVER_ERROR", + "SERVICE_UNAVAILABLE", + ] as const) { + expect(connectCodeToErrorCode(ERROR_CODE_TO_CONNECT[code])).toBe(code); + } + }); + + test("unknown Connect codes fall back to the internal section", () => { + expect(connectCodeToErrorCode(Code.DataLoss)).toBe("INTERNAL_SERVER_ERROR"); + expect(connectCodeToErrorCode(Code.Canceled)).toBe("INTERNAL_SERVER_ERROR"); + expect(connectCodeToErrorCode(Code.FailedPrecondition)).toBe( + "UNPROCESSABLE_ENTITY", + ); + }); +}); + +describe("connectErrorToJson", () => { + test("produces the Connect wire shape with typed, base64 details and debug", () => { + const err = withErrorInfo( + rpcError({ + code: Code.Unavailable, + reason: ErrorReason.SERVICE_UNAVAILABLE, + message: "Server is busy, retry shortly", + retryAfterSeconds: 5, + }), + "req-9", + ); + const json = connectErrorToJson(err) as { + code: string; + message: string; + details: { type: string; value: string; debug?: unknown }[]; + }; + expect(json.code).toBe("unavailable"); + expect(json.message).toBe("Server is busy, retry shortly"); + expect(json.details.map((d) => d.type)).toEqual([ + "google.rpc.ErrorInfo", + "google.rpc.RetryInfo", + ]); + expect(json.details[0].debug).toEqual({ + reason: "SERVICE_UNAVAILABLE", + domain: ERROR_DOMAIN, + metadata: { + requestId: "req-9", + docs: errorDocsUrl("SERVICE_UNAVAILABLE"), + }, + }); + expect(json.details[1].debug).toEqual({ retryDelay: "5s" }); + // value is unpadded base64 (Connect "std_raw") + expect(json.details[0].value).toMatch(/^[A-Za-z0-9+/]+$/); + }); + + test("a Connect client parses it back with the same reason and metadata", () => { + const err = withErrorInfo( + rpcError({ + code: Code.ResourceExhausted, + reason: ErrorReason.TOO_MANY_REQUESTS, + message: "slow down", + retryAfterSeconds: 7, + }), + "req-10", + ); + const parsed = errorFromJson( + connectErrorToJson(err) as JsonValue, + undefined, + new ConnectError("fallback", Code.Unknown), + ); + expect(parsed.code).toBe(Code.ResourceExhausted); + expect(parsed.rawMessage).toBe("slow down"); + expect(info(parsed)).toMatchObject({ + reason: "TOO_MANY_REQUESTS", + metadata: { requestId: "req-10" }, + }); + expect(parsed.findDetails(RetryInfoSchema)[0].retryDelay?.seconds).toBe(7n); + }); +}); + +// Mirrors `slugify` in apps/web/src/content/mdx-components/heading.tsx, which +// produces the anchors the `docs` links point at. +function headingSlug(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/\s+/g, "-") + .replace(/&/g, "-and-") + .replace(/[^\w-]+/g, "") + .replace(/--+/g, "-"); +} + +describe("docs page stays in sync", () => { + const page = Deno.readTextFileSync( + new URL( + "../../../../web/src/content/pages/docs/reference/api-errors.mdx", + import.meta.url, + ), + ); + const headings = page + .split("\n") + .filter((l) => /^#{2,3} /.test(l)) + .map((l) => headingSlug(l.replace(/^#+\s*/, "").replace(/`/g, ""))); + + test("every docs link resolves to a heading on the errors page", () => { + for (const code of ErrorCodes) { + const anchor = errorDocsUrl(code).split("#")[1]; + expect(headings).toContain(anchor); + } + }); + + test("every reason is documented", () => { + for (const reason of Object.values(ErrorReason)) { + expect(page).toContain(`| \`${reason}\` |`); + } + }); + + test("the docs page lists no reason the server cannot produce", () => { + const documented = [...page.matchAll(/^\| `([A-Z_]+)` \|/gm)].map( + (m) => m[1], + ); + for (const reason of documented) { + expect(Object.values(ErrorReason)).toContain(reason); + } + }); +}); diff --git a/apps/server/src/libs/errors/rpc.ts b/apps/server/src/libs/errors/rpc.ts new file mode 100644 index 00000000..b2b0c459 --- /dev/null +++ b/apps/server/src/libs/errors/rpc.ts @@ -0,0 +1,226 @@ +import type { MessageInitShape } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { errorToJson } from "@connectrpc/connect/protocol-connect"; +import { type ErrorCode, ErrorCodes, errorDocsUrl } from "@openstatus/error"; +import { + BadRequestSchema, + ErrorInfoSchema, + RetryInfoSchema, +} from "@openstatus/proto/google/rpc"; + +export const ERROR_DOMAIN = "openstatus.dev"; + +/** + * Stable, machine-readable reasons carried in `google.rpc.ErrorInfo.reason`. + * The generic ones share their name with the v1 REST error code so one docs + * anchor serves both surfaces. + */ +export const ErrorReason = { + // Generic (one per Connect code / v1 code) + BAD_REQUEST: "BAD_REQUEST", + UNAUTHORIZED: "UNAUTHORIZED", + PAYMENT_REQUIRED: "PAYMENT_REQUIRED", + FORBIDDEN: "FORBIDDEN", + NOT_FOUND: "NOT_FOUND", + METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED", + CONFLICT: "CONFLICT", + UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY", + TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS", + INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR", + SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE", + // Authentication + MISSING_CREDENTIALS: "MISSING_CREDENTIALS", + INVALID_API_KEY: "INVALID_API_KEY", + // Validation + VALIDATION_FAILED: "VALIDATION_FAILED", + INVALID_REGION: "INVALID_REGION", + INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", + INVALID_STATUS: "INVALID_STATUS", + INVALID_CUSTOM_DOMAIN: "INVALID_CUSTOM_DOMAIN", + INVALID_ICON_URL: "INVALID_ICON_URL", + INVALID_MONITOR_ID: "INVALID_MONITOR_ID", + INVALID_NOTIFICATION_DATA: "INVALID_NOTIFICATION_DATA", + PASSWORD_REQUIRED: "PASSWORD_REQUIRED", + AUTH_EMAIL_DOMAINS_REQUIRED: "AUTH_EMAIL_DOMAINS_REQUIRED", + IDENTIFIER_REQUIRED: "IDENTIFIER_REQUIRED", + MONITOR_TYPE_MISMATCH: "MONITOR_TYPE_MISMATCH", + PROVIDER_NOT_SUPPORTED: "PROVIDER_NOT_SUPPORTED", + // Plan + PLAN_LIMIT_REACHED: "PLAN_LIMIT_REACHED", + PLAN_FEATURE_NOT_AVAILABLE: "PLAN_FEATURE_NOT_AVAILABLE", + // Resources + MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", + RESPONSE_LOG_NOT_FOUND: "RESPONSE_LOG_NOT_FOUND", + NOTIFICATION_NOT_FOUND: "NOTIFICATION_NOT_FOUND", + STATUS_PAGE_NOT_FOUND: "STATUS_PAGE_NOT_FOUND", + STATUS_PAGE_NOT_PUBLISHED: "STATUS_PAGE_NOT_PUBLISHED", + STATUS_PAGE_ACCESS_DENIED: "STATUS_PAGE_ACCESS_DENIED", + SLUG_ALREADY_EXISTS: "SLUG_ALREADY_EXISTS", + PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", + COMPONENT_GROUP_NOT_FOUND: "COMPONENT_GROUP_NOT_FOUND", + SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND", + STATUS_REPORT_NOT_FOUND: "STATUS_REPORT_NOT_FOUND", + MAINTENANCE_NOT_FOUND: "MAINTENANCE_NOT_FOUND", + PRIVATE_LOCATION_NOT_FOUND: "PRIVATE_LOCATION_NOT_FOUND", + // Delivery + TEST_NOTIFICATION_FAILED: "TEST_NOTIFICATION_FAILED", +} as const; + +export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; + +type OutgoingDetail = NonNullable< + ConstructorParameters[3] +>[number]; +type ErrorInfoInit = MessageInitShape; + +export type FieldViolation = { field: string; description: string }; + +export type RpcErrorInit = { + code: Code; + reason: ErrorReason; + message: string; + /** Extra `ErrorInfo.metadata` entries, lowerCamelCase keys. */ + metadata?: Record; + /** Attaches `google.rpc.RetryInfo`. */ + retryAfterSeconds?: number; + /** Attaches `google.rpc.BadRequest`. */ + fieldViolations?: FieldViolation[]; + cause?: unknown; +}; + +/** The one way to build a ConnectError in this server: every error carries `google.rpc.ErrorInfo`. */ +export function rpcError(init: RpcErrorInit): ConnectError { + const details: OutgoingDetail[] = [ + { + desc: ErrorInfoSchema, + value: { + reason: init.reason, + domain: ERROR_DOMAIN, + metadata: { ...init.metadata }, + }, + }, + ]; + if (init.retryAfterSeconds !== undefined) { + details.push({ + desc: RetryInfoSchema, + value: { + retryDelay: { + seconds: BigInt(Math.max(1, Math.ceil(init.retryAfterSeconds))), + nanos: 0, + }, + }, + }); + } + if (init.fieldViolations?.length) { + details.push({ + desc: BadRequestSchema, + value: { fieldViolations: init.fieldViolations }, + }); + } + return new ConnectError( + init.message, + init.code, + undefined, + details, + init.cause, + ); +} + +export const ERROR_CODE_TO_CONNECT: Record = { + BAD_REQUEST: Code.InvalidArgument, + UNAUTHORIZED: Code.Unauthenticated, + PAYMENT_REQUIRED: Code.ResourceExhausted, + FORBIDDEN: Code.PermissionDenied, + NOT_FOUND: Code.NotFound, + METHOD_NOT_ALLOWED: Code.Unimplemented, + CONFLICT: Code.AlreadyExists, + UNPROCESSABLE_ENTITY: Code.InvalidArgument, + TOO_MANY_REQUESTS: Code.ResourceExhausted, + INTERNAL_SERVER_ERROR: Code.Internal, + SERVICE_UNAVAILABLE: Code.Unavailable, +}; + +/** Inverse of `ERROR_CODE_TO_CONNECT`; picks the v1 code whose docs section applies. */ +export function connectCodeToErrorCode(code: Code): ErrorCode { + switch (code) { + case Code.InvalidArgument: + case Code.OutOfRange: + return "BAD_REQUEST"; + case Code.Unauthenticated: + return "UNAUTHORIZED"; + case Code.PermissionDenied: + return "FORBIDDEN"; + case Code.NotFound: + return "NOT_FOUND"; + case Code.AlreadyExists: + case Code.Aborted: + return "CONFLICT"; + case Code.FailedPrecondition: + return "UNPROCESSABLE_ENTITY"; + case Code.ResourceExhausted: + return "TOO_MANY_REQUESTS"; + case Code.Unimplemented: + return "METHOD_NOT_ALLOWED"; + case Code.Unavailable: + return "SERVICE_UNAVAILABLE"; + default: + return "INTERNAL_SERVER_ERROR"; + } +} + +const V1_CODES: ReadonlySet = new Set(ErrorCodes); + +/** A v1 reason names its own docs section; the Connect code is lossy (402 and 429 both map to ResourceExhausted). */ +function docsFor(err: ConnectError, reason?: string): string { + const code = + reason && V1_CODES.has(reason) + ? (reason as ErrorCode) + : connectCodeToErrorCode(err.code); + return errorDocsUrl(code); +} + +function findErrorInfo(err: ConnectError): ErrorInfoInit | undefined { + for (const detail of err.details) { + if ("desc" in detail && detail.desc.typeName === ErrorInfoSchema.typeName) { + return detail.value as ErrorInfoInit; + } + } + return undefined; +} + +/** + * Backfills `requestId` and `docs` on the ErrorInfo, adding one with a generic + * reason when a handler threw a bare ConnectError. Mutates in place so the + * error identity (and its cause) survives. + */ +export function withErrorInfo( + err: ConnectError, + requestId?: string, +): ConnectError { + const info = findErrorInfo(err); + const base: Record = { docs: docsFor(err, info?.reason) }; + if (requestId) base.requestId = requestId; + + if (info) { + info.metadata = { ...base, ...info.metadata }; + return err; + } + err.details.push({ + desc: ErrorInfoSchema, + value: { + reason: connectCodeToErrorCode(err.code), + domain: ERROR_DOMAIN, + metadata: base, + }, + }); + return err; +} + +/** + * Wire shape of a Connect unary error, for responses written outside the + * Connect router. Typed loosely on purpose: Hono's `c.json` recurses over the + * protobuf `JsonValue` type and trips TS2589. + */ +export function connectErrorToJson(err: ConnectError): Record { + return errorToJson(err, undefined); +} diff --git a/apps/server/src/libs/errors/utils.ts b/apps/server/src/libs/errors/utils.ts index 57e2767d..f7beb97f 100644 --- a/apps/server/src/libs/errors/utils.ts +++ b/apps/server/src/libs/errors/utils.ts @@ -5,6 +5,7 @@ import { ErrorCodes, SchemaError, codeToStatus, + errorDocsUrl, statusToCode, } from "@openstatus/error"; // Props to Unkey: https://github.com/unkeyed/unkey/blob/main/apps/api/src/pkg/errors/http.ts @@ -40,7 +41,7 @@ export function handleError(err: Error, c: Context): Response { { code: "BAD_REQUEST", message: error.message, - docs: "https://www.openstatus.dev/docs/api-references/errors/code/BAD_REQUEST", + docs: errorDocsUrl("BAD_REQUEST"), requestId: c.get("requestId"), }, { status: 400 }, @@ -62,7 +63,7 @@ export function handleError(err: Error, c: Context): Response { { code: code, message: err.message, - docs: `https://www.openstatus.dev/docs/api-references/errors/code/${code}`, + docs: errorDocsUrl(code), requestId: c.get("requestId"), }, { status: err.status }, @@ -75,7 +76,7 @@ export function handleError(err: Error, c: Context): Response { { code: code, message: err.message, - docs: `https://www.openstatus.dev/docs/api-references/errors/code/${code}`, + docs: errorDocsUrl(code), requestId: c.get("requestId"), }, { status: err.status }, @@ -97,7 +98,7 @@ export function handleError(err: Error, c: Context): Response { { code: "INTERNAL_SERVER_ERROR", message: err.message ?? "Something went wrong", - docs: "https://www.openstatus.dev/docs/api-references/errors/code/INTERNAL_SERVER_ERROR", + docs: errorDocsUrl("INTERNAL_SERVER_ERROR"), requestId: c.get("requestId"), }, @@ -122,7 +123,7 @@ export function handleZodError( return c.json>>( { code: "BAD_REQUEST", - docs: "https://www.openstatus.dev/docs/api-references/errors/code/BAD_REQUEST", + docs: errorDocsUrl("BAD_REQUEST"), message: error.message, requestId: c.get("requestId"), }, @@ -143,7 +144,7 @@ export function createErrorSchema(code: ErrorCode) { }), docs: z.string().openapi({ description: "A link to the documentation for the error.", - example: `https://www.openstatus.dev/docs/api-references/errors/code/${code}`, + example: errorDocsUrl(code), }), requestId: z.string().openapi({ description: diff --git a/apps/server/src/libs/middlewares/concurrency.test.ts b/apps/server/src/libs/middlewares/concurrency.test.ts index 32b837bf..fb13de19 100644 --- a/apps/server/src/libs/middlewares/concurrency.test.ts +++ b/apps/server/src/libs/middlewares/concurrency.test.ts @@ -1,3 +1,7 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { errorFromJson } from "@connectrpc/connect/protocol-connect"; +import { errorDocsUrl } from "@openstatus/error"; +import { ErrorInfoSchema, RetryInfoSchema } from "@openstatus/proto/google/rpc"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; import { Hono } from "hono"; @@ -101,14 +105,14 @@ describe("concurrency guard", () => { expect(await shed.json()).toEqual({ code: "SERVICE_UNAVAILABLE", message: "Server is busy, retry shortly", - docs: "https://www.openstatus.dev/docs/api-references/errors/code/SERVICE_UNAVAILABLE", + docs: errorDocsUrl("SERVICE_UNAVAILABLE"), requestId: expect.any(String), }); release(); await held; }); - test("sheds /rpc with a Connect error body", async () => { + test("sheds /rpc with a Connect error body carrying ErrorInfo and RetryInfo", async () => { const { app, release } = build(1); const held = app.request("/slow"); await tick(); @@ -120,10 +124,26 @@ describe("concurrency guard", () => { ); expect(shed.status).toBe(503); expect(shed.headers.get("retry-after")).toBe("5"); - expect(await shed.json()).toEqual({ + const body = await shed.json(); + expect(body).toMatchObject({ code: "unavailable", message: "Server is busy, retry shortly", }); + const err = errorFromJson( + body, + undefined, + new ConnectError("fallback", Code.Unknown), + ); + expect(err.code).toBe(Code.Unavailable); + expect(err.findDetails(ErrorInfoSchema)[0]).toMatchObject({ + reason: "SERVICE_UNAVAILABLE", + domain: "openstatus.dev", + metadata: { + docs: errorDocsUrl("SERVICE_UNAVAILABLE"), + requestId: shed.headers.get("x-request-id"), + }, + }); + expect(err.findDetails(RetryInfoSchema)[0].retryDelay?.seconds).toBe(5n); release(); await held; }); diff --git a/apps/server/src/libs/middlewares/rate-limit.test.ts b/apps/server/src/libs/middlewares/rate-limit.test.ts index 860efe3b..6c6c128c 100644 --- a/apps/server/src/libs/middlewares/rate-limit.test.ts +++ b/apps/server/src/libs/middlewares/rate-limit.test.ts @@ -1,3 +1,4 @@ +import { errorDocsUrl } from "@openstatus/error"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; import { FakeTime } from "@std/testing/time"; @@ -127,7 +128,7 @@ describe("rate limit", () => { expect(limited.status).toBe(429); expect(limited.headers.get("retry-after")).not.toBeNull(); // Connect clients read the code from the body, so /rpc gets the Connect shape - expect(await limited.json()).toEqual({ + expect(await limited.json()).toMatchObject({ code: "resource_exhausted", message: "Rate limit exceeded, retry later", }); @@ -221,7 +222,7 @@ describe("rate limit", () => { expect(retryAfter).toBeLessThanOrEqual(10); expect(await limited.json()).toMatchObject({ message: "Rate limit exceeded, retry later", - docs: "https://www.openstatus.dev/docs/api-references/errors/code/TOO_MANY_REQUESTS", + docs: errorDocsUrl("TOO_MANY_REQUESTS"), }); }); diff --git a/apps/server/src/libs/middlewares/shed.ts b/apps/server/src/libs/middlewares/shed.ts index 4b810df2..92bbbe6f 100644 --- a/apps/server/src/libs/middlewares/shed.ts +++ b/apps/server/src/libs/middlewares/shed.ts @@ -1,18 +1,19 @@ -import type { ErrorCode } from "@openstatus/error"; +import { type ErrorCode, errorDocsUrl } from "@openstatus/error"; import type { Context } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import type { ErrorSchema } from "@/libs/errors"; - -/** Connect clients parse a JSON error body on non-200; without it the code comes from the status alone. */ -const CONNECT_CODES: Partial> = { - TOO_MANY_REQUESTS: "resource_exhausted", - SERVICE_UNAVAILABLE: "unavailable", -}; +import { + ERROR_CODE_TO_CONNECT, + connectErrorToJson, + rpcError, + withErrorInfo, +} from "@/libs/errors/rpc"; /** * Same envelope as `handleError`, returned directly instead of thrown so the - * Sentry middleware does not capture every shed request. + * Sentry middleware does not capture every shed request. `/rpc` gets the + * Connect error shape, which clients parse on any non-200 JSON body. */ export function shedResponse( c: Context, @@ -24,12 +25,16 @@ export function shedResponse( }, ) { c.header("Retry-After", String(Math.max(1, opts.retryAfterSeconds))); + const requestId = (c.get("requestId" as never) as string | undefined) ?? ""; if (c.req.path.startsWith("/rpc/")) { + const err = rpcError({ + code: ERROR_CODE_TO_CONNECT[opts.code], + reason: opts.code, + message: opts.message, + retryAfterSeconds: opts.retryAfterSeconds, + }); return c.json( - { - code: CONNECT_CODES[opts.code] ?? "unavailable", - message: opts.message, - }, + connectErrorToJson(withErrorInfo(err, requestId)), opts.status, ); } @@ -37,8 +42,8 @@ export function shedResponse( { code: opts.code, message: opts.message, - docs: `https://www.openstatus.dev/docs/api-references/errors/code/${opts.code}`, - requestId: (c.get("requestId" as never) as string | undefined) ?? "", + docs: errorDocsUrl(opts.code), + requestId, }, opts.status, ); diff --git a/apps/server/src/routes/rpc/__tests__/adapter.test.ts b/apps/server/src/routes/rpc/__tests__/adapter.test.ts new file mode 100644 index 00000000..c6a776ff --- /dev/null +++ b/apps/server/src/routes/rpc/__tests__/adapter.test.ts @@ -0,0 +1,130 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { + BadRequestSchema, + ErrorInfoSchema, +} from "@openstatus/proto/google/rpc"; +import { + ConflictError, + ForbiddenError, + InternalServiceError, + LimitExceededError, + NotFoundError, + PreconditionFailedError, + UnauthorizedError, + ValidationError, +} from "@openstatus/services"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; +import { z } from "zod"; + +import { ErrorReason } from "@/libs/errors/rpc"; + +import { toConnectError } from "../adapter"; + +function capture(err: unknown): unknown { + try { + toConnectError(err); + } catch (e) { + return e; + } + throw new Error("expected toConnectError to throw"); +} + +const info = (err: ConnectError) => err.findDetails(ErrorInfoSchema)[0]; + +describe("toConnectError", () => { + test("passes a ConnectError through by identity", () => { + const original = new ConnectError("x", Code.NotFound); + expect(capture(original)).toBe(original); + }); + + test("rethrows unclassified errors untouched for the interceptor", () => { + const raw = new Error("Failed query: select * from secret"); + expect(capture(raw)).toBe(raw); + }); + + test("turns a ZodError into VALIDATION_FAILED with readable message and field violations", () => { + const schema = z.object({ + url: z.string().url(), + periodicity: z.enum(["1m", "5m"]), + }); + const result = schema.safeParse({ url: "nope", periodicity: "2m" }); + if (result.success) throw new Error("expected zod failure"); + + const err = capture(result.error) as ConnectError; + expect(err.code).toBe(Code.InvalidArgument); + expect(err.rawMessage.startsWith("Invalid request: ")).toBe(true); + // not the raw JSON dump zod puts in `message` + expect(err.rawMessage).not.toContain('"code"'); + expect(info(err).reason).toBe(ErrorReason.VALIDATION_FAILED); + const [bad] = err.findDetails(BadRequestSchema); + expect(bad.fieldViolations.map((v) => v.field).sort()).toEqual([ + "periodicity", + "url", + ]); + expect(err.cause).toBe(result.error); + }); + + test("NotFoundError → not_found with the entity in metadata", () => { + const err = capture(new NotFoundError("monitor", 12)) as ConnectError; + expect(err.code).toBe(Code.NotFound); + expect(err.rawMessage).toBe("monitor 12 not found"); + expect(info(err)).toMatchObject({ + reason: ErrorReason.NOT_FOUND, + metadata: { resource: "monitor" }, + }); + }); + + test("LimitExceededError → permission_denied PLAN_LIMIT_REACHED with limit/max/current", () => { + const err = capture( + new LimitExceededError("monitors", 5, 5), + ) as ConnectError; + expect(err.code).toBe(Code.PermissionDenied); + expect(info(err)).toMatchObject({ + reason: ErrorReason.PLAN_LIMIT_REACHED, + metadata: { limit: "monitors", max: "5", current: "5" }, + }); + const noCurrent = capture( + new LimitExceededError("monitors", 5), + ) as ConnectError; + expect(info(noCurrent).metadata).not.toHaveProperty("current"); + }); + + test("ConflictError → invalid_argument with reason CONFLICT (RPC contract pins 400)", () => { + const err = capture(new ConflictError("mixed pages")) as ConnectError; + expect(err.code).toBe(Code.InvalidArgument); + expect(info(err).reason).toBe(ErrorReason.CONFLICT); + }); + + test("maps the remaining service errors", () => { + const cases: [Error, Code, string][] = [ + [new ForbiddenError("no"), Code.PermissionDenied, ErrorReason.FORBIDDEN], + [ + new UnauthorizedError("no"), + Code.Unauthenticated, + ErrorReason.UNAUTHORIZED, + ], + [ + new ValidationError("bad"), + Code.InvalidArgument, + ErrorReason.VALIDATION_FAILED, + ], + [ + new PreconditionFailedError("later"), + Code.FailedPrecondition, + ErrorReason.UNPROCESSABLE_ENTITY, + ], + [ + new InternalServiceError("boom"), + Code.Internal, + ErrorReason.INTERNAL_SERVER_ERROR, + ], + ]; + for (const [input, code, reason] of cases) { + const err = capture(input) as ConnectError; + expect(err.code).toBe(code); + expect(info(err).reason).toBe(reason); + expect(info(err).domain).toBe("openstatus.dev"); + } + }); +}); diff --git a/apps/server/src/routes/rpc/__tests__/errors.e2e.test.ts b/apps/server/src/routes/rpc/__tests__/errors.e2e.test.ts new file mode 100644 index 00000000..38ef3efb --- /dev/null +++ b/apps/server/src/routes/rpc/__tests__/errors.e2e.test.ts @@ -0,0 +1,153 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { errorFromJson } from "@connectrpc/connect/protocol-connect"; +import { errorDocsUrl } from "@openstatus/error"; +import { ErrorInfoSchema } from "@openstatus/proto/google/rpc"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { app } from "../../../index"; + +/** Decodes a Connect JSON error body the way a Connect client would. */ +async function parseError(res: Response): Promise { + return errorFromJson( + await res.json(), + res.headers, + new ConnectError("unparseable", Code.Unknown), + ); +} + +const info = (err: ConnectError) => err.findDetails(ErrorInfoSchema)[0]; + +function rpc( + path: string, + body: Record = {}, + headers: Record = {}, +) { + return app.request(`/rpc/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +describe("RPC error contract (wire)", () => { + test("missing credentials: unauthenticated + ErrorInfo with requestId matching the response header", async () => { + const res = await rpc("openstatus.monitor.v1.MonitorService/ListMonitors"); + expect(res.status).toBe(401); + expect(res.headers.get("content-type")).toContain("application/json"); + + const err = await parseError(res); + expect(err.code).toBe(Code.Unauthenticated); + const detail = info(err); + expect(detail.reason).toBe("MISSING_CREDENTIALS"); + expect(detail.domain).toBe("openstatus.dev"); + expect(detail.metadata.docs).toBe(errorDocsUrl("UNAUTHORIZED")); + expect(detail.metadata.requestId).toBeTruthy(); + expect(detail.metadata.requestId).toBe(res.headers.get("x-request-id")); + }); + + test("a client-supplied x-request-id is echoed in ErrorInfo and the header", async () => { + const res = await rpc( + "openstatus.monitor.v1.MonitorService/ListMonitors", + {}, + { "x-request-id": "client-abc-123" }, + ); + const err = await parseError(res); + expect(info(err).metadata.requestId).toBe("client-abc-123"); + expect(res.headers.get("x-request-id")).toBe("client-abc-123"); + }); + + test("invalid api key: unauthenticated INVALID_API_KEY", async () => { + const res = await rpc( + "openstatus.monitor.v1.MonitorService/ListMonitors", + {}, + { "x-openstatus-key": "definitely-not-a-key" }, + ); + expect(res.status).toBe(401); + const err = await parseError(res); + expect(info(err).reason).toBe("INVALID_API_KEY"); + }); + + test("unknown procedure: 404 not_found with ErrorInfo", async () => { + const res = await rpc("openstatus.monitor.v1.MonitorService/NoSuchMethod"); + expect(res.status).toBe(404); + const err = await parseError(res); + expect(err.code).toBe(Code.NotFound); + expect(info(err)).toMatchObject({ + reason: "NOT_FOUND", + metadata: { docs: errorDocsUrl("NOT_FOUND") }, + }); + expect(info(err).metadata.requestId).toBeTruthy(); + }); + + test("wrong HTTP method: 405 unimplemented with ErrorInfo", async () => { + const res = await app.request( + "/rpc/openstatus.monitor.v1.MonitorService/ListMonitors", + { method: "DELETE" }, + ); + expect(res.status).toBe(405); + const err = await parseError(res); + expect(err.code).toBe(Code.Unimplemented); + expect(info(err).reason).toBe("METHOD_NOT_ALLOWED"); + }); + + test("protovalidate failure: invalid_argument VALIDATION_FAILED with the Violations detail kept", async () => { + const res = await rpc( + "openstatus.monitor.v1.MonitorService/CreateHTTPMonitor", + { monitor: { name: "", url: "not a url" } }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(400); + const err = await parseError(res); + expect(err.code).toBe(Code.InvalidArgument); + expect(info(err).reason).toBe("VALIDATION_FAILED"); + expect(info(err).metadata.docs).toBe(errorDocsUrl("BAD_REQUEST")); + const types = err.details.map((d) => + "type" in d ? d.type : d.desc.typeName, + ); + expect(types).toContain("google.rpc.ErrorInfo"); + expect(types).toContain("buf.validate.Violations"); + }); + + test("missing resource: not_found with a specific reason and resource id in metadata", async () => { + const res = await rpc( + "openstatus.monitor.v1.MonitorService/GetMonitor", + { id: "999999999" }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(404); + const err = await parseError(res); + expect(err.code).toBe(Code.NotFound); + expect(info(err)).toMatchObject({ + reason: "MONITOR_NOT_FOUND", + metadata: { monitorId: "999999999", docs: errorDocsUrl("NOT_FOUND") }, + }); + }); + + test("id required: invalid_argument VALIDATION_FAILED", async () => { + const res = await rpc( + "openstatus.monitor.v1.MonitorService/GetMonitor", + { id: "" }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(400); + const err = await parseError(res); + expect(info(err).reason).toBe("VALIDATION_FAILED"); + }); + + test("every error body carries exactly one ErrorInfo", async () => { + const responses = await Promise.all([ + rpc("openstatus.monitor.v1.MonitorService/ListMonitors"), + rpc("openstatus.monitor.v1.MonitorService/NoSuchMethod"), + rpc( + "openstatus.monitor.v1.MonitorService/GetMonitor", + { id: "999999999" }, + { "x-openstatus-key": "1" }, + ), + ]); + for (const res of responses) { + const err = await parseError(res); + expect(err.findDetails(ErrorInfoSchema)).toHaveLength(1); + } + }); +}); diff --git a/apps/server/src/routes/rpc/adapter.ts b/apps/server/src/routes/rpc/adapter.ts index 9e364a93..6298f4d0 100644 --- a/apps/server/src/routes/rpc/adapter.ts +++ b/apps/server/src/routes/rpc/adapter.ts @@ -1,8 +1,15 @@ import { Code, ConnectError } from "@connectrpc/connect"; -import { type ServiceContext, ServiceError } from "@openstatus/services"; +import { parseZodErrorIssues } from "@openstatus/error"; +import { + LimitExceededError, + NotFoundError, + type ServiceContext, + ServiceError, +} from "@openstatus/services"; import { ZodError } from "zod"; import { tb } from "@/libs/clients"; +import { ErrorReason, rpcError } from "@/libs/errors/rpc"; import type { RpcContext } from "./interceptors"; @@ -26,38 +33,93 @@ export function toServiceCtx(rpcCtx: RpcContext): ServiceContext { } /** - * Map any error thrown by a service call to a `ConnectError`. Preserves the - * existing Connect error surface — granular reasons carried by the caller's - * per-handler error helpers (in `errors.ts`) still bypass this mapper since - * they throw `ConnectError` directly. Errors it can't classify propagate + * Map any error thrown by a service call to a `ConnectError`. Granular + * reasons from the per-handler `errors.ts` helpers bypass this mapper since + * they already are ConnectErrors. Errors it can't classify propagate * untouched to `errorInterceptor`, which logs and redacts them. */ export function toConnectError(err: unknown): never { if (err instanceof ConnectError) throw err; if (err instanceof ZodError) { - throw new ConnectError( - `Invalid request: ${err.message}`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid request: ${parseZodErrorIssues(err.issues)}`, + fieldViolations: err.issues.map((issue) => ({ + field: issue.path.map(String).join("."), + description: issue.message, + })), + cause: err, + }); } if (err instanceof ServiceError) { switch (err.code) { case "NOT_FOUND": - throw new ConnectError(err.message, Code.NotFound); + throw rpcError({ + code: Code.NotFound, + reason: ErrorReason.NOT_FOUND, + message: err.message, + metadata: + err instanceof NotFoundError ? { resource: err.entity } : undefined, + }); case "FORBIDDEN": - throw new ConnectError(err.message, Code.PermissionDenied); + throw rpcError({ + code: Code.PermissionDenied, + reason: ErrorReason.FORBIDDEN, + message: err.message, + }); case "UNAUTHORIZED": - throw new ConnectError(err.message, Code.Unauthenticated); + throw rpcError({ + code: Code.Unauthenticated, + reason: ErrorReason.UNAUTHORIZED, + message: err.message, + }); case "CONFLICT": - throw new ConnectError(err.message, Code.InvalidArgument); + // Services raise ConflictError for cross-entity mismatches too (a + // maintenance spanning two pages), and the RPC contract pins those to + // 400, so this stays InvalidArgument rather than AlreadyExists. + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.CONFLICT, + message: err.message, + }); case "VALIDATION": - throw new ConnectError(err.message, Code.InvalidArgument); - case "LIMIT_EXCEEDED": - throw new ConnectError(err.message, Code.ResourceExhausted); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: err.message, + cause: err.cause, + }); + case "LIMIT_EXCEEDED": { + // PermissionDenied, same as the handler-level `planLimitReachedError`: + // the docs pin plan gating to 403, and ResourceExhausted would tell + // clients to retry a quota that only an upgrade lifts. + const metadata: Record = {}; + if (err instanceof LimitExceededError) { + metadata.limit = err.limit; + metadata.max = String(err.max); + if (err.current !== undefined) metadata.current = String(err.current); + } + throw rpcError({ + code: Code.PermissionDenied, + reason: ErrorReason.PLAN_LIMIT_REACHED, + message: err.message, + metadata, + }); + } case "PRECONDITION_FAILED": - throw new ConnectError(err.message, Code.FailedPrecondition); + throw rpcError({ + code: Code.FailedPrecondition, + reason: ErrorReason.UNPROCESSABLE_ENTITY, + message: err.message, + }); case "INTERNAL": - throw new ConnectError(err.message, Code.Internal); + throw rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: err.message, + cause: err.cause, + }); } } // Unclassified: rethrow raw so `errorInterceptor` handles it. Only the diff --git a/apps/server/src/routes/rpc/errors.ts b/apps/server/src/routes/rpc/errors.ts new file mode 100644 index 00000000..a8f411dd --- /dev/null +++ b/apps/server/src/routes/rpc/errors.ts @@ -0,0 +1,80 @@ +import { Code, type ConnectError } from "@connectrpc/connect"; + +import { ErrorReason, rpcError } from "@/libs/errors/rpc"; + +export { ErrorReason, rpcError } from "@/libs/errors/rpc"; + +/** Factories shared by more than one service; domain-specific ones live next to their handler. */ + +export function idRequiredError(resource: string): ConnectError { + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `${resource} ID is required`, + fieldViolations: [ + { field: "id", description: `${resource} ID is required` }, + ], + }); +} + +export function monitorNotFoundError( + monitorId: string, + message = "Monitor not found", +): ConnectError { + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.MONITOR_NOT_FOUND, + message, + metadata: { monitorId }, + }); +} + +export function pageComponentNotFoundError(componentId: string): ConnectError { + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.PAGE_COMPONENT_NOT_FOUND, + message: "Page component not found", + metadata: { pageComponentId: componentId }, + }); +} + +export function invalidDateFormatError(dateValue: string): ConnectError { + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_DATE_FORMAT, + message: + "Invalid date format. Expected RFC 3339 format (e.g., 2024-01-15T10:30:00Z)", + metadata: { value: dateValue }, + }); +} + +/** Boolean entitlements (`custom-domain`, `sms`, ...): the plan lacks the feature outright. */ +export function planFeatureNotAvailableError( + message: string, + feature: string, + metadata?: Record, +): ConnectError { + return rpcError({ + code: Code.PermissionDenied, + reason: ErrorReason.PLAN_FEATURE_NOT_AVAILABLE, + message, + metadata: { feature, ...metadata }, + }); +} + +/** Row-count caps (`monitors`, `status-pages`, ...): the plan has the feature, the quota is used up. */ +export function planLimitReachedError( + message: string, + limit: string, + max: number, + current?: number, +): ConnectError { + const metadata: Record = { limit, max: String(max) }; + if (current !== undefined) metadata.current = String(current); + return rpcError({ + code: Code.PermissionDenied, + reason: ErrorReason.PLAN_LIMIT_REACHED, + message, + metadata, + }); +} diff --git a/apps/server/src/routes/rpc/handlers/maintenance/errors.ts b/apps/server/src/routes/rpc/handlers/maintenance/errors.ts index ab1a6d18..de1d7678 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/errors.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/errors.ts @@ -1,112 +1,9 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import type { ConnectError } from "@connectrpc/connect"; -/** - * Error reasons for structured error handling. - */ -export const ErrorReason = { - MAINTENANCE_NOT_FOUND: "MAINTENANCE_NOT_FOUND", - MAINTENANCE_ID_REQUIRED: "MAINTENANCE_ID_REQUIRED", - MAINTENANCE_CREATE_FAILED: "MAINTENANCE_CREATE_FAILED", - MAINTENANCE_UPDATE_FAILED: "MAINTENANCE_UPDATE_FAILED", - INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", - INVALID_DATE_RANGE: "INVALID_DATE_RANGE", -} as const; +import { idRequiredError } from "../../errors"; -export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; +export { invalidDateFormatError } from "../../errors"; -const DOMAIN = "openstatus.dev"; - -/** - * Creates a ConnectError with structured metadata. - */ -function createError( - message: string, - code: Code, - reason: ErrorReason, - metadata?: Record, -): ConnectError { - const headers = new Headers({ - "error-domain": DOMAIN, - "error-reason": reason, - }); - - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - headers.set(`error-${key}`, value); - } - } - - return new ConnectError(message, code, headers); -} - -/** - * Creates a "maintenance not found" error. - */ -export function maintenanceNotFoundError(maintenanceId: string): ConnectError { - return createError( - "Maintenance not found", - Code.NotFound, - ErrorReason.MAINTENANCE_NOT_FOUND, - { "maintenance-id": maintenanceId }, - ); -} - -/** - * Creates a "maintenance ID required" error. - */ export function maintenanceIdRequiredError(): ConnectError { - return createError( - "Maintenance ID is required", - Code.InvalidArgument, - ErrorReason.MAINTENANCE_ID_REQUIRED, - ); -} - -/** - * Creates a "failed to create maintenance" error. - */ -export function maintenanceCreateFailedError(): ConnectError { - return createError( - "Failed to create maintenance", - Code.Internal, - ErrorReason.MAINTENANCE_CREATE_FAILED, - ); -} - -/** - * Creates a "failed to update maintenance" error. - */ -export function maintenanceUpdateFailedError( - maintenanceId: string, -): ConnectError { - return createError( - "Failed to update maintenance", - Code.Internal, - ErrorReason.MAINTENANCE_UPDATE_FAILED, - { "maintenance-id": maintenanceId }, - ); -} - -/** - * Creates an "invalid date format" error. - */ -export function invalidDateFormatError(dateValue: string): ConnectError { - return createError( - "Invalid date format. Expected RFC 3339 format (e.g., 2024-01-15T10:30:00Z)", - Code.InvalidArgument, - ErrorReason.INVALID_DATE_FORMAT, - { "date-value": dateValue }, - ); -} - -/** - * Creates an "invalid date range" error (from must be before to). - */ -export function invalidDateRangeError(from: string, to: string): ConnectError { - return createError( - "Invalid date range. Start time (from) must be before end time (to)", - Code.InvalidArgument, - ErrorReason.INVALID_DATE_RANGE, - { from, to }, - ); + return idRequiredError("Maintenance"); } diff --git a/apps/server/src/routes/rpc/handlers/maintenance/index.ts b/apps/server/src/routes/rpc/handlers/maintenance/index.ts index ac272189..95f8a720 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/index.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/index.ts @@ -1,4 +1,4 @@ -import { Code, ConnectError, type ServiceImpl } from "@connectrpc/connect"; +import { Code, type ServiceImpl } from "@connectrpc/connect"; import type { MaintenanceService } from "@openstatus/proto/maintenance/v1"; import { createMaintenance, @@ -10,6 +10,7 @@ import { } from "@openstatus/services/maintenance"; import { toConnectError, toServiceCtx } from "../../adapter"; +import { ErrorReason, rpcError } from "../../errors"; import { getRpcContext } from "../../interceptors"; import { dbMaintenanceToProto, @@ -35,10 +36,17 @@ function parsePageComponentIds(ids: ReadonlyArray): number[] { // and surfaces the correct `InvalidArgument` here. const n = Number.parseInt(id, 10); if (!Number.isFinite(n)) { - throw new ConnectError( - `Invalid page component id: "${id}"`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid page component id: "${id}"`, + fieldViolations: [ + { + field: "pageComponentIds", + description: `Invalid page component id: "${id}"`, + }, + ], + }); } return n; }); diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts index 5ca3cbed..1b2e09d1 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts @@ -1,4 +1,4 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code } from "@connectrpc/connect"; import { getLogger } from "@logtape/logtape"; import { type Assertion, @@ -20,6 +20,7 @@ import type { } from "@openstatus/proto/monitor/v1"; import type { z } from "zod"; +import { ErrorReason, rpcError } from "../../../errors"; import { compareToNumberComparator, compareToRecordComparator, @@ -178,10 +179,14 @@ export type MonitorAssertionInput = function toDnsRecordKey(record: string): DnsRecord { const match = dnsRecords.find((r) => r === record); if (!match) { - throw new ConnectError( - `Invalid DNS record type: ${record}`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid DNS record type: ${record}`, + fieldViolations: [ + { field: "record", description: `Invalid DNS record type: ${record}` }, + ], + }); } return match; } diff --git a/apps/server/src/routes/rpc/handlers/monitor/errors.ts b/apps/server/src/routes/rpc/handlers/monitor/errors.ts index 05faa122..8f1315ac 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/errors.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/errors.ts @@ -1,168 +1,86 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code, type ConnectError } from "@connectrpc/connect"; -/** - * Error reasons for structured error handling. - */ -export const ErrorReason = { - MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", - MONITOR_REQUIRED: "MONITOR_REQUIRED", - MONITOR_ID_REQUIRED: "MONITOR_ID_REQUIRED", - MONITOR_CREATE_FAILED: "MONITOR_CREATE_FAILED", - MONITOR_UPDATE_FAILED: "MONITOR_UPDATE_FAILED", - MONITOR_PARSE_FAILED: "MONITOR_PARSE_FAILED", - MONITOR_RUN_CREATE_FAILED: "MONITOR_RUN_CREATE_FAILED", - MONITOR_INVALID_DATA: "MONITOR_INVALID_DATA", - MONITOR_TYPE_MISMATCH: "MONITOR_TYPE_MISMATCH", - RESPONSE_LOG_NOT_FOUND: "RESPONSE_LOG_NOT_FOUND", - RESPONSE_LOGS_NOT_ENABLED: "RESPONSE_LOGS_NOT_ENABLED", - RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", -} as const; +import { + ErrorReason, + idRequiredError, + planFeatureNotAvailableError, + planLimitReachedError, + rpcError, +} from "../../errors"; -export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; +export { monitorNotFoundError } from "../../errors"; -const DOMAIN = "openstatus.dev"; - -/** - * Creates a ConnectError with structured metadata. - * - * This provides machine-parseable error information via metadata headers - * while maintaining human-readable error messages. - */ -function createError( - message: string, - code: Code, - reason: ErrorReason, - metadata?: Record, -): ConnectError { - const headers = new Headers({ - "error-domain": DOMAIN, - "error-reason": reason, - }); - - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - headers.set(`error-${key}`, value); - } - } - - return new ConnectError(message, code, headers); -} - -/** - * Creates a "monitor not found" error with the monitor ID in metadata. - */ -export function monitorNotFoundError(monitorId: string): ConnectError { - return createError( - "Monitor not found", - Code.NotFound, - ErrorReason.MONITOR_NOT_FOUND, - { "monitor-id": monitorId }, - ); -} - -/** - * Creates a "monitor required" error. - */ export function monitorRequiredError(): ConnectError { - return createError( - "Monitor is required", - Code.InvalidArgument, - ErrorReason.MONITOR_REQUIRED, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: "Monitor is required", + fieldViolations: [{ field: "monitor", description: "Monitor is required" }], + }); } -/** - * Creates a "monitor ID required" error. - */ export function monitorIdRequiredError(): ConnectError { - return createError( - "Monitor ID is required", - Code.InvalidArgument, - ErrorReason.MONITOR_ID_REQUIRED, - ); + return idRequiredError("Monitor"); } -/** - * Creates a "monitor type mismatch" error when trying to update with wrong type. - */ export function monitorTypeMismatchError( monitorId: string, expectedType: string, actualType: string, ): ConnectError { - return createError( - `Monitor type mismatch: expected ${expectedType}, got ${actualType}`, - Code.InvalidArgument, - ErrorReason.MONITOR_TYPE_MISMATCH, - { - "monitor-id": monitorId, - "expected-type": expectedType, - "actual-type": actualType, - }, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.MONITOR_TYPE_MISMATCH, + message: `Monitor type mismatch: expected ${expectedType}, got ${actualType}`, + metadata: { monitorId, expectedType, actualType }, + }); } -/** - * Creates a "response log not found" error. - */ export function responseLogNotFoundError( monitorId: string, logId: string, ): ConnectError { - return createError( - "Response log not found", - Code.NotFound, - ErrorReason.RESPONSE_LOG_NOT_FOUND, - { "monitor-id": monitorId, "log-id": logId }, - ); + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.RESPONSE_LOG_NOT_FOUND, + message: "Response log not found", + metadata: { monitorId, logId }, + }); } -/** - * Creates a "response logs not enabled" error. - */ export function responseLogsNotEnabledError(): ConnectError { - return createError( + return planFeatureNotAvailableError( "Upgrade for response logs", - Code.PermissionDenied, - ErrorReason.RESPONSE_LOGS_NOT_ENABLED, + "response-logs", ); } -/** - * Creates a "failed to parse monitor data" error. - */ export function monitorParseFailedError(monitorId?: string): ConnectError { - return createError( - "Failed to parse monitor data", - Code.Internal, - ErrorReason.MONITOR_PARSE_FAILED, - monitorId ? { "monitor-id": monitorId } : undefined, - ); + return rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: "Failed to parse monitor data", + metadata: monitorId ? { monitorId } : undefined, + }); } -/** - * Creates an "invalid monitor data" error for corrupted data. - */ export function monitorInvalidDataError(monitorId: string): ConnectError { - return createError( - "Invalid monitor data, please contact support", - Code.Internal, - ErrorReason.MONITOR_INVALID_DATA, - { "monitor-id": monitorId }, - ); + return rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: "Invalid monitor data, please contact support", + metadata: { monitorId }, + }); } -/** - * Creates a rate limit exceeded error. - */ export function rateLimitExceededError( limit: number, current: number, ): ConnectError { - return createError( + return planLimitReachedError( "Upgrade for more checks", - Code.ResourceExhausted, - ErrorReason.RATE_LIMIT_EXCEEDED, - { limit: String(limit), current: String(current) }, + "checks", + limit, + current, ); } diff --git a/apps/server/src/routes/rpc/handlers/monitor/limits.ts b/apps/server/src/routes/rpc/handlers/monitor/limits.ts index 3ab91d52..227630eb 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/limits.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/limits.ts @@ -1,4 +1,3 @@ -import { Code, ConnectError } from "@connectrpc/connect"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; @@ -6,6 +5,10 @@ import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { Periodicity, Region } from "@openstatus/proto/monitor/v1"; import { z } from "zod"; +import { + planFeatureNotAvailableError, + planLimitReachedError, +} from "../../errors"; import { periodicityToString, regionsToStrings } from "./converters"; /** @@ -22,9 +25,10 @@ export function checkMonitorConfigLimits( if (periodicity) { const periodicityStr = periodicityToString(periodicity); if (!limits.periodicity.includes(periodicityStr)) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade for more periodicity options", - Code.PermissionDenied, + "periodicity", + { periodicity: periodicityStr }, ); } } @@ -37,15 +41,21 @@ export function checkMonitorConfigLimits( // Check max regions limit if (regionStrings.length > limits["max-regions"]) { - throw new ConnectError("Upgrade for more regions", Code.PermissionDenied); + throw planLimitReachedError( + "Upgrade for more regions", + "max-regions", + limits["max-regions"], + regionStrings.length, + ); } // Check if each region is allowed for (const region of regionStrings) { if (!limits.regions.includes(region)) { - throw new ConnectError( + throw planFeatureNotAvailableError( `Region '${region}' is not available on your plan. Upgrade for more regions`, - Code.PermissionDenied, + "region", + { region }, ); } } @@ -73,7 +83,12 @@ export async function checkMonitorLimits( const count = countResult?.count ?? 0; if (count >= limits.monitors) { - throw new ConnectError("Upgrade for more monitors", Code.PermissionDenied); + throw planLimitReachedError( + "Upgrade for more monitors", + "monitors", + limits.monitors, + count, + ); } checkMonitorConfigLimits(limits, periodicity, regions); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index 11c90fc6..fcf416cb 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -1,9 +1,10 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code } from "@connectrpc/connect"; import { monitorPeriodicity } from "@openstatus/db/src/schema/constants"; import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; import type { Periodicity, Region } from "@openstatus/proto/monitor/v1"; import type { UpdateMonitorConfigInput } from "@openstatus/services/monitor"; +import { ErrorReason, rpcError } from "../../errors"; import { MONITOR_DEFAULTS, protoOpenTelemetryToService, @@ -50,10 +51,12 @@ export function validateCommonMonitorFields(mon: { regions?: Region[] }): void { const regionStrings = regionsToStrings(mon.regions); const invalidRegions = validateRegions(regionStrings); if (invalidRegions.length > 0) { - throw new ConnectError( - `Invalid regions: ${invalidRegions.join(", ")}`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_REGION, + message: `Invalid regions: ${invalidRegions.join(", ")}`, + metadata: { regions: invalidRegions.join(",") }, + }); } } } @@ -81,7 +84,11 @@ const MONITOR_BOUNDS = { } as const; function invalidArgument(message: string): never { - throw new ConnectError(message, Code.InvalidArgument); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message, + }); } /** diff --git a/apps/server/src/routes/rpc/handlers/notification/converters.ts b/apps/server/src/routes/rpc/handlers/notification/converters.ts index 13abc84d..a1793154 100644 --- a/apps/server/src/routes/rpc/handlers/notification/converters.ts +++ b/apps/server/src/routes/rpc/handlers/notification/converters.ts @@ -1,5 +1,5 @@ import { create } from "@bufbuild/protobuf"; -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code } from "@connectrpc/connect"; import type { NotificationProvider as DBNotificationProvider } from "@openstatus/db/src/schema"; import type { Notification, @@ -14,6 +14,8 @@ import { OpsgenieRegion, } from "@openstatus/proto/notification/v1"; +import { ErrorReason, rpcError } from "../../errors"; + type DBNotification = { id: number; name: string; @@ -128,10 +130,14 @@ export function protoProviderToDb( }; const mapped = mapping[provider]; if (!mapped) { - throw new ConnectError( - `Unknown or unspecified notification provider: ${NotificationProvider[provider] ?? provider}`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.PROVIDER_NOT_SUPPORTED, + message: `Unknown or unspecified notification provider: ${NotificationProvider[provider] ?? provider}`, + metadata: { + provider: String(NotificationProvider[provider] ?? provider), + }, + }); } return mapped; } diff --git a/apps/server/src/routes/rpc/handlers/notification/errors.ts b/apps/server/src/routes/rpc/handlers/notification/errors.ts index b57b8369..a2db3a5b 100644 --- a/apps/server/src/routes/rpc/handlers/notification/errors.ts +++ b/apps/server/src/routes/rpc/handlers/notification/errors.ts @@ -1,165 +1,62 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code, type ConnectError } from "@connectrpc/connect"; -/** - * Error reasons for structured error handling. - */ -export const ErrorReason = { - NOTIFICATION_NOT_FOUND: "NOTIFICATION_NOT_FOUND", - NOTIFICATION_ID_REQUIRED: "NOTIFICATION_ID_REQUIRED", - NOTIFICATION_CREATE_FAILED: "NOTIFICATION_CREATE_FAILED", - NOTIFICATION_UPDATE_FAILED: "NOTIFICATION_UPDATE_FAILED", - NOTIFICATION_LIMIT_REACHED: "NOTIFICATION_LIMIT_REACHED", - PROVIDER_NOT_ALLOWED: "PROVIDER_NOT_ALLOWED", - PROVIDER_NOT_SUPPORTED: "PROVIDER_NOT_SUPPORTED", - INVALID_NOTIFICATION_DATA: "INVALID_NOTIFICATION_DATA", - MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", - TEST_NOTIFICATION_FAILED: "TEST_NOTIFICATION_FAILED", -} as const; +import { + ErrorReason, + idRequiredError, + monitorNotFoundError as sharedMonitorNotFoundError, + planFeatureNotAvailableError, + planLimitReachedError, + rpcError, +} from "../../errors"; -export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; - -const DOMAIN = "openstatus.dev"; - -/** - * Creates a ConnectError with structured metadata. - */ -function createError( - message: string, - code: Code, - reason: ErrorReason, - metadata?: Record, -): ConnectError { - const headers = new Headers({ - "error-domain": DOMAIN, - "error-reason": reason, - }); - - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - headers.set(`error-${key}`, value); - } - } - - return new ConnectError(message, code, headers); -} - -/** - * Creates a "notification not found" error. - */ -export function notificationNotFoundError( - notificationId: string, -): ConnectError { - return createError( - "Notification not found", - Code.NotFound, - ErrorReason.NOTIFICATION_NOT_FOUND, - { "notification-id": notificationId }, - ); -} - -/** - * Creates a "notification ID required" error. - */ export function notificationIdRequiredError(): ConnectError { - return createError( - "Notification ID is required", - Code.InvalidArgument, - ErrorReason.NOTIFICATION_ID_REQUIRED, - ); -} - -/** - * Creates a "failed to create notification" error. - */ -export function notificationCreateFailedError(): ConnectError { - return createError( - "Failed to create notification", - Code.Internal, - ErrorReason.NOTIFICATION_CREATE_FAILED, - ); -} - -/** - * Creates a "failed to update notification" error. - */ -export function notificationUpdateFailedError( - notificationId: string, -): ConnectError { - return createError( - "Failed to update notification", - Code.Internal, - ErrorReason.NOTIFICATION_UPDATE_FAILED, - { "notification-id": notificationId }, - ); + return idRequiredError("Notification"); } -/** - * Creates a "notification limit reached" error. - */ -export function notificationLimitReachedError(): ConnectError { - return createError( +export function notificationLimitReachedError(max: number): ConnectError { + return planLimitReachedError( "You have reached your notification channel limit. Upgrade to add more.", - Code.ResourceExhausted, - ErrorReason.NOTIFICATION_LIMIT_REACHED, + "notification-channels", + max, ); } -/** - * Creates a "provider not allowed" error for limited providers. - */ export function providerNotAllowedError(provider: string): ConnectError { - return createError( + return planFeatureNotAvailableError( `The ${provider} provider requires an upgraded plan.`, - Code.PermissionDenied, - ErrorReason.PROVIDER_NOT_ALLOWED, + "notification-provider", { provider }, ); } -/** - * Creates a "provider not supported" error. - */ export function providerNotSupportedError(provider: string): ConnectError { - return createError( - `The provider ${provider} is not supported for test notifications.`, - Code.InvalidArgument, - ErrorReason.PROVIDER_NOT_SUPPORTED, - { provider }, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.PROVIDER_NOT_SUPPORTED, + message: `The provider ${provider} is not supported for test notifications.`, + metadata: { provider }, + }); } -/** - * Creates an "invalid notification data" error. - */ export function invalidNotificationDataError(details: string): ConnectError { - return createError( - `Invalid notification data: ${details}`, - Code.InvalidArgument, - ErrorReason.INVALID_NOTIFICATION_DATA, - { details }, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_NOTIFICATION_DATA, + message: `Invalid notification data: ${details}`, + }); } -/** - * Creates a "monitor not found" error. - */ export function monitorNotFoundError(monitorId: string): ConnectError { - return createError( + return sharedMonitorNotFoundError( + monitorId, "Monitor not found or not accessible", - Code.NotFound, - ErrorReason.MONITOR_NOT_FOUND, - { "monitor-id": monitorId }, ); } -/** - * Creates a "test notification failed" error. - */ export function testNotificationFailedError(message: string): ConnectError { - return createError( - `Test notification failed: ${message}`, - Code.Internal, - ErrorReason.TEST_NOTIFICATION_FAILED, - { message }, - ); + return rpcError({ + code: Code.Internal, + reason: ErrorReason.TEST_NOTIFICATION_FAILED, + message: `Test notification failed: ${message}`, + }); } diff --git a/apps/server/src/routes/rpc/handlers/notification/limits.ts b/apps/server/src/routes/rpc/handlers/notification/limits.ts index e286aab2..2c91bbdc 100644 --- a/apps/server/src/routes/rpc/handlers/notification/limits.ts +++ b/apps/server/src/routes/rpc/handlers/notification/limits.ts @@ -96,7 +96,7 @@ export async function checkNotificationLimit( const currentCount = result?.count ?? 0; if (currentCount >= maxCount) { - throw notificationLimitReachedError(); + throw notificationLimitReachedError(maxCount); } } diff --git a/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts b/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts index 791c79d6..7fd7caac 100644 --- a/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts +++ b/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts @@ -313,7 +313,16 @@ describe("PrivateLocationService.GetPrivateLocation", () => { ); expect(res.status).toBe(404); - expect(res.headers.get("error-reason")).toBe("PRIVATE_LOCATION_NOT_FOUND"); + const body = await res.json(); + expect(body.code).toBe("not_found"); + const info = body.details.find( + (d: { type: string }) => d.type === "google.rpc.ErrorInfo", + ); + expect(info.debug).toMatchObject({ + reason: "PRIVATE_LOCATION_NOT_FOUND", + domain: "openstatus.dev", + metadata: { privateLocationId: "99999999" }, + }); }); test("returns 404 for a location in another workspace", async () => { diff --git a/apps/server/src/routes/rpc/handlers/private-location/errors.ts b/apps/server/src/routes/rpc/handlers/private-location/errors.ts index 612981bb..10ad6767 100644 --- a/apps/server/src/routes/rpc/handlers/private-location/errors.ts +++ b/apps/server/src/routes/rpc/handlers/private-location/errors.ts @@ -1,59 +1,27 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code, type ConnectError } from "@connectrpc/connect"; -export const ErrorReason = { - PRIVATE_LOCATION_NOT_FOUND: "PRIVATE_LOCATION_NOT_FOUND", - PRIVATE_LOCATION_ID_REQUIRED: "PRIVATE_LOCATION_ID_REQUIRED", - INVALID_MONITOR_ID: "INVALID_MONITOR_ID", -} as const; - -export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; - -const DOMAIN = "openstatus.dev"; - -function createError( - message: string, - code: Code, - reason: ErrorReason, - metadata?: Record, -): ConnectError { - const headers = new Headers({ - "error-domain": DOMAIN, - "error-reason": reason, - }); - - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - headers.set(`error-${key}`, value); - } - } - - return new ConnectError(message, code, headers); -} +import { ErrorReason, idRequiredError, rpcError } from "../../errors"; export function privateLocationNotFoundError( privateLocationId: string, ): ConnectError { - return createError( - "Private location not found", - Code.NotFound, - ErrorReason.PRIVATE_LOCATION_NOT_FOUND, - { "private-location-id": privateLocationId }, - ); + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.PRIVATE_LOCATION_NOT_FOUND, + message: "Private location not found", + metadata: { privateLocationId }, + }); } export function privateLocationIdRequiredError(): ConnectError { - return createError( - "Private location ID is required", - Code.InvalidArgument, - ErrorReason.PRIVATE_LOCATION_ID_REQUIRED, - ); + return idRequiredError("Private location"); } export function invalidMonitorIdError(monitorId: string): ConnectError { - return createError( - `Invalid monitor id: "${monitorId}"`, - Code.InvalidArgument, - ErrorReason.INVALID_MONITOR_ID, - { "monitor-id": monitorId }, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_MONITOR_ID, + message: `Invalid monitor id: "${monitorId}"`, + metadata: { monitorId }, + }); } diff --git a/apps/server/src/routes/rpc/handlers/private-location/limits.ts b/apps/server/src/routes/rpc/handlers/private-location/limits.ts index 81fec25d..3b8e160c 100644 --- a/apps/server/src/routes/rpc/handlers/private-location/limits.ts +++ b/apps/server/src/routes/rpc/handlers/private-location/limits.ts @@ -1,15 +1,16 @@ -import { Code, ConnectError } from "@connectrpc/connect"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; +import { planFeatureNotAvailableError } from "../../errors"; + /** * `private-locations` is a boolean entitlement, not a row-count cap — there is * no per-workspace limit on how many agents a paid plan may register. */ export function checkPrivateLocationsEnabled(limits: Limits): void { if (!limits["private-locations"]) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade to use private locations", - Code.PermissionDenied, + "private-locations", ); } } diff --git a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts index 616b72a1..c250f60b 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts @@ -431,7 +431,7 @@ describe("StatusPageService.CreateStatusPage", () => { expect(res.status).toBe(403); // PermissionDenied const data = await res.json(); - expect(data.message).toContain("Upgrade for more status pages"); + expect(data.message).toContain("status-pages limit reached"); } finally { // Clean up await db.delete(page).where(eq(page.id, firstPage.id)); diff --git a/apps/server/src/routes/rpc/handlers/status-page/errors.ts b/apps/server/src/routes/rpc/handlers/status-page/errors.ts index 05335302..4224fa74 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/errors.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/errors.ts @@ -1,245 +1,118 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code, type ConnectError } from "@connectrpc/connect"; -/** - * Error reasons for structured error handling. - */ -export const ErrorReason = { - STATUS_PAGE_NOT_FOUND: "STATUS_PAGE_NOT_FOUND", - STATUS_PAGE_ID_REQUIRED: "STATUS_PAGE_ID_REQUIRED", - STATUS_PAGE_CREATE_FAILED: "STATUS_PAGE_CREATE_FAILED", - STATUS_PAGE_UPDATE_FAILED: "STATUS_PAGE_UPDATE_FAILED", - STATUS_PAGE_NOT_PUBLISHED: "STATUS_PAGE_NOT_PUBLISHED", - STATUS_PAGE_ACCESS_DENIED: "STATUS_PAGE_ACCESS_DENIED", - SLUG_ALREADY_EXISTS: "SLUG_ALREADY_EXISTS", - PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", - PAGE_COMPONENT_CREATE_FAILED: "PAGE_COMPONENT_CREATE_FAILED", - PAGE_COMPONENT_UPDATE_FAILED: "PAGE_COMPONENT_UPDATE_FAILED", - COMPONENT_GROUP_NOT_FOUND: "COMPONENT_GROUP_NOT_FOUND", - COMPONENT_GROUP_CREATE_FAILED: "COMPONENT_GROUP_CREATE_FAILED", - COMPONENT_GROUP_UPDATE_FAILED: "COMPONENT_GROUP_UPDATE_FAILED", - MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", - SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND", - SUBSCRIBER_CREATE_FAILED: "SUBSCRIBER_CREATE_FAILED", - IDENTIFIER_REQUIRED: "IDENTIFIER_REQUIRED", - INVALID_CUSTOM_DOMAIN: "INVALID_CUSTOM_DOMAIN", - INVALID_ICON_URL: "INVALID_ICON_URL", - PASSWORD_REQUIRED: "PASSWORD_REQUIRED", - AUTH_EMAIL_DOMAINS_REQUIRED: "AUTH_EMAIL_DOMAINS_REQUIRED", -} as const; +import { ErrorReason, idRequiredError, rpcError } from "../../errors"; -export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; +export { monitorNotFoundError, pageComponentNotFoundError } from "../../errors"; -const DOMAIN = "openstatus.dev"; - -/** - * Creates a ConnectError with structured metadata. - */ -function createError( - message: string, - code: Code, - reason: ErrorReason, - metadata?: Record, -): ConnectError { - const headers = new Headers({ - "error-domain": DOMAIN, - "error-reason": reason, - }); - - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - headers.set(`error-${key}`, value); - } - } - - return new ConnectError(message, code, headers); -} - -/** - * Creates a "status page not found" error. - */ export function statusPageNotFoundError(pageId: string): ConnectError { - return createError( - "Status page not found", - Code.NotFound, - ErrorReason.STATUS_PAGE_NOT_FOUND, - { "page-id": pageId }, - ); + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.STATUS_PAGE_NOT_FOUND, + message: "Status page not found", + metadata: { pageId }, + }); } -/** - * Creates a "status page ID required" error. - */ export function statusPageIdRequiredError(): ConnectError { - return createError( - "Status page ID is required", - Code.InvalidArgument, - ErrorReason.STATUS_PAGE_ID_REQUIRED, - ); -} - -/** - * Creates a "failed to create status page" error. - */ -export function statusPageCreateFailedError(): ConnectError { - return createError( - "Failed to create status page", - Code.Internal, - ErrorReason.STATUS_PAGE_CREATE_FAILED, - ); + return idRequiredError("Status page"); } -/** - * Creates a "failed to update status page" error. - */ -export function statusPageUpdateFailedError(pageId: string): ConnectError { - return createError( - "Failed to update status page", - Code.Internal, - ErrorReason.STATUS_PAGE_UPDATE_FAILED, - { "page-id": pageId }, - ); -} - -/** - * Creates a "slug already exists" error. - */ export function slugAlreadyExistsError(slug: string): ConnectError { - return createError( - "A status page with this slug already exists", - Code.AlreadyExists, - ErrorReason.SLUG_ALREADY_EXISTS, - { slug }, - ); + return rpcError({ + code: Code.AlreadyExists, + reason: ErrorReason.SLUG_ALREADY_EXISTS, + message: "A status page with this slug already exists", + metadata: { slug }, + }); } -/** - * Creates a "status page not published" error. - * Used when trying to access an unpublished page via public slug. - */ +/** Unpublished pages are invisible on the public slug lookup. */ export function statusPageNotPublishedError(slug: string): ConnectError { - return createError( - "Status page is not published", - Code.NotFound, - ErrorReason.STATUS_PAGE_NOT_PUBLISHED, - { slug }, - ); + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.STATUS_PAGE_NOT_PUBLISHED, + message: "Status page is not published", + metadata: { slug }, + }); } -/** - * Creates a "status page access denied" error. - * Used when trying to access a protected page without proper authentication. - */ export function statusPageAccessDeniedError( slug: string, accessType: string, ): ConnectError { - return createError( - `Status page requires ${accessType} access`, - Code.PermissionDenied, - ErrorReason.STATUS_PAGE_ACCESS_DENIED, - { slug, "access-type": accessType }, - ); -} - -/** - * Creates a "page component not found" error. - */ -export function pageComponentNotFoundError(componentId: string): ConnectError { - return createError( - "Page component not found", - Code.NotFound, - ErrorReason.PAGE_COMPONENT_NOT_FOUND, - { "component-id": componentId }, - ); + return rpcError({ + code: Code.PermissionDenied, + reason: ErrorReason.STATUS_PAGE_ACCESS_DENIED, + message: `Status page requires ${accessType} access`, + metadata: { slug, accessType }, + }); } -/** - * Creates a "component group not found" error. - */ export function componentGroupNotFoundError(groupId: string): ConnectError { - return createError( - "Component group not found", - Code.NotFound, - ErrorReason.COMPONENT_GROUP_NOT_FOUND, - { "group-id": groupId }, - ); -} - -/** - * Creates a "monitor not found" error. - */ -export function monitorNotFoundError(monitorId: string): ConnectError { - return createError( - "Monitor not found", - Code.NotFound, - ErrorReason.MONITOR_NOT_FOUND, - { "monitor-id": monitorId }, - ); + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.COMPONENT_GROUP_NOT_FOUND, + message: "Component group not found", + metadata: { groupId }, + }); } -/** - * Creates a "subscriber not found" error. - */ export function subscriberNotFoundError(identifier: string): ConnectError { - return createError( - "Subscriber not found", - Code.NotFound, - ErrorReason.SUBSCRIBER_NOT_FOUND, - { identifier }, - ); + return rpcError({ + code: Code.NotFound, + reason: ErrorReason.SUBSCRIBER_NOT_FOUND, + message: "Subscriber not found", + metadata: { identifier }, + }); } -/** - * Creates a "failed to create subscriber" error. - */ export function subscriberCreateFailedError(): ConnectError { - return createError( - "Failed to create subscriber", - Code.Internal, - ErrorReason.SUBSCRIBER_CREATE_FAILED, - ); + return rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: "Failed to create subscriber", + }); } -/** - * Creates an "identifier required" error. - */ export function identifierRequiredError(): ConnectError { - return createError( - "Either email or token is required to identify the subscriber", - Code.InvalidArgument, - ErrorReason.IDENTIFIER_REQUIRED, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.IDENTIFIER_REQUIRED, + message: "Either email or token is required to identify the subscriber", + }); } export function invalidCustomDomainError(domain: string): ConnectError { - return createError( - "Custom domain must not contain 'openstatus' or start with http://, https://, or www.", - Code.InvalidArgument, - ErrorReason.INVALID_CUSTOM_DOMAIN, - { "custom-domain": domain }, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_CUSTOM_DOMAIN, + message: + "Custom domain must not contain 'openstatus' or start with http://, https://, or www.", + metadata: { customDomain: domain }, + }); } export function invalidIconUrlError(): ConnectError { - return createError( - "Icon must be a valid URL", - Code.InvalidArgument, - ErrorReason.INVALID_ICON_URL, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_ICON_URL, + message: "Icon must be a valid URL", + }); } export function passwordRequiredError(): ConnectError { - return createError( - "Password is required when access_type is PASSWORD_PROTECTED", - Code.InvalidArgument, - ErrorReason.PASSWORD_REQUIRED, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.PASSWORD_REQUIRED, + message: "Password is required when access_type is PASSWORD_PROTECTED", + }); } export function authEmailDomainsRequiredError(): ConnectError { - return createError( - "At least one email domain is required when access_type is AUTHENTICATED", - Code.InvalidArgument, - ErrorReason.AUTH_EMAIL_DOMAINS_REQUIRED, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.AUTH_EMAIL_DOMAINS_REQUIRED, + message: + "At least one email domain is required when access_type is AUTHENTICATED", + }); } diff --git a/apps/server/src/routes/rpc/handlers/status-page/index.ts b/apps/server/src/routes/rpc/handlers/status-page/index.ts index 06d77cc5..57835c62 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -36,7 +36,6 @@ import { } from "@openstatus/proto/status_page/v1"; import { ConflictError, - LimitExceededError, NotFoundError, ServiceError, withTransaction, @@ -81,6 +80,11 @@ import { } from "@openstatus/theme-store"; import { toConnectError, toServiceCtx } from "../../adapter"; +import { + ErrorReason, + planFeatureNotAvailableError, + rpcError, +} from "../../errors"; import { getRpcContext } from "../../interceptors"; import { dbImpactToProto } from "../status-report/converters"; import { @@ -216,10 +220,17 @@ function validateAuthEmailDomains(domains: string[]): string[] { } for (const domain of trimmed) { if (!domain.includes(".")) { - throw new ConnectError( - `Invalid email domain: "${domain}"`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid email domain: "${domain}"`, + fieldViolations: [ + { + field: "authEmailDomains", + description: `Invalid email domain: "${domain}"`, + }, + ], + }); } } return trimmed; @@ -235,10 +246,17 @@ function validateAllowedIpRanges(ranges: string): string[] { .map((s) => s.trim()) .filter(Boolean); if (entries.length === 0) { - throw new ConnectError( - "At least one IP range is required for IP restriction", - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: "At least one IP range is required for IP restriction", + fieldViolations: [ + { + field: "ipRestriction", + description: "At least one IP range is required for IP restriction", + }, + ], + }); } const cidrRegex = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)\/(3[0-2]|[12]?\d)$/; @@ -246,10 +264,17 @@ function validateAllowedIpRanges(ranges: string): string[] { for (const entry of entries) { const value = entry.includes("/") ? entry : `${entry}/32`; if (!cidrRegex.test(value)) { - throw new ConnectError( - `Invalid IPv4 CIDR range: "${entry}"`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid IPv4 CIDR range: "${entry}"`, + fieldViolations: [ + { + field: "ipRestriction", + description: `Invalid IPv4 CIDR range: "${entry}"`, + }, + ], + }); } normalized.push(value); } @@ -269,7 +294,15 @@ function validateProtoCustomTheme(customTheme: CustomTheme): { }; const result = validateCustomTheme(input); if (!result.valid) { - throw new ConnectError(result.errors.join(" "), Code.InvalidArgument); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: result.errors.join(" "), + fieldViolations: result.errors.map((description) => ({ + field: "customTheme", + description, + })), + }); } return input; } @@ -521,19 +554,19 @@ export const statusPageServiceImpl: ServiceImpl = { throw slugAlreadyExistsError(req.slug); } - // i18n — keep at handler to preserve PermissionDenied over the - // service's LimitExceededError → ResourceExhausted mapping. + // i18n — keep at handler so the reason is PLAN_FEATURE_NOT_AVAILABLE + // rather than the service's generic PLAN_LIMIT_REACHED. if (!limits.i18n) { if (req.defaultLocale !== undefined && req.defaultLocale !== 0) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade to configure locales.", - Code.PermissionDenied, + "i18n", ); } if (req.locales.length > 0) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade to configure locales.", - Code.PermissionDenied, + "i18n", ); } } @@ -548,10 +581,18 @@ export const statusPageServiceImpl: ServiceImpl = { ? [...new Set(validLocales.map(protoLocaleToDb))] : null; if (locales && !locales.includes(defaultLocale)) { - throw new ConnectError( - "Default locale must be included in the locales list", - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: "Default locale must be included in the locales list", + fieldViolations: [ + { + field: "defaultLocale", + description: + "Default locale must be included in the locales list", + }, + ], + }); } // Proto-specific format validations (regex / URL shape) — these @@ -636,23 +677,6 @@ export const statusPageServiceImpl: ServiceImpl = { allowIndex, customTheme, }, - }).catch((err) => { - // Same handler-layer remap as the `i18n` pre-check above — - // preserve `PermissionDenied` (403) for "plan quota reached" - // on `status-pages`. The service throws `LimitExceededError` - // which the Connect adapter maps to `ResourceExhausted` - // (429), but the gRPC contract here is 403 for "upgrade - // required". - if ( - err instanceof LimitExceededError && - err.message.startsWith("status-pages") - ) { - throw new ConnectError( - "Upgrade for more status pages.", - Code.PermissionDenied, - ); - } - throw err; }); return { statusPage: dbPageToProto(serviceToConverterPage(created)) }; @@ -724,15 +748,15 @@ export const statusPageServiceImpl: ServiceImpl = { // i18n — keep at handler to preserve PermissionDenied. if (!limits.i18n) { if (req.defaultLocale !== undefined && req.defaultLocale !== 0) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade to configure locales.", - Code.PermissionDenied, + "i18n", ); } if (req.locales.length > 0) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade to configure locales.", - Code.PermissionDenied, + "i18n", ); } } @@ -828,10 +852,18 @@ export const statusPageServiceImpl: ServiceImpl = { ? [...new Set(validLocales.map(protoLocaleToDb))] : existing.locales; if (nextLocales && !nextLocales.includes(nextDefaultLocale)) { - throw new ConnectError( - "Default locale must be included in the locales list", - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: "Default locale must be included in the locales list", + fieldViolations: [ + { + field: "defaultLocale", + description: + "Default locale must be included in the locales list", + }, + ], + }); } const localesChanged = limits.i18n === true && @@ -1361,10 +1393,19 @@ export const statusPageServiceImpl: ServiceImpl = { req.channel.case !== "emailChannel" && req.channel.case !== "webhookChannel" ) { - throw new ConnectError( - "channel oneof must be set to email_channel or webhook_channel", - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: + "channel oneof must be set to email_channel or webhook_channel", + fieldViolations: [ + { + field: "channel", + description: + "channel oneof must be set to email_channel or webhook_channel", + }, + ], + }); } let result: Awaited>; diff --git a/apps/server/src/routes/rpc/handlers/status-page/limits.ts b/apps/server/src/routes/rpc/handlers/status-page/limits.ts index 86c5f72a..977c14f6 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/limits.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/limits.ts @@ -1,8 +1,12 @@ -import { Code, ConnectError } from "@connectrpc/connect"; import { count, db, eq } from "@openstatus/db"; import { page, pageComponent } from "@openstatus/db/src/schema"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; +import { + planFeatureNotAvailableError, + planLimitReachedError, +} from "../../errors"; + /** * Check workspace limits for creating a new status page. * Throws ConnectError with PermissionDenied if limit is exceeded. @@ -20,9 +24,11 @@ export async function checkStatusPageLimits( const currentCount = countResult?.count ?? 0; if (currentCount >= limits["status-pages"]) { - throw new ConnectError( + throw planLimitReachedError( "Upgrade for more status pages", - Code.PermissionDenied, + "status-pages", + limits["status-pages"], + currentCount, ); } } @@ -33,7 +39,10 @@ export async function checkStatusPageLimits( */ export function checkCustomDomainLimit(limits: Limits): void { if (!limits["custom-domain"]) { - throw new ConnectError("Upgrade for custom domains", Code.PermissionDenied); + throw planFeatureNotAvailableError( + "Upgrade for custom domains", + "custom-domain", + ); } } @@ -43,9 +52,9 @@ export function checkCustomDomainLimit(limits: Limits): void { */ export function checkPasswordProtectionLimit(limits: Limits): void { if (!limits["password-protection"]) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade for password protection", - Code.PermissionDenied, + "password-protection", ); } } @@ -56,9 +65,9 @@ export function checkPasswordProtectionLimit(limits: Limits): void { */ export function checkEmailDomainProtectionLimit(limits: Limits): void { if (!limits["email-domain-protection"]) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade for email domain protection", - Code.PermissionDenied, + "email-domain-protection", ); } } @@ -69,7 +78,10 @@ export function checkEmailDomainProtectionLimit(limits: Limits): void { */ export function checkIpRestrictionLimit(limits: Limits): void { if (!limits["ip-restriction"]) { - throw new ConnectError("Upgrade for IP restriction", Code.PermissionDenied); + throw planFeatureNotAvailableError( + "Upgrade for IP restriction", + "ip-restriction", + ); } } @@ -79,9 +91,9 @@ export function checkIpRestrictionLimit(limits: Limits): void { */ export function checkNoIndexLimit(limits: Limits): void { if (!limits["no-index"]) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade for search engine indexing toggle", - Code.PermissionDenied, + "no-index", ); } } @@ -92,15 +104,18 @@ export function checkNoIndexLimit(limits: Limits): void { */ export function checkCustomThemeLimit(limits: Limits): void { if (!limits["custom-theme"]) { - throw new ConnectError("Upgrade for custom theme", Code.PermissionDenied); + throw planFeatureNotAvailableError( + "Upgrade for custom theme", + "custom-theme", + ); } } export function checkStatusSubscribersLimit(limits: Limits): void { if (!limits["status-subscribers"]) { - throw new ConnectError( + throw planFeatureNotAvailableError( "Upgrade to use status subscribers", - Code.PermissionDenied, + "status-subscribers", ); } } @@ -121,9 +136,11 @@ export async function checkPageComponentLimits( const currentCount = countResult?.count ?? 0; if (currentCount >= limits["page-components"]) { - throw new ConnectError( + throw planLimitReachedError( "Upgrade for more page components", - Code.PermissionDenied, + "page-components", + limits["page-components"], + currentCount, ); } } diff --git a/apps/server/src/routes/rpc/handlers/status-report/converters.ts b/apps/server/src/routes/rpc/handlers/status-report/converters.ts index 0d6b444c..d69d87f3 100644 --- a/apps/server/src/routes/rpc/handlers/status-report/converters.ts +++ b/apps/server/src/routes/rpc/handlers/status-report/converters.ts @@ -1,4 +1,4 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code } from "@connectrpc/connect"; import type { ComponentImpact, StatusReport, @@ -10,6 +10,7 @@ import { StatusReportStatus, } from "@openstatus/proto/status_report/v1"; +import { ErrorReason, rpcError } from "../../errors"; import { invalidStatusError } from "./errors"; type DBPageComponentImpact = @@ -124,10 +125,17 @@ export function protoImpactToDb( case PageComponentImpact.MAJOR_OUTAGE: return "major_outage"; default: - throw new ConnectError( - `Invalid component impact: ${impact}`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid component impact: ${impact}`, + fieldViolations: [ + { + field: "impact", + description: `Invalid component impact: ${impact}`, + }, + ], + }); } } diff --git a/apps/server/src/routes/rpc/handlers/status-report/errors.ts b/apps/server/src/routes/rpc/handlers/status-report/errors.ts index 39fe7fbe..0ade4ade 100644 --- a/apps/server/src/routes/rpc/handlers/status-report/errors.ts +++ b/apps/server/src/routes/rpc/handlers/status-report/errors.ts @@ -1,160 +1,21 @@ -import { Code, ConnectError } from "@connectrpc/connect"; +import { Code, type ConnectError } from "@connectrpc/connect"; -/** - * Error reasons for structured error handling. - */ -export const ErrorReason = { - STATUS_REPORT_NOT_FOUND: "STATUS_REPORT_NOT_FOUND", - STATUS_REPORT_ID_REQUIRED: "STATUS_REPORT_ID_REQUIRED", - STATUS_REPORT_CREATE_FAILED: "STATUS_REPORT_CREATE_FAILED", - STATUS_REPORT_UPDATE_FAILED: "STATUS_REPORT_UPDATE_FAILED", - PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", - PAGE_COMPONENTS_MIXED_PAGES: "PAGE_COMPONENTS_MIXED_PAGES", - PAGE_ID_COMPONENT_MISMATCH: "PAGE_ID_COMPONENT_MISMATCH", - INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", - INVALID_STATUS: "INVALID_STATUS", -} as const; +import { ErrorReason, idRequiredError, rpcError } from "../../errors"; -export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; +export { + invalidDateFormatError, + pageComponentNotFoundError, +} from "../../errors"; -const DOMAIN = "openstatus.dev"; - -/** - * Creates a ConnectError with structured metadata. - */ -function createError( - message: string, - code: Code, - reason: ErrorReason, - metadata?: Record, -): ConnectError { - const headers = new Headers({ - "error-domain": DOMAIN, - "error-reason": reason, - }); - - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - headers.set(`error-${key}`, value); - } - } - - return new ConnectError(message, code, headers); -} - -/** - * Creates a "status report not found" error. - */ -export function statusReportNotFoundError( - statusReportId: string, -): ConnectError { - return createError( - "Status report not found", - Code.NotFound, - ErrorReason.STATUS_REPORT_NOT_FOUND, - { "status-report-id": statusReportId }, - ); -} - -/** - * Creates a "status report ID required" error. - */ export function statusReportIdRequiredError(): ConnectError { - return createError( - "Status report ID is required", - Code.InvalidArgument, - ErrorReason.STATUS_REPORT_ID_REQUIRED, - ); + return idRequiredError("Status report"); } -/** - * Creates a "failed to create status report" error. - */ -export function statusReportCreateFailedError(): ConnectError { - return createError( - "Failed to create status report", - Code.Internal, - ErrorReason.STATUS_REPORT_CREATE_FAILED, - ); -} - -/** - * Creates a "failed to update status report" error. - */ -export function statusReportUpdateFailedError( - statusReportId: string, -): ConnectError { - return createError( - "Failed to update status report", - Code.Internal, - ErrorReason.STATUS_REPORT_UPDATE_FAILED, - { "status-report-id": statusReportId }, - ); -} - -/** - * Creates a "page component not found" error. - */ -export function pageComponentNotFoundError( - pageComponentId: string, -): ConnectError { - return createError( - "Page component not found", - Code.NotFound, - ErrorReason.PAGE_COMPONENT_NOT_FOUND, - { "page-component-id": pageComponentId }, - ); -} - -/** - * Creates a "page components from mixed pages" error. - */ -export function pageComponentsMixedPagesError(): ConnectError { - return createError( - "All page components must belong to the same page", - Code.InvalidArgument, - ErrorReason.PAGE_COMPONENTS_MIXED_PAGES, - ); -} - -/** - * Creates an "invalid date format" error. - */ -export function invalidDateFormatError(dateValue: string): ConnectError { - return createError( - "Invalid date format. Expected RFC 3339 format (e.g., 2024-01-15T10:30:00Z)", - Code.InvalidArgument, - ErrorReason.INVALID_DATE_FORMAT, - { "date-value": dateValue }, - ); -} - -/** - * Creates an "invalid status" error. - */ export function invalidStatusError(statusValue: number): ConnectError { - return createError( - `Invalid status value: ${statusValue}. Expected INVESTIGATING, IDENTIFIED, MONITORING, or RESOLVED`, - Code.InvalidArgument, - ErrorReason.INVALID_STATUS, - { "status-value": String(statusValue) }, - ); -} - -/** - * Creates a "page ID and component page mismatch" error. - */ -export function pageIdComponentMismatchError( - providedPageId: string, - componentPageId: string, -): ConnectError { - return createError( - `Page ID ${providedPageId} does not match the page ID ${componentPageId} of the provided components`, - Code.InvalidArgument, - ErrorReason.PAGE_ID_COMPONENT_MISMATCH, - { - "provided-page-id": providedPageId, - "component-page-id": componentPageId, - }, - ); + return rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.INVALID_STATUS, + message: `Invalid status value: ${statusValue}. Expected INVESTIGATING, IDENTIFIED, MONITORING, or RESOLVED`, + metadata: { status: String(statusValue) }, + }); } diff --git a/apps/server/src/routes/rpc/handlers/status-report/index.ts b/apps/server/src/routes/rpc/handlers/status-report/index.ts index e50d07dd..c9b2abdb 100644 --- a/apps/server/src/routes/rpc/handlers/status-report/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-report/index.ts @@ -1,4 +1,4 @@ -import { Code, ConnectError, type ServiceImpl } from "@connectrpc/connect"; +import { Code, type ServiceImpl } from "@connectrpc/connect"; import type { ComponentImpact, StatusReportService, @@ -15,6 +15,7 @@ import { } from "@openstatus/services/status-report"; import { toConnectError, toServiceCtx } from "../../adapter"; +import { ErrorReason, rpcError } from "../../errors"; import { getRpcContext } from "../../interceptors"; import { dbReportToProto, @@ -41,10 +42,17 @@ function parsePageComponentIds(ids: ReadonlyArray): number[] { return ids.map((id) => { const trimmed = id.trim(); if (!PAGE_COMPONENT_ID.test(trimmed)) { - throw new ConnectError( - `Invalid page component id: "${id}"`, - Code.InvalidArgument, - ); + throw rpcError({ + code: Code.InvalidArgument, + reason: ErrorReason.VALIDATION_FAILED, + message: `Invalid page component id: "${id}"`, + fieldViolations: [ + { + field: "pageComponentIds", + description: `Invalid page component id: "${id}"`, + }, + ], + }); } return Number(trimmed); }); diff --git a/apps/server/src/routes/rpc/index.ts b/apps/server/src/routes/rpc/index.ts index f6ff1879..cdad16fa 100644 --- a/apps/server/src/routes/rpc/index.ts +++ b/apps/server/src/routes/rpc/index.ts @@ -1,9 +1,12 @@ +import { Code } from "@connectrpc/connect"; import { universalServerRequestFromFetch, universalServerResponseToFetch, } from "@connectrpc/connect/protocol"; import type { Hono } from "hono"; +import { connectErrorToJson, rpcError, withErrorInfo } from "@/libs/errors/rpc"; + import { routes } from "./router"; // Re-export for external use @@ -28,6 +31,7 @@ export function mountRpcRoutes( const url = new URL(c.req.url); // Remove the /rpc prefix from the path for matching const pathWithoutPrefix = url.pathname.replace(/^\/rpc/, ""); + const requestId = c.get("requestId" as never) as string | undefined; // Find the handler that matches this request const handler = routes.handlers.find( @@ -35,21 +39,39 @@ export function mountRpcRoutes( ); if (!handler) { - return c.json({ error: "Not found" }, 404); + const err = rpcError({ + code: Code.NotFound, + reason: "NOT_FOUND", + message: "Not found", + }); + return c.json(connectErrorToJson(withErrorInfo(err, requestId)), 404); } // Check if the HTTP method is allowed if (!handler.allowedMethods.includes(c.req.method)) { - return c.json({ error: "Method not allowed" }, 405); + const err = rpcError({ + code: Code.Unimplemented, + reason: "METHOD_NOT_ALLOWED", + message: "Method not allowed", + }); + return c.json(connectErrorToJson(withErrorInfo(err, requestId)), 405); } - // Convert fetch Request to universal request - const universalRequest = universalServerRequestFromFetch(c.req.raw, {}); + // Hono already minted (or accepted) the request id; hand the same one to + // the interceptors so the client, the wide event and the RPC log agree. + const headers = new Headers(c.req.raw.headers); + if (requestId) headers.set("x-request-id", requestId); + const universalRequest = universalServerRequestFromFetch( + new Request(c.req.raw, { headers }), + {}, + ); // Call the handler const universalResponse = await handler(universalRequest); - // Convert universal response back to fetch Response - return universalServerResponseToFetch(universalResponse); + // A raw Response skips Hono's prepared headers, so echo the id ourselves. + const res = universalServerResponseToFetch(universalResponse); + if (requestId) res.headers.set("x-request-id", requestId); + return res; }); } diff --git a/apps/server/src/routes/rpc/interceptors/__tests__/error.test.ts b/apps/server/src/routes/rpc/interceptors/__tests__/error.test.ts index 8997a44f..4660ccb4 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/error.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/error.test.ts @@ -1,6 +1,11 @@ import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"; +import { errorDocsUrl } from "@openstatus/error"; +import { ErrorInfoSchema } from "@openstatus/proto/google/rpc"; import { describe, expect, test } from "@openstatus/test-utils"; +import { OpenStatusApiError } from "@/libs/errors"; +import { ErrorReason, rpcError } from "@/libs/errors/rpc"; + import { RPC_CONTEXT_KEY } from "../auth"; import { errorInterceptor } from "../error"; @@ -11,7 +16,10 @@ function mockNextReject(error: unknown): NextFn { return (() => Promise.reject(error)) as unknown as NextFn; } -function createMockRequest(opts?: { requestId?: string }): RpcRequest { +function createMockRequest(opts?: { + requestId?: string; + headers?: Record; +}): RpcRequest { const contextValues = new Map(); if (opts?.requestId) { contextValues.set(RPC_CONTEXT_KEY, { requestId: opts.requestId }); @@ -20,7 +28,7 @@ function createMockRequest(opts?: { requestId?: string }): RpcRequest { service: { typeName: "openstatus.status_page.v1.StatusPageService" }, method: { name: "AddMonitorComponent" }, message: {}, - header: new Headers(), + header: new Headers(opts?.headers), contextValues: { get: (key: unknown) => contextValues.get(key) }, } as unknown as RpcRequest; } @@ -34,6 +42,13 @@ async function captureReject(fn: () => Promise): Promise { throw new Error("expected fn to reject, but it resolved"); } +const run = (error: unknown, req: RpcRequest) => + captureReject(() => + errorInterceptor()(mockNextReject(error))(req), + ) as Promise; + +const info = (err: ConnectError) => err.findDetails(ErrorInfoSchema)[0]; + // A drizzle `DrizzleQueryError.message` is the rendered SQL plus the bound // params — the exact shape that leaked to a Terraform user via the RPC API. const DRIZZLE_ERROR = new Error( @@ -43,45 +58,117 @@ const DRIZZLE_ERROR = new Error( describe("errorInterceptor", () => { test("redacts an unclassified error to an opaque Internal", async () => { - const err = (await captureReject(() => - errorInterceptor()(mockNextReject(DRIZZLE_ERROR))( - createMockRequest({ requestId: "req-1" }), - ), - )) as ConnectError; + const err = await run( + DRIZZLE_ERROR, + createMockRequest({ requestId: "req-1" }), + ); expect(err).toBeInstanceOf(ConnectError); expect(err.code).toBe(Code.Internal); expect(err.rawMessage).not.toContain("page_component"); expect(err.rawMessage).not.toContain("params:"); expect(err.rawMessage).not.toContain("insert into"); + expect(info(err)).toMatchObject({ + reason: ErrorReason.INTERNAL_SERVER_ERROR, + metadata: { + requestId: "req-1", + docs: errorDocsUrl("INTERNAL_SERVER_ERROR"), + }, + }); }); test("carries the request id so logs and client share a correlation id", async () => { - const err = (await captureReject(() => - errorInterceptor()(mockNextReject(DRIZZLE_ERROR))( - createMockRequest({ requestId: "req-correlate-me" }), - ), - )) as ConnectError; + const err = await run( + DRIZZLE_ERROR, + createMockRequest({ requestId: "req-correlate-me" }), + ); expect(err.rawMessage).toContain("req-correlate-me"); + expect(info(err).metadata.requestId).toBe("req-correlate-me"); }); test("falls back to a bare message when there is no RPC context", async () => { - const err = (await captureReject(() => - errorInterceptor()(mockNextReject(DRIZZLE_ERROR))(createMockRequest()), - )) as ConnectError; + const err = await run(DRIZZLE_ERROR, createMockRequest()); expect(err.rawMessage).toBe("Internal server error"); + expect(info(err).metadata).not.toHaveProperty("requestId"); }); - test("passes an existing ConnectError through unchanged", async () => { - const original = new ConnectError("Monitor not found", Code.NotFound); - const err = await captureReject(() => - errorInterceptor()(mockNextReject(original))( - createMockRequest({ requestId: "req-2" }), - ), + test("uses the x-request-id header before auth has built the context", async () => { + const err = await run( + new ConnectError("Invalid API Key", Code.Unauthenticated), + createMockRequest({ headers: { "x-request-id": "hdr-1" } }), ); + expect(info(err).metadata.requestId).toBe("hdr-1"); + }); + + test("passes an existing ConnectError through unchanged, adding ErrorInfo", async () => { + const original = new ConnectError("Monitor not found", Code.NotFound); + const err = await run(original, createMockRequest({ requestId: "req-2" })); + expect(err).toBe(original); + expect(err.code).toBe(Code.NotFound); + expect(info(err)).toMatchObject({ + reason: ErrorReason.NOT_FOUND, + metadata: { requestId: "req-2", docs: errorDocsUrl("NOT_FOUND") }, + }); + }); + + test("keeps a handler's specific reason and metadata", async () => { + const original = rpcError({ + code: Code.NotFound, + reason: ErrorReason.MONITOR_NOT_FOUND, + message: "Monitor not found", + metadata: { monitorId: "5" }, + }); + const err = await run(original, createMockRequest({ requestId: "req-3" })); + + expect(err.findDetails(ErrorInfoSchema)).toHaveLength(1); + expect(info(err)).toMatchObject({ + reason: ErrorReason.MONITOR_NOT_FOUND, + metadata: { monitorId: "5", requestId: "req-3" }, + }); + }); + + test("maps OpenStatusApiError codes to Connect codes with the v1 code as reason", async () => { + const err = await run( + new OpenStatusApiError({ + code: "NOT_FOUND", + message: "Workspace not found", + }), + createMockRequest({ requestId: "req-4" }), + ); + + expect(err.code).toBe(Code.NotFound); + expect(err.rawMessage).toBe("Workspace not found"); + expect(info(err)).toMatchObject({ + reason: "NOT_FOUND", + metadata: { requestId: "req-4", docs: errorDocsUrl("NOT_FOUND") }, + }); + }); + + test("maps every v1 code an OpenStatusApiError can carry", async () => { + const cases = [ + ["BAD_REQUEST", Code.InvalidArgument], + ["UNAUTHORIZED", Code.Unauthenticated], + ["PAYMENT_REQUIRED", Code.ResourceExhausted], + ["FORBIDDEN", Code.PermissionDenied], + ["CONFLICT", Code.AlreadyExists], + ["UNPROCESSABLE_ENTITY", Code.InvalidArgument], + ["TOO_MANY_REQUESTS", Code.ResourceExhausted], + ["INTERNAL_SERVER_ERROR", Code.Internal], + ["SERVICE_UNAVAILABLE", Code.Unavailable], + ] as const; + for (const [code, connect] of cases) { + const err = await run( + new OpenStatusApiError({ code, message: code }), + createMockRequest({ requestId: "req-5" }), + ); + expect(err.code).toBe(connect); + expect(info(err).reason).toBe(code); + // Docs follow the v1 code, not the lossy Connect code. + expect(info(err).metadata.docs).toBe(errorDocsUrl(code)); + } }); }); diff --git a/apps/server/src/routes/rpc/interceptors/auth.ts b/apps/server/src/routes/rpc/interceptors/auth.ts index 325b9b53..50a07bcb 100644 --- a/apps/server/src/routes/rpc/interceptors/auth.ts +++ b/apps/server/src/routes/rpc/interceptors/auth.ts @@ -1,6 +1,8 @@ -import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"; +import { Code, type Interceptor } from "@connectrpc/connect"; import { nanoid } from "nanoid"; +import { ErrorReason, rpcError } from "@/libs/errors/rpc"; + import { lookupWorkspace, validateKey } from "../../../libs/middlewares/auth"; import { MISSING_CREDENTIALS_MESSAGE, @@ -25,23 +27,39 @@ export function authInterceptor(): Interceptor { const credential = extractCredential(req.header); if (!credential) { - throw new ConnectError(MISSING_CREDENTIALS_MESSAGE, Code.Unauthenticated); + throw rpcError({ + code: Code.Unauthenticated, + reason: ErrorReason.MISSING_CREDENTIALS, + message: MISSING_CREDENTIALS_MESSAGE, + }); } const { error, result } = await validateKey(credential.token); if (error) { - throw new ConnectError(error.message, Code.Unauthenticated); + throw rpcError({ + code: Code.Unauthenticated, + reason: ErrorReason.INVALID_API_KEY, + message: error.message, + }); } if (!result.valid || !result.ownerId) { - throw new ConnectError("Invalid API Key", Code.Unauthenticated); + throw rpcError({ + code: Code.Unauthenticated, + reason: ErrorReason.INVALID_API_KEY, + message: "Invalid API Key", + }); } const ownerId = Number.parseInt(result.ownerId); if (Number.isNaN(ownerId)) { - throw new ConnectError("Invalid API Key format", Code.Unauthenticated); + throw rpcError({ + code: Code.Unauthenticated, + reason: ErrorReason.INVALID_API_KEY, + message: "Invalid API Key format", + }); } // lookupWorkspace throws OpenStatusApiError if not found diff --git a/apps/server/src/routes/rpc/interceptors/context.ts b/apps/server/src/routes/rpc/interceptors/context.ts index f58f0b57..6b10066a 100644 --- a/apps/server/src/routes/rpc/interceptors/context.ts +++ b/apps/server/src/routes/rpc/interceptors/context.ts @@ -1,6 +1,8 @@ -import { Code, ConnectError, createContextKey } from "@connectrpc/connect"; +import { Code, createContextKey } from "@connectrpc/connect"; import type { Scope, Workspace } from "@openstatus/db/src/schema"; +import { ErrorReason, rpcError } from "@/libs/errors/rpc"; + /** * RPC context containing workspace and request information. * This is set by the auth interceptor and available to all handlers. @@ -36,10 +38,11 @@ export function getRpcContext(ctx: { }): RpcContext { const rpcCtx = ctx.values.get(RPC_CONTEXT_KEY); if (!rpcCtx) { - throw new ConnectError( - "RPC context not found - auth interceptor may not have run", - Code.Internal, - ); + throw rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: "RPC context not found - auth interceptor may not have run", + }); } return rpcCtx; } diff --git a/apps/server/src/routes/rpc/interceptors/error.ts b/apps/server/src/routes/rpc/interceptors/error.ts index 4b49e1e3..7032c1fa 100644 --- a/apps/server/src/routes/rpc/interceptors/error.ts +++ b/apps/server/src/routes/rpc/interceptors/error.ts @@ -1,48 +1,36 @@ import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"; import { getLogger } from "@logtape/logtape"; -import type { ErrorCode } from "@openstatus/error"; import { OpenStatusApiError } from "@/libs/errors"; +import { + ERROR_CODE_TO_CONNECT, + ErrorReason, + rpcError, + withErrorInfo, +} from "@/libs/errors/rpc"; import { RPC_CONTEXT_KEY } from "./auth"; const logger = getLogger("api-server"); -/** - * Mapping from OpenStatus error codes to ConnectRPC codes. - */ -const ERROR_CODE_MAP: Record = { - BAD_REQUEST: Code.InvalidArgument, - UNAUTHORIZED: Code.Unauthenticated, - PAYMENT_REQUIRED: Code.ResourceExhausted, - FORBIDDEN: Code.PermissionDenied, - NOT_FOUND: Code.NotFound, - METHOD_NOT_ALLOWED: Code.Unimplemented, - CONFLICT: Code.AlreadyExists, - UNPROCESSABLE_ENTITY: Code.InvalidArgument, - TOO_MANY_REQUESTS: Code.ResourceExhausted, - INTERNAL_SERVER_ERROR: Code.Internal, - SERVICE_UNAVAILABLE: Code.Unavailable, -}; - /** * Opaque `Internal` error for anything we didn't classify. The request id is * the only detail that crosses the wire — it's the handle support needs to * find the real cause in the logs. */ export function internalError(requestId?: string): ConnectError { - return new ConnectError( - requestId + return rpcError({ + code: Code.Internal, + reason: ErrorReason.INTERNAL_SERVER_ERROR, + message: requestId ? `Internal server error (request id: ${requestId})` : "Internal server error", - Code.Internal, - ); + }); } /** - * Error mapping interceptor for ConnectRPC. - * Converts OpenStatusApiError to ConnectError with appropriate codes. - * Logs server errors and passes through client errors. + * Outermost interceptor: every error leaving the RPC layer is a ConnectError + * carrying `google.rpc.ErrorInfo` with `requestId` and `docs`. */ export function errorInterceptor(): Interceptor { return (next) => async (req) => { @@ -50,15 +38,17 @@ export function errorInterceptor(): Interceptor { return await next(req); } catch (error) { const rpcCtx = req.contextValues.get(RPC_CONTEXT_KEY); + // Auth failures throw before the context exists; the bridge stamps the + // Hono request id on the header for exactly that case. + const requestId = + rpcCtx?.requestId ?? req.header.get("x-request-id") ?? undefined; - // Already a ConnectError, pass through if (error instanceof ConnectError) { - throw error; + throw withErrorInfo(error, requestId); } - // Map OpenStatusApiError to ConnectError if (error instanceof OpenStatusApiError) { - const code = ERROR_CODE_MAP[error.code] ?? Code.Internal; + const code = ERROR_CODE_TO_CONNECT[error.code] ?? Code.Internal; // Log server errors (5xx equivalent) if (error.status >= 500) { @@ -67,11 +57,14 @@ export function errorInterceptor(): Interceptor { code: error.code, message: error.message, }, - requestId: rpcCtx?.requestId, + requestId, }); } - throw new ConnectError(error.message, code); + throw withErrorInfo( + rpcError({ code, reason: error.code, message: error.message }), + requestId, + ); } // Unknown error - log and wrap as Internal @@ -81,13 +74,13 @@ export function errorInterceptor(): Interceptor { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }, - requestId: rpcCtx?.requestId, + requestId, }); // Never forward the raw message: drizzle's `DrizzleQueryError` embeds // the full SQL and bound params, which would leak schema and other // rows' ids to the API client. - throw internalError(rpcCtx?.requestId); + throw withErrorInfo(internalError(requestId), requestId); } }; } diff --git a/apps/server/src/routes/rpc/interceptors/validation.ts b/apps/server/src/routes/rpc/interceptors/validation.ts index 4c90ce31..5f951da9 100644 --- a/apps/server/src/routes/rpc/interceptors/validation.ts +++ b/apps/server/src/routes/rpc/interceptors/validation.ts @@ -1,6 +1,8 @@ import { ConnectError, type Interceptor } from "@connectrpc/connect"; import { createValidateInterceptor } from "@connectrpc/validate"; +import { ErrorReason, rpcError } from "@/libs/errors/rpc"; + // Methods that skip standard protovalidate (they do manual validation in handlers) // These methods use partial updates where nested message fields are optional const SKIP_VALIDATION_METHODS = new Set([ @@ -21,6 +23,16 @@ function normalizeValidationMessage(message: string): string { ); } +const VIOLATIONS_TYPE = "buf.validate.Violations"; + +function isProtovalidateError(err: ConnectError): boolean { + return err.details.some((d) => + "desc" in d + ? d.desc.typeName === VIOLATIONS_TYPE + : d.type === VIOLATIONS_TYPE, + ); +} + /** * Validation interceptor for ConnectRPC using protovalidate. * Validates incoming request messages against their proto constraints. @@ -44,17 +56,18 @@ export function validationInterceptor(): Interceptor { try { return await baseInterceptor(next)(req); } catch (err) { - if (err instanceof ConnectError) { - const normalized = normalizeValidationMessage(err.rawMessage); - if (normalized !== err.rawMessage) { - throw new ConnectError( - normalized, - err.code, - err.metadata, - undefined, - err.cause, - ); - } + // Handler errors also pass through here; only protovalidate's own + // rejection (recognisable by its Violations detail) gets rebuilt. + if (err instanceof ConnectError && isProtovalidateError(err)) { + const rebuilt = rpcError({ + code: err.code, + reason: ErrorReason.VALIDATION_FAILED, + message: normalizeValidationMessage(err.rawMessage), + cause: err.cause, + }); + err.metadata.forEach((value, key) => rebuilt.metadata.set(key, value)); + rebuilt.details.push(...err.details); + throw rebuilt; } throw err; } diff --git a/apps/server/static/openapi-v1.json b/apps/server/static/openapi-v1.json index 75735c10..0ba4753b 100644 --- a/apps/server/static/openapi-v1.json +++ b/apps/server/static/openapi-v1.json @@ -423,7 +423,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/BAD_REQUEST" + "example": "https://www.openstatus.dev/docs/reference/api-errors#bad-request" }, "requestId": { "type": "string", @@ -462,7 +462,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/UNAUTHORIZED" + "example": "https://www.openstatus.dev/docs/reference/api-errors#unauthorized" }, "requestId": { "type": "string", @@ -501,7 +501,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/PAYMENT_REQUIRED" + "example": "https://www.openstatus.dev/docs/reference/api-errors#payment-required" }, "requestId": { "type": "string", @@ -540,7 +540,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/FORBIDDEN" + "example": "https://www.openstatus.dev/docs/reference/api-errors#forbidden" }, "requestId": { "type": "string", @@ -579,7 +579,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/NOT_FOUND" + "example": "https://www.openstatus.dev/docs/reference/api-errors#not-found" }, "requestId": { "type": "string", @@ -618,7 +618,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/CONFLICT" + "example": "https://www.openstatus.dev/docs/reference/api-errors#conflict" }, "requestId": { "type": "string", @@ -657,7 +657,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/TOO_MANY_REQUESTS" + "example": "https://www.openstatus.dev/docs/reference/api-errors#too-many-requests" }, "requestId": { "type": "string", @@ -696,7 +696,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/INTERNAL_SERVER_ERROR" + "example": "https://www.openstatus.dev/docs/reference/api-errors#internal-server-error" }, "requestId": { "type": "string", @@ -735,7 +735,7 @@ "docs": { "type": "string", "description": "A link to the documentation for the error.", - "example": "https://www.openstatus.dev/docs/api-references/errors/code/SERVICE_UNAVAILABLE" + "example": "https://www.openstatus.dev/docs/reference/api-errors#service-unavailable" }, "requestId": { "type": "string", @@ -928,7 +928,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-09-10T07:58:20.617Z", + "default": "2026-09-24T07:47:08.268Z", "description": "The date of the update in ISO8601 format" }, "message": { @@ -6584,7 +6584,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-09-10T07:58:20.617Z", + "default": "2026-09-24T07:47:08.268Z", "description": "The date of the report in ISO8601 format, defaults to now" }, "message": { @@ -7039,7 +7039,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-09-10T07:58:20.617Z", + "default": "2026-09-24T07:47:08.268Z", "description": "The date of the update in ISO8601 format" }, "message": { @@ -7340,7 +7340,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-09-10T07:58:20.617Z", + "default": "2026-09-24T07:47:08.268Z", "description": "The date of the update in ISO8601 format" }, "message": { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 73b55eb8..7fa7839c 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -125,6 +125,13 @@ const nextConfig: NextConfig = { destination: "/docs/guides/how-to-connect-openstatus-to-your-agent", permanent: true, }, + // Old per-code error pages from the Mintlify docs; the `docs` link in + // every v1 error envelope shipped before 2026-09 points here. + { + source: "/docs/api-references/errors/code/:code", + destination: "/docs/reference/api-errors", + permanent: true, + }, { source: "/legal/terms", destination: "/terms", diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index d7828cde..893bcdf6 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -245,6 +245,7 @@ export const docsNav: DocsNavSection[] = [ external: true, }, { slug: "reference/api-rate-limits", label: "API Rate Limits" }, + { slug: "reference/api-errors", label: "API Errors" }, { slug: "reference/cli-reference", label: "CLI Reference" }, { slug: "reference/mcp-server", label: "MCP Server" }, { slug: "reference/dns-monitor", label: "DNS Monitor Reference" }, diff --git a/apps/web/src/content/pages/docs/reference/api-errors.mdx b/apps/web/src/content/pages/docs/reference/api-errors.mdx new file mode 100644 index 00000000..49ab0cad --- /dev/null +++ b/apps/web/src/content/pages/docs/reference/api-errors.mdx @@ -0,0 +1,201 @@ +--- +category: Reference +title: API Errors +description: "Every error the openstatus API returns: HTTP and Connect codes, the ErrorInfo detail, stable reasons, and how to handle each." +--- + +Every error from `api.openstatus.dev` is machine-readable and uses one contract on both surfaces. The v1 REST API answers with a JSON envelope. The RPC API under `/rpc`, which the SDKs, the CLI and the Terraform provider speak, answers with a [Connect error](https://connectrpc.com/docs/protocol#error-end-stream) carrying a `google.rpc.ErrorInfo` detail. Both name the same error class, both include a request id, and both link back to the matching section of this page. + +## v1 envelope + +```http +HTTP/1.1 404 Not Found +Content-Type: application/json + +{ + "code": "NOT_FOUND", + "message": "Monitor not found", + "docs": "https://www.openstatus.dev/docs/reference/api-errors#not-found", + "requestId": "1c4b8f1e-3f4b-4f79-9f9d-2a6d7f0b6c1a" +} +``` + +| Field | Meaning | +|---|---| +| `code` | One of the codes in the [table below](#codes). Switch on this, never on `message`. | +| `message` | Human-readable, may change between releases. | +| `docs` | Link to the section of this page for `code`. | +| `requestId` | Quote it when you contact support; it is the key into our logs. | + +## Connect errors + +On `/rpc` the body is a Connect error. The HTTP status follows the Connect code, so a `not_found` is still a `404`, and the `x-request-id` response header carries the same id as the detail. + +```json +{ + "code": "not_found", + "message": "Monitor not found", + "details": [ + { + "type": "google.rpc.ErrorInfo", + "value": "", + "debug": { + "reason": "MONITOR_NOT_FOUND", + "domain": "openstatus.dev", + "metadata": { + "monitorId": "42", + "requestId": "1c4b8f1e-3f4b-4f79-9f9d-2a6d7f0b6c1a", + "docs": "https://www.openstatus.dev/docs/reference/api-errors#not-found" + } + } + } + ] +} +``` + +Every RPC error carries exactly one `google.rpc.ErrorInfo`: + +| Field | Meaning | +|---|---| +| `reason` | A stable identifier from the [reasons table](#reasons). More specific than the code; switch on it when you need to. | +| `domain` | Always `openstatus.dev`. | +| `metadata.requestId` | Same value as the `x-request-id` response header. | +| `metadata.docs` | Link to the section of this page for the code. | +| other `metadata` keys | Ids of the resources involved (`monitorId`, `pageId`, ...), plan limits (`limit`, `max`, `current`), or the field that failed. | + +Depending on the error, more standard details ride along: + +- **`google.rpc.RetryInfo`** on `resource_exhausted` and `unavailable`: the delay to wait before retrying, matching the `Retry-After` header. +- **`google.rpc.BadRequest`** on `invalid_argument` raised by the server: one `fieldViolations` entry per bad field. +- **`buf.validate.Violations`** on `invalid_argument` raised by the request schema: the [protovalidate](https://buf.build/docs/protovalidate) constraint that failed. + +Connect clients decode the details for you: + +```typescript +import { ConnectError } from "@connectrpc/connect"; +import { ErrorInfoSchema } from "@buf/googleapis_googleapis.bufbuild_es/google/rpc/error_details_pb"; + +try { + await client.getMonitor({ id: "42" }); +} catch (error) { + if (error instanceof ConnectError) { + const [info] = error.findDetails(ErrorInfoSchema); + console.error(error.code, info?.reason, info?.metadata.requestId); + } +} +``` + + + +## Codes + +| v1 `code` | HTTP | Connect `code` | Retry? | +|---|---|---|---| +| [`BAD_REQUEST`](#bad-request) | 400 | `invalid_argument` | No, fix the request | +| [`UNAUTHORIZED`](#unauthorized) | 401 | `unauthenticated` | No, fix the credential | +| [`PAYMENT_REQUIRED`](#payment-required) | 402 | `resource_exhausted` | No, upgrade the plan | +| [`FORBIDDEN`](#forbidden) | 403 | `permission_denied` | No | +| [`NOT_FOUND`](#not-found) | 404 | `not_found` | No | +| [`METHOD_NOT_ALLOWED`](#method-not-allowed) | 405 | `unimplemented` | No | +| [`CONFLICT`](#conflict) | 409 | `already_exists` | No, change the input | +| [`UNPROCESSABLE_ENTITY`](#unprocessable-entity) | 422 | `failed_precondition` | No, change the state first | +| [`TOO_MANY_REQUESTS`](#too-many-requests) | 429 | `resource_exhausted` | Yes, after `Retry-After` | +| [`INTERNAL_SERVER_ERROR`](#internal-server-error) | 500 | `internal` | Once, with backoff | +| [`SERVICE_UNAVAILABLE`](#service-unavailable) | 503 | `unavailable` | Yes, after `Retry-After` | + +### Bad request + +The request is malformed or fails validation: a missing required field, a value out of range, an unknown enum, an id that is not a number. The message names the field. On `/rpc` a `google.rpc.BadRequest` or `buf.validate.Violations` detail lists every violation. Fix the request; retrying the same payload will fail the same way. + +### Unauthorized + +No credential, or a credential that does not resolve to a workspace. Send the API key in `x-openstatus-key` or an OAuth access token as a bearer token. Reasons: `MISSING_CREDENTIALS`, `INVALID_API_KEY`. Repeated `401`s from one IP count against the [failed-authentication limit](/docs/reference/api-rate-limits). + +### Payment required + +Reserved by the v1 envelope for plan gating. The RPC API reports plan limits under [Forbidden](#forbidden) with a `PLAN_*` reason instead. + +### Forbidden + +The credential is valid but may not do this: a read-only key calling a write, a feature the workspace plan does not include (`PLAN_FEATURE_NOT_AVAILABLE`), or a quota that is used up (`PLAN_LIMIT_REACHED`, with `limit`, `max` and, when known, `current` in the metadata). Upgrade the plan or use a key with the right scope. + +### Not found + +The resource does not exist, was deleted, or belongs to another workspace. The reason names the resource type and the metadata carries the id you sent. Cross-workspace access always looks like a missing resource, never like a permission error. + +### Method not allowed + +The HTTP method is wrong for the route. Connect procedures are `POST`, and only idempotent `Get*` and `List*` procedures also accept `GET`. + +### Conflict + +The write collides with existing data: a status page slug that is already taken (`SLUG_ALREADY_EXISTS`), a subscriber that already exists. Change the input rather than retrying. + +### Unprocessable entity + +The request is well-formed but the system is not in a state that allows it, for example deleting a workspace with an active subscription. Change the state first, then retry. + +### Too many requests + +A [rate limit](/docs/reference/api-rate-limits) was exceeded. Wait for `Retry-After` seconds, then retry with backoff. On `/rpc` the `google.rpc.RetryInfo` detail carries the same delay. + +### Internal server error + +Something failed on our side. The message never includes internals; the `requestId` is the handle we use to find the cause. Retry once with backoff, and contact [ping@openstatus.dev](mailto:ping@openstatus.dev) with the request id if it persists. + +### Service unavailable + +The server is shedding load. Treat it exactly like [Too many requests](#too-many-requests): honour `Retry-After` and back off. + +## Reasons + +The `reason` values the RPC API can return. Generic reasons share their name with the v1 code and are used when nothing more specific applies. + +| Reason | Connect code | Metadata | +|---|---|---| +| `BAD_REQUEST` | `invalid_argument` | | +| `UNAUTHORIZED` | `unauthenticated` | | +| `PAYMENT_REQUIRED` | `resource_exhausted` | | +| `FORBIDDEN` | `permission_denied` | | +| `NOT_FOUND` | `not_found` | `resource` | +| `METHOD_NOT_ALLOWED` | `unimplemented` | | +| `CONFLICT` | `invalid_argument` | | +| `UNPROCESSABLE_ENTITY` | `failed_precondition` | | +| `TOO_MANY_REQUESTS` | `resource_exhausted` | plus `RetryInfo` | +| `INTERNAL_SERVER_ERROR` | `internal` | | +| `SERVICE_UNAVAILABLE` | `unavailable` | plus `RetryInfo` | +| `MISSING_CREDENTIALS` | `unauthenticated` | | +| `INVALID_API_KEY` | `unauthenticated` | | +| `VALIDATION_FAILED` | `invalid_argument` | plus `BadRequest` or `Violations` | +| `INVALID_REGION` | `invalid_argument` | `regions` | +| `INVALID_DATE_FORMAT` | `invalid_argument` | `value` | +| `INVALID_STATUS` | `invalid_argument` | `status` | +| `INVALID_CUSTOM_DOMAIN` | `invalid_argument` | `customDomain` | +| `INVALID_ICON_URL` | `invalid_argument` | | +| `INVALID_MONITOR_ID` | `invalid_argument` | `monitorId` | +| `INVALID_NOTIFICATION_DATA` | `invalid_argument` | | +| `PASSWORD_REQUIRED` | `invalid_argument` | | +| `AUTH_EMAIL_DOMAINS_REQUIRED` | `invalid_argument` | | +| `IDENTIFIER_REQUIRED` | `invalid_argument` | | +| `MONITOR_TYPE_MISMATCH` | `invalid_argument` | `monitorId`, `expectedType`, `actualType` | +| `PROVIDER_NOT_SUPPORTED` | `invalid_argument` | `provider` | +| `PLAN_LIMIT_REACHED` | `permission_denied` | `limit`, `max`, `current` | +| `PLAN_FEATURE_NOT_AVAILABLE` | `permission_denied` | `feature` | +| `MONITOR_NOT_FOUND` | `not_found` | `monitorId` | +| `RESPONSE_LOG_NOT_FOUND` | `not_found` | `monitorId`, `logId` | +| `NOTIFICATION_NOT_FOUND` | `not_found` | `notificationId` | +| `STATUS_PAGE_NOT_FOUND` | `not_found` | `pageId` | +| `STATUS_PAGE_NOT_PUBLISHED` | `not_found` | `slug` | +| `STATUS_PAGE_ACCESS_DENIED` | `permission_denied` | `slug`, `accessType` | +| `SLUG_ALREADY_EXISTS` | `already_exists` | `slug` | +| `PAGE_COMPONENT_NOT_FOUND` | `not_found` | `pageComponentId` | +| `COMPONENT_GROUP_NOT_FOUND` | `not_found` | `groupId` | +| `SUBSCRIBER_NOT_FOUND` | `not_found` | `identifier` | +| `STATUS_REPORT_NOT_FOUND` | `not_found` | `statusReportId` | +| `MAINTENANCE_NOT_FOUND` | `not_found` | `maintenanceId` | +| `PRIVATE_LOCATION_NOT_FOUND` | `not_found` | `privateLocationId` | +| `TEST_NOTIFICATION_FAILED` | `internal` | | + +Reasons are append-only: new ones may appear in a release, existing ones are never renamed. Treat an unknown reason like its Connect code. diff --git a/apps/web/src/content/pages/docs/reference/api-rate-limits.mdx b/apps/web/src/content/pages/docs/reference/api-rate-limits.mdx index 4193fa72..02473717 100644 --- a/apps/web/src/content/pages/docs/reference/api-rate-limits.mdx +++ b/apps/web/src/content/pages/docs/reference/api-rate-limits.mdx @@ -40,15 +40,22 @@ Content-Type: application/json { "code": "TOO_MANY_REQUESTS", "message": "Rate limit exceeded, retry later", - "docs": "https://www.openstatus.dev/docs/api-references/errors/code/TOO_MANY_REQUESTS", + "docs": "https://www.openstatus.dev/docs/reference/api-errors#too-many-requests", "requestId": "1c4b8f1e-3f4b-4f79-9f9d-2a6d7f0b6c1a" } ``` -On `/rpc` the body is a Connect error instead, so ConnectRPC clients such as the [Node.js SDK](/docs/sdk/nodejs/error-handling) raise a `ConnectError` with code `resource_exhausted`: +On `/rpc` the body is a Connect error instead, so ConnectRPC clients such as the [Node.js SDK](/docs/sdk/nodejs/error-handling) raise a `ConnectError` with code `resource_exhausted`. It carries a `google.rpc.ErrorInfo` with reason `TOO_MANY_REQUESTS` and a `google.rpc.RetryInfo` with the same delay as the header (see [API errors](/docs/reference/api-errors#connect-errors)): ```json -{ "code": "resource_exhausted", "message": "Rate limit exceeded, retry later" } +{ + "code": "resource_exhausted", + "message": "Rate limit exceeded, retry later", + "details": [ + { "type": "google.rpc.ErrorInfo", "value": "…", "debug": { "reason": "TOO_MANY_REQUESTS", "domain": "openstatus.dev", "metadata": { "requestId": "…", "docs": "…" } } }, + { "type": "google.rpc.RetryInfo", "value": "…", "debug": { "retryDelay": "7s" } } + ] +} ``` When a server is overloaded it may also answer `503 Service Unavailable` with a `Retry-After` header: `code: "SERVICE_UNAVAILABLE"` in the envelope, `unavailable` on `/rpc`. Treat it exactly like a `429`. diff --git a/apps/web/src/content/pages/docs/reference/overview.mdx b/apps/web/src/content/pages/docs/reference/overview.mdx index e118eb30..9ceeda3f 100644 --- a/apps/web/src/content/pages/docs/reference/overview.mdx +++ b/apps/web/src/content/pages/docs/reference/overview.mdx @@ -49,3 +49,4 @@ The HTTP API has its own dedicated documentation, generated from the OpenAPI sch - **[API reference (current)](https://api.openstatus.dev/openapi)** — the OpenAPI document, machine-readable, always up to date with the deployed server. - **[API reference v1 (deprecated)](https://api.openstatus.dev/v1)** — the legacy v1 surface; kept available for existing integrations. - **[API rate limits](/docs/reference/api-rate-limits)** — per-key and per-IP limits, the `429` response, and how to retry. +- **[API errors](/docs/reference/api-errors)** — every error code and reason, the v1 envelope, and the `ErrorInfo` detail on `/rpc`. diff --git a/apps/web/src/content/pages/docs/sdk/nodejs/error-handling.mdx b/apps/web/src/content/pages/docs/sdk/nodejs/error-handling.mdx index 4cd0a48c..c82c2f32 100644 --- a/apps/web/src/content/pages/docs/sdk/nodejs/error-handling.mdx +++ b/apps/web/src/content/pages/docs/sdk/nodejs/error-handling.mdx @@ -31,6 +31,37 @@ try { | `resource_exhausted` | Rate limited (HTTP `429`); wait for the `Retry-After` header, see [API rate limits](/docs/reference/api-rate-limits) | | `unavailable` | The server is shedding load (HTTP `503`); retry with backoff | +The full list, including the HTTP status each code maps to, is in the [API errors reference](/docs/reference/api-errors). + +## Structured Details + +Every error carries a `google.rpc.ErrorInfo` detail with a stable `reason`, the `requestId` to quote to support, and the ids of the resources involved. Switch on `reason` when the code alone is too coarse: + +```typescript +import { Code, ConnectError } from "@connectrpc/connect"; +import { ErrorInfoSchema } from "@buf/googleapis_googleapis.bufbuild_es/google/rpc/error_details_pb"; + +try { + await client.monitor.v1.MonitorService.getMonitor({ id }); +} catch (error) { + if (error instanceof ConnectError) { + const [info] = error.findDetails(ErrorInfoSchema); + if (info?.reason === "MONITOR_NOT_FOUND") { + console.warn(`monitor ${info.metadata.monitorId} is gone`); + } else if (info?.reason === "PLAN_LIMIT_REACHED") { + console.warn(`limit ${info.metadata.limit}: ${info.metadata.current}/${info.metadata.max}`); + } + console.error(`request id: ${info?.metadata.requestId}`); + } +} +``` + +`invalid_argument` errors add a `google.rpc.BadRequest` (or `buf.validate.Violations`) detail naming each bad field, and `resource_exhausted` and `unavailable` add a `google.rpc.RetryInfo` with the delay to wait. The [reasons table](/docs/reference/api-errors#reasons) lists every value and its metadata keys. + + + ## Retry Strategy ConnectRPC does not retry by default. For transient failures (`resource_exhausted`, `unavailable`, `deadline_exceeded`), implement your own retry logic. The response headers, including `Retry-After`, are available on `error.metadata`; keep the delay at or above that value when it is present: diff --git a/packages/error/src/utils.ts b/packages/error/src/utils.ts index 646ac8ce..157dac95 100644 --- a/packages/error/src/utils.ts +++ b/packages/error/src/utils.ts @@ -77,3 +77,8 @@ export function redactError(err: TError) { if (!(err instanceof Error)) return err; console.error(`Type of Error: ${err.constructor}`); } + +/** Anchor is the heading slug on the API errors page: lowercase, `_` becomes `-`. */ +export function errorDocsUrl(code: ErrorCode): string { + return `https://www.openstatus.dev/docs/reference/api-errors#${code.toLowerCase().replace(/_/g, "-")}`; +} diff --git a/packages/proto/gen/ts/google/rpc/error_details_pb.ts b/packages/proto/gen/ts/google/rpc/error_details_pb.ts new file mode 100644 index 00000000..32ce87ca --- /dev/null +++ b/packages/proto/gen/ts/google/rpc/error_details_pb.ts @@ -0,0 +1,662 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.15.0 with parameter "target=ts,import_extension=.ts" +// @generated from file google/rpc/error_details.proto (package google.rpc, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Duration } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_duration } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file google/rpc/error_details.proto. + */ +export const file_google_rpc_error_details: GenFile = /*@__PURE__*/ + fileDesc("Ch5nb29nbGUvcnBjL2Vycm9yX2RldGFpbHMucHJvdG8SCmdvb2dsZS5ycGMikwEKCUVycm9ySW5mbxIOCgZyZWFzb24YASABKAkSDgoGZG9tYWluGAIgASgJEjUKCG1ldGFkYXRhGAMgAygLMiMuZ29vZ2xlLnJwYy5FcnJvckluZm8uTWV0YWRhdGFFbnRyeRovCg1NZXRhZGF0YUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiOwoJUmV0cnlJbmZvEi4KC3JldHJ5X2RlbGF5GAEgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIjIKCURlYnVnSW5mbxIVCg1zdGFja19lbnRyaWVzGAEgAygJEg4KBmRldGFpbBgCIAEoCSKPAwoMUXVvdGFGYWlsdXJlEjYKCnZpb2xhdGlvbnMYASADKAsyIi5nb29nbGUucnBjLlF1b3RhRmFpbHVyZS5WaW9sYXRpb24axgIKCVZpb2xhdGlvbhIPCgdzdWJqZWN0GAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEhMKC2FwaV9zZXJ2aWNlGAMgASgJEhQKDHF1b3RhX21ldHJpYxgEIAEoCRIQCghxdW90YV9pZBgFIAEoCRJRChBxdW90YV9kaW1lbnNpb25zGAYgAygLMjcuZ29vZ2xlLnJwYy5RdW90YUZhaWx1cmUuVmlvbGF0aW9uLlF1b3RhRGltZW5zaW9uc0VudHJ5EhMKC3F1b3RhX3ZhbHVlGAcgASgDEh8KEmZ1dHVyZV9xdW90YV92YWx1ZRgIIAEoA0gAiAEBGjYKFFF1b3RhRGltZW5zaW9uc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAFCFQoTX2Z1dHVyZV9xdW90YV92YWx1ZSKVAQoTUHJlY29uZGl0aW9uRmFpbHVyZRI9Cgp2aW9sYXRpb25zGAEgAygLMikuZ29vZ2xlLnJwYy5QcmVjb25kaXRpb25GYWlsdXJlLlZpb2xhdGlvbho/CglWaW9sYXRpb24SDAoEdHlwZRgBIAEoCRIPCgdzdWJqZWN0GAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJIswBCgpCYWRSZXF1ZXN0Ej8KEGZpZWxkX3Zpb2xhdGlvbnMYASADKAsyJS5nb29nbGUucnBjLkJhZFJlcXVlc3QuRmllbGRWaW9sYXRpb24afQoORmllbGRWaW9sYXRpb24SDQoFZmllbGQYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkSDgoGcmVhc29uGAMgASgJEjcKEWxvY2FsaXplZF9tZXNzYWdlGAQgASgLMhwuZ29vZ2xlLnJwYy5Mb2NhbGl6ZWRNZXNzYWdlIjcKC1JlcXVlc3RJbmZvEhIKCnJlcXVlc3RfaWQYASABKAkSFAoMc2VydmluZ19kYXRhGAIgASgJImAKDFJlc291cmNlSW5mbxIVCg1yZXNvdXJjZV90eXBlGAEgASgJEhUKDXJlc291cmNlX25hbWUYAiABKAkSDQoFb3duZXIYAyABKAkSEwoLZGVzY3JpcHRpb24YBCABKAkiVgoESGVscBIkCgVsaW5rcxgBIAMoCzIVLmdvb2dsZS5ycGMuSGVscC5MaW5rGigKBExpbmsSEwoLZGVzY3JpcHRpb24YASABKAkSCwoDdXJsGAIgASgJIjMKEExvY2FsaXplZE1lc3NhZ2USDgoGbG9jYWxlGAEgASgJEg8KB21lc3NhZ2UYAiABKAlCbAoOY29tLmdvb2dsZS5ycGNCEUVycm9yRGV0YWlsc1Byb3RvUAFaP2dvb2dsZS5nb2xhbmcub3JnL2dlbnByb3RvL2dvb2dsZWFwaXMvcnBjL2VycmRldGFpbHM7ZXJyZGV0YWlsc6ICA1JQQ2IGcHJvdG8z", [file_google_protobuf_duration]); + +/** + * Describes the cause of the error with structured details. + * + * Example of an error when contacting the "pubsub.googleapis.com" API when it + * is not enabled: + * + * { "reason": "API_DISABLED" + * "domain": "googleapis.com" + * "metadata": { + * "resource": "projects/123", + * "service": "pubsub.googleapis.com" + * } + * } + * + * This response indicates that the pubsub.googleapis.com API is not enabled. + * + * Example of an error that is returned when attempting to create a Spanner + * instance in a region that is out of stock: + * + * { "reason": "STOCKOUT" + * "domain": "spanner.googleapis.com", + * "metadata": { + * "availableRegions": "us-central1,us-east2" + * } + * } + * + * @generated from message google.rpc.ErrorInfo + */ +export type ErrorInfo = Message<"google.rpc.ErrorInfo"> & { + /** + * The reason of the error. This is a constant value that identifies the + * proximate cause of the error. Error reasons are unique within a particular + * domain of errors. This should be at most 63 characters and match a + * regular expression of `[A-Z][A-Z0-9_]+[A-Z0-9]`, which represents + * UPPER_SNAKE_CASE. + * + * @generated from field: string reason = 1; + */ + reason: string; + + /** + * The logical grouping to which the "reason" belongs. The error domain + * is typically the registered service name of the tool or product that + * generates the error. Example: "pubsub.googleapis.com". If the error is + * generated by some common infrastructure, the error domain must be a + * globally unique value that identifies the infrastructure. For Google API + * infrastructure, the error domain is "googleapis.com". + * + * @generated from field: string domain = 2; + */ + domain: string; + + /** + * Additional structured details about this error. + * + * Keys must match a regular expression of `[a-z][a-zA-Z0-9-_]+` but should + * ideally be lowerCamelCase. Also, they must be limited to 64 characters in + * length. When identifying the current value of an exceeded limit, the units + * should be contained in the key, not the value. For example, rather than + * `{"instanceLimit": "100/request"}`, should be returned as, + * `{"instanceLimitPerRequest": "100"}`, if the client exceeds the number of + * instances that can be created in a single (batch) request. + * + * @generated from field: map metadata = 3; + */ + metadata: { [key: string]: string }; +}; + +/** + * Describes the message google.rpc.ErrorInfo. + * Use `create(ErrorInfoSchema)` to create a new message. + */ +export const ErrorInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 0); + +/** + * Describes when the clients can retry a failed request. Clients could ignore + * the recommendation here or retry when this information is missing from error + * responses. + * + * It's always recommended that clients should use exponential backoff when + * retrying. + * + * Clients should wait until `retry_delay` amount of time has passed since + * receiving the error response before retrying. If retrying requests also + * fail, clients should use an exponential backoff scheme to gradually increase + * the delay between retries based on `retry_delay`, until either a maximum + * number of retries have been reached or a maximum retry delay cap has been + * reached. + * + * @generated from message google.rpc.RetryInfo + */ +export type RetryInfo = Message<"google.rpc.RetryInfo"> & { + /** + * Clients should wait at least this long between retrying the same request. + * + * @generated from field: google.protobuf.Duration retry_delay = 1; + */ + retryDelay?: Duration | undefined; +}; + +/** + * Describes the message google.rpc.RetryInfo. + * Use `create(RetryInfoSchema)` to create a new message. + */ +export const RetryInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 1); + +/** + * Describes additional debugging info. + * + * @generated from message google.rpc.DebugInfo + */ +export type DebugInfo = Message<"google.rpc.DebugInfo"> & { + /** + * The stack trace entries indicating where the error occurred. + * + * @generated from field: repeated string stack_entries = 1; + */ + stackEntries: string[]; + + /** + * Additional debugging information provided by the server. + * + * @generated from field: string detail = 2; + */ + detail: string; +}; + +/** + * Describes the message google.rpc.DebugInfo. + * Use `create(DebugInfoSchema)` to create a new message. + */ +export const DebugInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 2); + +/** + * Describes how a quota check failed. + * + * For example if a daily limit was exceeded for the calling project, + * a service could respond with a QuotaFailure detail containing the project + * id and the description of the quota limit that was exceeded. If the + * calling project hasn't enabled the service in the developer console, then + * a service could respond with the project id and set `service_disabled` + * to true. + * + * Also see RetryInfo and Help types for other details about handling a + * quota failure. + * + * @generated from message google.rpc.QuotaFailure + */ +export type QuotaFailure = Message<"google.rpc.QuotaFailure"> & { + /** + * Describes all quota violations. + * + * @generated from field: repeated google.rpc.QuotaFailure.Violation violations = 1; + */ + violations: QuotaFailure_Violation[]; +}; + +/** + * Describes the message google.rpc.QuotaFailure. + * Use `create(QuotaFailureSchema)` to create a new message. + */ +export const QuotaFailureSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 3); + +/** + * A message type used to describe a single quota violation. For example, a + * daily quota or a custom quota that was exceeded. + * + * @generated from message google.rpc.QuotaFailure.Violation + */ +export type QuotaFailure_Violation = Message<"google.rpc.QuotaFailure.Violation"> & { + /** + * The subject on which the quota check failed. + * For example, "clientip:" or "project:". + * + * @generated from field: string subject = 1; + */ + subject: string; + + /** + * A description of how the quota check failed. Clients can use this + * description to find more about the quota configuration in the service's + * public documentation, or find the relevant quota limit to adjust through + * developer console. + * + * For example: "Service disabled" or "Daily Limit for read operations + * exceeded". + * + * @generated from field: string description = 2; + */ + description: string; + + /** + * The API Service from which the `QuotaFailure.Violation` orginates. In + * some cases, Quota issues originate from an API Service other than the one + * that was called. In other words, a dependency of the called API Service + * could be the cause of the `QuotaFailure`, and this field would have the + * dependency API service name. + * + * For example, if the called API is Kubernetes Engine API + * (container.googleapis.com), and a quota violation occurs in the + * Kubernetes Engine API itself, this field would be + * "container.googleapis.com". On the other hand, if the quota violation + * occurs when the Kubernetes Engine API creates VMs in the Compute Engine + * API (compute.googleapis.com), this field would be + * "compute.googleapis.com". + * + * @generated from field: string api_service = 3; + */ + apiService: string; + + /** + * The metric of the violated quota. A quota metric is a named counter to + * measure usage, such as API requests or CPUs. When an activity occurs in a + * service, such as Virtual Machine allocation, one or more quota metrics + * may be affected. + * + * For example, "compute.googleapis.com/cpus_per_vm_family", + * "storage.googleapis.com/internet_egress_bandwidth". + * + * @generated from field: string quota_metric = 4; + */ + quotaMetric: string; + + /** + * The id of the violated quota. Also know as "limit name", this is the + * unique identifier of a quota in the context of an API service. + * + * For example, "CPUS-PER-VM-FAMILY-per-project-region". + * + * @generated from field: string quota_id = 5; + */ + quotaId: string; + + /** + * The dimensions of the violated quota. Every non-global quota is enforced + * on a set of dimensions. While quota metric defines what to count, the + * dimensions specify for what aspects the counter should be increased. + * + * For example, the quota "CPUs per region per VM family" enforces a limit + * on the metric "compute.googleapis.com/cpus_per_vm_family" on dimensions + * "region" and "vm_family". And if the violation occurred in region + * "us-central1" and for VM family "n1", the quota_dimensions would be, + * + * { + * "region": "us-central1", + * "vm_family": "n1", + * } + * + * When a quota is enforced globally, the quota_dimensions would always be + * empty. + * + * @generated from field: map quota_dimensions = 6; + */ + quotaDimensions: { [key: string]: string }; + + /** + * The enforced quota value at the time of the `QuotaFailure`. + * + * For example, if the enforced quota value at the time of the + * `QuotaFailure` on the number of CPUs is "10", then the value of this + * field would reflect this quantity. + * + * @generated from field: int64 quota_value = 7; + */ + quotaValue: bigint; + + /** + * The new quota value being rolled out at the time of the violation. At the + * completion of the rollout, this value will be enforced in place of + * quota_value. If no rollout is in progress at the time of the violation, + * this field is not set. + * + * For example, if at the time of the violation a rollout is in progress + * changing the number of CPUs quota from 10 to 20, 20 would be the value of + * this field. + * + * @generated from field: optional int64 future_quota_value = 8; + */ + futureQuotaValue?: bigint | undefined; +}; + +/** + * Describes the message google.rpc.QuotaFailure.Violation. + * Use `create(QuotaFailure_ViolationSchema)` to create a new message. + */ +export const QuotaFailure_ViolationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 3, 0); + +/** + * Describes what preconditions have failed. + * + * For example, if an RPC failed because it required the Terms of Service to be + * acknowledged, it could list the terms of service violation in the + * PreconditionFailure message. + * + * @generated from message google.rpc.PreconditionFailure + */ +export type PreconditionFailure = Message<"google.rpc.PreconditionFailure"> & { + /** + * Describes all precondition violations. + * + * @generated from field: repeated google.rpc.PreconditionFailure.Violation violations = 1; + */ + violations: PreconditionFailure_Violation[]; +}; + +/** + * Describes the message google.rpc.PreconditionFailure. + * Use `create(PreconditionFailureSchema)` to create a new message. + */ +export const PreconditionFailureSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 4); + +/** + * A message type used to describe a single precondition failure. + * + * @generated from message google.rpc.PreconditionFailure.Violation + */ +export type PreconditionFailure_Violation = Message<"google.rpc.PreconditionFailure.Violation"> & { + /** + * The type of PreconditionFailure. We recommend using a service-specific + * enum type to define the supported precondition violation subjects. For + * example, "TOS" for "Terms of Service violation". + * + * @generated from field: string type = 1; + */ + type: string; + + /** + * The subject, relative to the type, that failed. + * For example, "google.com/cloud" relative to the "TOS" type would indicate + * which terms of service is being referenced. + * + * @generated from field: string subject = 2; + */ + subject: string; + + /** + * A description of how the precondition failed. Developers can use this + * description to understand how to fix the failure. + * + * For example: "Terms of service not accepted". + * + * @generated from field: string description = 3; + */ + description: string; +}; + +/** + * Describes the message google.rpc.PreconditionFailure.Violation. + * Use `create(PreconditionFailure_ViolationSchema)` to create a new message. + */ +export const PreconditionFailure_ViolationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 4, 0); + +/** + * Describes violations in a client request. This error type focuses on the + * syntactic aspects of the request. + * + * @generated from message google.rpc.BadRequest + */ +export type BadRequest = Message<"google.rpc.BadRequest"> & { + /** + * Describes all violations in a client request. + * + * @generated from field: repeated google.rpc.BadRequest.FieldViolation field_violations = 1; + */ + fieldViolations: BadRequest_FieldViolation[]; +}; + +/** + * Describes the message google.rpc.BadRequest. + * Use `create(BadRequestSchema)` to create a new message. + */ +export const BadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 5); + +/** + * A message type used to describe a single bad request field. + * + * @generated from message google.rpc.BadRequest.FieldViolation + */ +export type BadRequest_FieldViolation = Message<"google.rpc.BadRequest.FieldViolation"> & { + /** + * A path that leads to a field in the request body. The value will be a + * sequence of dot-separated identifiers that identify a protocol buffer + * field. + * + * Consider the following: + * + * message CreateContactRequest { + * message EmailAddress { + * enum Type { + * TYPE_UNSPECIFIED = 0; + * HOME = 1; + * WORK = 2; + * } + * + * optional string email = 1; + * repeated EmailType type = 2; + * } + * + * string full_name = 1; + * repeated EmailAddress email_addresses = 2; + * } + * + * In this example, in proto `field` could take one of the following values: + * + * * `full_name` for a violation in the `full_name` value + * * `email_addresses[0].email` for a violation in the `email` field of the + * first `email_addresses` message + * * `email_addresses[2].type[1]` for a violation in the second `type` + * value in the third `email_addresses` message. + * + * In JSON, the same values are represented as: + * + * * `fullName` for a violation in the `fullName` value + * * `emailAddresses[0].email` for a violation in the `email` field of the + * first `emailAddresses` message + * * `emailAddresses[2].type[1]` for a violation in the second `type` + * value in the third `emailAddresses` message. + * + * @generated from field: string field = 1; + */ + field: string; + + /** + * A description of why the request element is bad. + * + * @generated from field: string description = 2; + */ + description: string; + + /** + * The reason of the field-level error. This is a constant value that + * identifies the proximate cause of the field-level error. It should + * uniquely identify the type of the FieldViolation within the scope of the + * google.rpc.ErrorInfo.domain. This should be at most 63 + * characters and match a regular expression of `[A-Z][A-Z0-9_]+[A-Z0-9]`, + * which represents UPPER_SNAKE_CASE. + * + * @generated from field: string reason = 3; + */ + reason: string; + + /** + * Provides a localized error message for field-level errors that is safe to + * return to the API consumer. + * + * @generated from field: google.rpc.LocalizedMessage localized_message = 4; + */ + localizedMessage?: LocalizedMessage | undefined; +}; + +/** + * Describes the message google.rpc.BadRequest.FieldViolation. + * Use `create(BadRequest_FieldViolationSchema)` to create a new message. + */ +export const BadRequest_FieldViolationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 5, 0); + +/** + * Contains metadata about the request that clients can attach when filing a bug + * or providing other forms of feedback. + * + * @generated from message google.rpc.RequestInfo + */ +export type RequestInfo = Message<"google.rpc.RequestInfo"> & { + /** + * An opaque string that should only be interpreted by the service generating + * it. For example, it can be used to identify requests in the service's logs. + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * Any data that was used to serve this request. For example, an encrypted + * stack trace that can be sent back to the service provider for debugging. + * + * @generated from field: string serving_data = 2; + */ + servingData: string; +}; + +/** + * Describes the message google.rpc.RequestInfo. + * Use `create(RequestInfoSchema)` to create a new message. + */ +export const RequestInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 6); + +/** + * Describes the resource that is being accessed. + * + * @generated from message google.rpc.ResourceInfo + */ +export type ResourceInfo = Message<"google.rpc.ResourceInfo"> & { + /** + * A name for the type of resource being accessed, e.g. "sql table", + * "cloud storage bucket", "file", "Google calendar"; or the type URL + * of the resource: e.g. "type.googleapis.com/google.pubsub.v1.Topic". + * + * @generated from field: string resource_type = 1; + */ + resourceType: string; + + /** + * The name of the resource being accessed. For example, a shared calendar + * name: "example.com_4fghdhgsrgh@group.calendar.google.com", if the current + * error is + * [google.rpc.Code.PERMISSION_DENIED][google.rpc.Code.PERMISSION_DENIED]. + * + * @generated from field: string resource_name = 2; + */ + resourceName: string; + + /** + * The owner of the resource (optional). + * For example, "user:" or "project:". + * + * @generated from field: string owner = 3; + */ + owner: string; + + /** + * Describes what error is encountered when accessing this resource. + * For example, updating a cloud project may require the `writer` permission + * on the developer console project. + * + * @generated from field: string description = 4; + */ + description: string; +}; + +/** + * Describes the message google.rpc.ResourceInfo. + * Use `create(ResourceInfoSchema)` to create a new message. + */ +export const ResourceInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 7); + +/** + * Provides links to documentation or for performing an out of band action. + * + * For example, if a quota check failed with an error indicating the calling + * project hasn't enabled the accessed service, this can contain a URL pointing + * directly to the right place in the developer console to flip the bit. + * + * @generated from message google.rpc.Help + */ +export type Help = Message<"google.rpc.Help"> & { + /** + * URL(s) pointing to additional information on handling the current error. + * + * @generated from field: repeated google.rpc.Help.Link links = 1; + */ + links: Help_Link[]; +}; + +/** + * Describes the message google.rpc.Help. + * Use `create(HelpSchema)` to create a new message. + */ +export const HelpSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 8); + +/** + * Describes a URL link. + * + * @generated from message google.rpc.Help.Link + */ +export type Help_Link = Message<"google.rpc.Help.Link"> & { + /** + * Describes what the link offers. + * + * @generated from field: string description = 1; + */ + description: string; + + /** + * The URL of the link. + * + * @generated from field: string url = 2; + */ + url: string; +}; + +/** + * Describes the message google.rpc.Help.Link. + * Use `create(Help_LinkSchema)` to create a new message. + */ +export const Help_LinkSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 8, 0); + +/** + * Provides a localized error message that is safe to return to the user + * which can be attached to an RPC error. + * + * @generated from message google.rpc.LocalizedMessage + */ +export type LocalizedMessage = Message<"google.rpc.LocalizedMessage"> & { + /** + * The locale used following the specification defined at + * https://www.rfc-editor.org/rfc/bcp/bcp47.txt. + * Examples are: "en-US", "fr-CH", "es-MX" + * + * @generated from field: string locale = 1; + */ + locale: string; + + /** + * The localized error message in the above locale. + * + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message google.rpc.LocalizedMessage. + * Use `create(LocalizedMessageSchema)` to create a new message. + */ +export const LocalizedMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_error_details, 9); + diff --git a/packages/proto/gen/ts/google/rpc/index.ts b/packages/proto/gen/ts/google/rpc/index.ts new file mode 100644 index 00000000..f45dad55 --- /dev/null +++ b/packages/proto/gen/ts/google/rpc/index.ts @@ -0,0 +1,2 @@ +// google.rpc error detail exports +export * from "./error_details_pb.js"; diff --git a/packages/proto/package.json b/packages/proto/package.json index 12ce39dd..3d46f34c 100644 --- a/packages/proto/package.json +++ b/packages/proto/package.json @@ -36,12 +36,16 @@ "./private_location/v1": { "import": "./gen/ts/openstatus/private_location/v1/index.ts", "types": "./gen/ts/openstatus/private_location/v1/index.ts" + }, + "./google/rpc": { + "import": "./gen/ts/google/rpc/index.ts", + "types": "./gen/ts/google/rpc/index.ts" } }, "scripts": { "check": "deno check --sloppy-imports .", "buf:openapi": "buf generate --path api/openstatus --template buf.gen.openapi.yaml && deno run --allow-read=gen/openapi.yaml --allow-write=gen/openapi.yaml,../../apps/server/static/openapi.yaml scripts/clean-openapi.ts", - "buf:ts": "buf generate --path api/openstatus --template buf.gen.ts.yaml", + "buf:ts": "buf generate --path api/openstatus --template buf.gen.ts.yaml && buf generate buf.build/googleapis/googleapis --path google/rpc/error_details.proto --template buf.gen.ts.yaml", "buf:go": "buf generate --path internal/private_location --template buf.gen.go.yaml", "buf:lint": "buf lint", "buf:lint:api": "buf lint api", diff --git a/packages/services/src/errors.ts b/packages/services/src/errors.ts index e50454d8..d6e574fc 100644 --- a/packages/services/src/errors.ts +++ b/packages/services/src/errors.ts @@ -57,7 +57,7 @@ export class ValidationError extends ServiceError { export class LimitExceededError extends ServiceError { constructor( - limit: string, + public limit: string, public max: number, /** Actual usage when the caller counted it — surfaced in client error metadata. */ public current?: number, -- 2.51.2