diff --git a/apps/api/src/routes/__tests__/query-engine-batch.test.ts b/apps/api/src/routes/__tests__/query-engine-batch.test.ts index c32b291ba..38d1475a4 100644 --- a/apps/api/src/routes/__tests__/query-engine-batch.test.ts +++ b/apps/api/src/routes/__tests__/query-engine-batch.test.ts @@ -1,12 +1,9 @@ import { assert, describe, it } from "@effect/vitest" import type { QueryEngineResult } from "@maple/query-engine" -import { Effect, Schema } from "effect" +import { Effect } from "effect" +import { QueryEngineExecutionError } from "@maple/domain/http" import { runQueryEngineBatch } from "@/routes/query-engine-batch" -class StubError extends Schema.TaggedError()("@maple/http/errors/QueryEngineExecutionError", { - message: Schema.String, -}) {} - const countResult = (total: number): QueryEngineResult => ({ kind: "count", source: "logs", @@ -38,7 +35,7 @@ describe("runQueryEngineBatch", () => { requests: [1, 2, 3], execute: (n: number) => n === 2 - ? Effect.fail(new StubError({ message: "boom" })) + ? Effect.fail(new QueryEngineExecutionError({ message: "boom" })) : Effect.succeed(countResult(n)), }) @@ -48,9 +45,10 @@ describe("runQueryEngineBatch", () => { ) const failed = outcomes[1] assert.ok(failed !== undefined && failed.outcome === "failure") - // The original tag rides along so the client keeps its specific copy. + // The complete public body rides alongside the successful siblings. assert.strictEqual(failed.error._tag, "@maple/http/errors/QueryEngineExecutionError") - assert.strictEqual(failed.error.message, "boom") + assert.strictEqual(failed.error.title, "Query failed") + assert.strictEqual(failed.error.message, "The aggregation query could not be completed.") }), ) diff --git a/apps/api/src/routes/query-engine-batch.ts b/apps/api/src/routes/query-engine-batch.ts index 6508ac4ba..8d71a00ee 100644 --- a/apps/api/src/routes/query-engine-batch.ts +++ b/apps/api/src/routes/query-engine-batch.ts @@ -1,5 +1,6 @@ import type { QueryEngineBatchOutcome, QueryEngineResult } from "@maple/query-engine" import { Clock, Duration, Effect } from "effect" +import { QueryEngineTimeoutError, type SelfDescribingHttpError } from "@maple/domain/http" /** * Fan-out for `POST /api/query-engine/execute-batch`. @@ -29,10 +30,7 @@ export const QE_BATCH_DEADLINE_MS = 25_000 const timedOut = (): QueryEngineBatchOutcome => ({ outcome: "failure", - error: { - _tag: "@maple/http/errors/QueryEngineTimeoutError", - message: "Query exceeded the batch deadline.", - }, + error: new QueryEngineTimeoutError({ message: "Query exceeded the batch deadline." }).error, }) /** @@ -41,10 +39,7 @@ const timedOut = (): QueryEngineBatchOutcome => ({ * as `failure` outcomes rather than failing the effect: one bad widget must not * take down the others sharing its request. */ -export const runQueryEngineBatch = < - Request, - Error extends { readonly _tag: string; readonly message: string }, ->(options: { +export const runQueryEngineBatch = (options: { readonly requests: ReadonlyArray readonly execute: (request: Request) => Effect.Effect readonly deadlineMs?: number @@ -72,7 +67,7 @@ export const runQueryEngineBatch = < Effect.catch((error) => Effect.succeed({ outcome: "failure", - error: { _tag: error._tag, message: error.message }, + error: error.error, } satisfies QueryEngineBatchOutcome), ), ) diff --git a/apps/api/src/routes/v1/chat.http.test.ts b/apps/api/src/routes/v1/chat.http.test.ts index 77cf27dcb..171ac5d4f 100644 --- a/apps/api/src/routes/v1/chat.http.test.ts +++ b/apps/api/src/routes/v1/chat.http.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest" -import { ChatApiGroup, CurrentTenant } from "@maple/domain/http" +import { ChatApiGroup, CurrentTenant, V1SchemaErrors, V1UnexpectedErrors } from "@maple/domain/http" import { WorkerEnvironment } from "@maple/effect-cloudflare" import { Context, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" @@ -7,8 +7,12 @@ import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" import { McpToolExecutor, type McpToolExecutorShape } from "@/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" import { HttpChatLive } from "./chat.http" +import { V1ErrorBoundaryLive } from "./error-boundary" -class ChatOnlyApi extends HttpApi.make("MapleApi").add(ChatApiGroup) {} +class ChatOnlyApi extends HttpApi.make("MapleApi") + .add(ChatApiGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) {} const TENANT = new CurrentTenant.TenantSchema({ orgId: "org_chat_approval" as CurrentTenant.TenantSchema["orgId"], @@ -27,6 +31,7 @@ const AuthorizationStubLayer = Layer.succeed( const makeHarness = (executor: McpToolExecutorShape) => { const routes = HttpApiBuilder.layer(ChatOnlyApi).pipe( Layer.provide(HttpChatLive), + Layer.provide(V1ErrorBoundaryLive), Layer.provideMerge(AuthorizationStubLayer), Layer.provideMerge(Layer.succeed(McpToolExecutor, executor)), // No session identifiers are sent in these requests, so the route never diff --git a/apps/api/src/routes/v1/dashboards.http.test.ts b/apps/api/src/routes/v1/dashboards.http.test.ts index 359195a93..2dd8c3a1c 100644 --- a/apps/api/src/routes/v1/dashboards.http.test.ts +++ b/apps/api/src/routes/v1/dashboards.http.test.ts @@ -2,11 +2,12 @@ import { afterEach, describe, expect, it } from "@effect/vitest" import { ConfigProvider, Context, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" -import { CurrentTenant, DashboardsApiGroup } from "@maple/domain/http" +import { CurrentTenant, DashboardsApiGroup, V1SchemaErrors, V1UnexpectedErrors } from "@maple/domain/http" import { Env } from "@/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" -import { HttpDashboardSchemaErrorsLive, HttpDashboardsLive } from "./dashboards.http" +import { HttpDashboardsLive } from "./dashboards.http" +import { V1ErrorBoundaryLive } from "./error-boundary" const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) @@ -17,7 +18,10 @@ afterEach(() => cleanupTestDbs(createdDbs)) * layer on `(apiId, groupIdentifier)`, so this is what lets the real * `HttpDashboardsLive` satisfy it. */ -class DashboardsOnlyApi extends HttpApi.make("MapleApi").add(DashboardsApiGroup) {} +class DashboardsOnlyApi extends HttpApi.make("MapleApi") + .add(DashboardsApiGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) {} const TENANT = new CurrentTenant.TenantSchema({ orgId: "org_dashboards_schema_errors" as CurrentTenant.TenantSchema["orgId"], @@ -58,7 +62,7 @@ const makeHarness = () => { const routes = HttpApiBuilder.layer(DashboardsOnlyApi).pipe( Layer.provide(HttpDashboardsLive), - Layer.provide(HttpDashboardSchemaErrorsLive), + Layer.provide(V1ErrorBoundaryLive), Layer.provideMerge(AuthorizationStubLayer), Layer.provideMerge(servicesLive), ) @@ -113,7 +117,7 @@ describe("v1 dashboards request-decode failures", () => { expect(response.status).toBe(400) expect(response.body).not.toBeNull() - expect(response.body._tag).toBe("@maple/http/errors/DashboardValidationError") + expect(response.body._tag).toBe("@maple/http/v1/V1RequestValidationError") expect(response.body.message).toContain("payload is invalid") const details: string[] = response.body.details diff --git a/apps/api/src/routes/v1/dashboards.http.ts b/apps/api/src/routes/v1/dashboards.http.ts index aaeb6a08d..647e49078 100644 --- a/apps/api/src/routes/v1/dashboards.http.ts +++ b/apps/api/src/routes/v1/dashboards.http.ts @@ -1,7 +1,6 @@ -import { HttpApiBuilder, HttpApiMiddleware } from "effect/unstable/httpapi" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant, - DashboardSchemaErrors, DashboardTemplateMetadata, DashboardTemplateNotFoundError, DashboardTemplatesListResponse, @@ -11,31 +10,11 @@ import { PortableDashboardDocument, } from "@maple/domain/http" import { Effect } from "effect" -import { describeSchemaIssue, summarizeSchemaError } from "@/routes/schema-error-detail" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { getTemplateById, listTemplateMetadata } from "@/dashboard-templates" import type { TemplateParameterValues } from "@/dashboard-templates" import { convertPersesDashboardToPortable } from "@/services/dashboards/perses-dashboard-import" -/** - * Renders a dashboard request-decode failure as a `DashboardValidationError` - * whose `details` name the widget and field at fault, instead of the runtime's - * default empty 400 (see `./schema-error-detail`). - */ -export const HttpDashboardSchemaErrorsLive = HttpApiMiddleware.layerSchemaErrorTransform( - DashboardSchemaErrors, - (schemaError) => - Effect.suspend(() => { - const details = describeSchemaIssue(schemaError.cause.issue) - return Effect.fail( - new DashboardValidationError({ - message: summarizeSchemaError(schemaError.kind, details), - details: details.map(({ line }) => line), - }), - ) - }), -) - export const HttpDashboardsLive = HttpApiBuilder.group(MapleApi, "dashboards", (handlers) => Effect.gen(function* () { const persistence = yield* DashboardPersistenceService diff --git a/apps/api/src/routes/v1/error-boundary.test.ts b/apps/api/src/routes/v1/error-boundary.test.ts new file mode 100644 index 000000000..43761ade1 --- /dev/null +++ b/apps/api/src/routes/v1/error-boundary.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@effect/vitest" +import { Context, Effect, Layer, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { V1SchemaErrors, V1UnexpectedErrors } from "@maple/domain/http" +import { V1ErrorBoundaryLive } from "./error-boundary" + +const BoundaryGroup = HttpApiGroup.make("boundary") + .add( + HttpApiEndpoint.post("validate", "/validate", { + payload: Schema.Struct({ name: Schema.String.check(Schema.isMinLength(2)) }), + success: Schema.String, + }), + ) + .add(HttpApiEndpoint.get("invalidResponse", "/invalid-response", { success: Schema.String })) + .add(HttpApiEndpoint.get("defect", "/defect", { success: Schema.String })) + +class BoundaryApi extends HttpApi.make("BoundaryApi") + .add(BoundaryGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) {} + +const BoundaryHandlersLive = HttpApiBuilder.group(BoundaryApi, "boundary", (handlers) => + Effect.succeed( + handlers + .handle("validate", ({ payload }) => Effect.succeed(payload.name)) + .handle("invalidResponse", () => Effect.succeed(42 as never)) + .handle("defect", () => Effect.die(new Error("database password must not cross the wire"))), + ), +) + +const makeHarness = () => { + const routes = HttpApiBuilder.layer(BoundaryApi).pipe( + Layer.provide(BoundaryHandlersLive), + Layer.provide(V1ErrorBoundaryLive), + ) + const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const request = async (method: string, path: string, body?: unknown) => { + const response = await handler( + new Request(`http://maple.test${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + Context.empty() as never, + ) + return { status: response.status, body: await response.json() } + } + return { request, dispose } +} + +describe("v1 HTTP error boundary", () => { + it("returns a structured, path-anchored 400 for every request decode failure", async () => { + const harness = makeHarness() + try { + const response = await harness.request("POST", "/validate", { name: "" }) + expect(response.status).toBe(400) + expect(response.body).toMatchObject({ + _tag: "@maple/http/v1/V1RequestValidationError", + param: "name", + details: [expect.stringContaining("name")], + }) + } finally { + await harness.dispose() + } + }) + + it("logs defects and returns a sanitized 500", async () => { + const harness = makeHarness() + try { + const response = await harness.request("GET", "/defect") + expect(response.status).toBe(500) + expect(response.body).toEqual({ + _tag: "@maple/http/v1/V1UnexpectedError", + message: "An unexpected error occurred on our end.", + }) + expect(JSON.stringify(response.body)).not.toContain("database password") + } finally { + await harness.dispose() + } + }) + + it("treats an invalid handler response as a sanitized 500, not a caller 400", async () => { + const harness = makeHarness() + try { + const response = await harness.request("GET", "/invalid-response") + expect(response.status).toBe(500) + expect(response.body).toEqual({ + _tag: "@maple/http/v1/V1UnexpectedError", + message: "An unexpected error occurred on our end.", + }) + expect(JSON.stringify(response.body)).not.toContain("42") + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/v1/error-boundary.ts b/apps/api/src/routes/v1/error-boundary.ts new file mode 100644 index 000000000..1823ce1e5 --- /dev/null +++ b/apps/api/src/routes/v1/error-boundary.ts @@ -0,0 +1,110 @@ +import { Effect, Layer, Schema } from "effect" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { + V1RequestValidationError, + V1SchemaErrors, + V1UnexpectedError, + V1UnexpectedErrors, +} from "@maple/domain/http" +import { describeSchemaIssue, summarizeSchemaError } from "@/routes/schema-error-detail" + +class V1RouteExecutionDefect extends Schema.TaggedError()( + "@maple/api/routes/v1/V1RouteExecutionDefect", + { + group: Schema.String, + operation: Schema.String, + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + +class V1ResponseSchemaError extends Schema.TaggedError()( + "@maple/api/routes/v1/V1ResponseSchemaError", + { + group: Schema.String, + operation: Schema.String, + component: Schema.Literals(["Body", "ResponseHeaders"]), + message: Schema.String, + details: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) {} + +const V1SchemaErrorTransformLive = HttpApiMiddleware.layerSchemaErrorTransform( + V1SchemaErrors, + (schemaError, { endpoint, group }) => + Effect.suspend((): Effect.Effect => { + const details = describeSchemaIssue(schemaError.cause.issue) + if (schemaError.kind === "Body" || schemaError.kind === "ResponseHeaders") { + const error = new V1ResponseSchemaError({ + group: group.identifier, + operation: endpoint.identifier, + component: schemaError.kind, + message: "V1 response failed its declared HTTP schema", + details: details.map(({ line }) => line), + cause: schemaError.cause, + }) + return Effect.logError(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + group: error.group, + operation: error.operation, + component: error.component, + details: error.details, + cause: error.cause, + }), + Effect.andThen( + Effect.fail( + new V1UnexpectedError({ + message: "An unexpected error occurred on our end.", + }), + ), + ), + ) + } + const first = details[0] + return Effect.fail( + new V1RequestValidationError({ + message: summarizeSchemaError(schemaError.kind, details), + ...(first === undefined || first.path === "" ? {} : { param: first.path }), + details: details.map(({ line }) => line), + }), + ) + }), +) + +const V1UnexpectedErrorsLive = Layer.succeed( + V1UnexpectedErrors, + V1UnexpectedErrors.of((httpEffect, { endpoint, group }) => + httpEffect.pipe( + Effect.catchDefect((cause) => { + const defectType = cause instanceof Error ? cause.name : typeof cause + const error = new V1RouteExecutionDefect({ + group: group.identifier, + operation: endpoint.identifier, + message: "Unexpected v1 route execution defect", + cause, + }) + return Effect.logError(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + group: error.group, + operation: error.operation, + defectType, + cause: error.cause, + }), + Effect.andThen( + Effect.fail( + new V1UnexpectedError({ + message: "An unexpected error occurred on our end.", + }), + ), + ), + ) + }), + ), + ), +) + +/** API-wide legacy error boundary: useful 400s and sanitized, logged defects. */ +export const V1ErrorBoundaryLive = Layer.merge(V1SchemaErrorTransformLive, V1UnexpectedErrorsLive) diff --git a/apps/api/src/routes/v1/integrations.http.ts b/apps/api/src/routes/v1/integrations.http.ts index 07841bd3e..dd776fcd1 100644 --- a/apps/api/src/routes/v1/integrations.http.ts +++ b/apps/api/src/routes/v1/integrations.http.ts @@ -1296,6 +1296,10 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => ), ), Effect.catchTags({ + "@maple/http/errors/IntegrationsConfigurationError": () => + Effect.succeed( + planetscaleErrorPage("PlanetScale integration is not configured in Maple"), + ), // Validation/upstream messages are our own sanitized strings — showing // them turns "it failed" into something actionable. "@maple/http/errors/IntegrationsValidationError": (error) => diff --git a/apps/api/src/routes/v1/planetscale-webhook.http.ts b/apps/api/src/routes/v1/planetscale-webhook.http.ts index a943d18a1..f09355f6b 100644 --- a/apps/api/src/routes/v1/planetscale-webhook.http.ts +++ b/apps/api/src/routes/v1/planetscale-webhook.http.ts @@ -2,7 +2,7 @@ import { HttpRouter, HttpServerResponse, type HttpServerRequest } from "effect/u import { IntegrationsPersistenceError, OrgId } from "@maple/domain/http" import { planetscaleConnections } from "@maple/db" import { eq } from "drizzle-orm" -import { Clock, Data, Effect, Option, Redacted, Schema } from "effect" +import { Clock, Effect, Option, Redacted, Schema } from "effect" import { decryptAes256Gcm, parseBase64Aes256GcmKey } from "@/platform/Crypto" import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" @@ -28,13 +28,10 @@ const textResponse = (body: string, status: number) => HttpServerResponse.text(b const decodeOrgIdSync = Schema.decodeUnknownSync(OrgId) -class PlanetScaleWebhookUnavailable extends Data.TaggedError( +class PlanetScaleWebhookUnavailable extends Schema.TaggedError()( "@maple/api/routes/PlanetScaleWebhookUnavailable", -)<{ readonly body: string }> { - override get message(): string { - return this.body - } -} + { message: Schema.String }, +) {} export const PlanetScaleWebhookRouter = HttpRouter.use((router) => Effect.gen(function* () { @@ -74,7 +71,7 @@ export const PlanetScaleWebhookRouter = HttpRouter.use((router) => "maple.planetscale.webhook.outcome": "enqueue_failed", "maple.planetscale.webhook.reason": reason, }) - return yield* new PlanetScaleWebhookUnavailable({ body }) + return yield* new PlanetScaleWebhookUnavailable({ message: body }) }) if (connectionId.length === 0) { @@ -216,9 +213,15 @@ export const PlanetScaleWebhookRouter = HttpRouter.use((router) => yield* router.add("POST", ROUTE, (req) => handle(req).pipe( - Effect.catchTag("@maple/api/routes/PlanetScaleWebhookUnavailable", ({ body }) => - Effect.succeed(textResponse(body, 503)), - ), + Effect.catchTags({ + "@maple/api/routes/PlanetScaleWebhookUnavailable": ({ message }) => + Effect.succeed(textResponse(message, 503)), + "@maple/http/errors/IntegrationsPersistenceError": (error) => + Effect.logError("PlanetScale webhook persistence failed").pipe( + Effect.annotateLogs({ message: error.message }), + Effect.as(textResponse("Webhook service unavailable", 503)), + ), + }), ), ) }), diff --git a/apps/api/src/routes/v1/slack-integration.http.ts b/apps/api/src/routes/v1/slack-integration.http.ts index 62f9d6663..5710c0c72 100644 --- a/apps/api/src/routes/v1/slack-integration.http.ts +++ b/apps/api/src/routes/v1/slack-integration.http.ts @@ -68,6 +68,13 @@ export const SlackCallbackRouter = HttpRouter.use((router) => }), ), Effect.catchTags({ + "@maple/http/errors/IntegrationsConfigurationError": () => + Effect.succeed( + redirect({ + slack: "error", + slack_message: "Slack integration is not configured in Maple", + }), + ), "@maple/http/errors/IntegrationsValidationError": (error) => Effect.succeed(redirect({ slack: "error", slack_message: error.message })), "@maple/http/errors/IntegrationsForbiddenError": (error) => diff --git a/apps/api/src/routes/v1/vcs-webhook.http.ts b/apps/api/src/routes/v1/vcs-webhook.http.ts index bd1e37059..6c8e37217 100644 --- a/apps/api/src/routes/v1/vcs-webhook.http.ts +++ b/apps/api/src/routes/v1/vcs-webhook.http.ts @@ -1,5 +1,5 @@ import { HttpRouter, type HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { Data, Effect, Option } from "effect" +import { Effect, Option, Schema } from "effect" import type { VcsProviderClient } from "@/services/integrations/vcs/VcsProviderClient" import { VcsProviderRegistry } from "@/services/integrations/vcs/VcsProviderRegistry" import { VcsSyncQueue } from "@/services/integrations/vcs/VcsSyncQueue" @@ -9,9 +9,10 @@ import { VcsSyncQueue } from "@/services/integrations/vcs/VcsSyncQueue" * 500. Failed immediately after the span annotation, then caught outside the * span (never serialized). */ -class EnqueueFailure extends Data.TaggedError("@maple/api/routes/VcsWebhookEnqueueFailure")<{ - readonly message: string -}> {} +class EnqueueFailure extends Schema.TaggedError()( + "@maple/api/routes/VcsWebhookEnqueueFailure", + { message: Schema.String }, +) {} // Public webhook receiver, one static route per registered provider // (`/api/integrations//webhook`). Generic pipeline: the provider diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts index 1ba0c4bfb..23f7d848f 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -41,7 +41,7 @@ import { HazelOAuthService } from "@/services/auth/HazelOAuthService" import { OrgClickHouseSettingsService } from "@/services/org/OrgClickHouseSettingsService" import { OrgMembersService } from "@/services/org/OrgMembersService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, @@ -170,7 +170,7 @@ const makeHarness = () => { Layer.provide(AllV2GroupLayersLive), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/alert-deliveries.http.ts b/apps/api/src/routes/v2/alert-deliveries.http.ts index 4b2e91790..dacc00bfd 100644 --- a/apps/api/src/routes/v2/alert-deliveries.http.ts +++ b/apps/api/src/routes/v2/alert-deliveries.http.ts @@ -5,7 +5,6 @@ import type { V2AlertDelivery } from "@maple/domain/http/v2" import { MapleApiV2, paginateOffsetQuery, timestamp, timestampOrNull } from "@maple/domain/http/v2" import { Effect } from "effect" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" -import { mapAlertError } from "./alerts-error-map" const toV2Delivery = (doc: AlertDeliveryEventDocument): V2AlertDelivery => ({ id: doc.id, @@ -34,10 +33,9 @@ export const HttpV2AlertDeliveriesLive = HttpApiBuilder.group(MapleApiV2, "alert Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => - readModels.listDeliveryEvents(tenant.orgId, { limit, offset }).pipe( - mapAlertError("delivery_list"), - Effect.map((response) => response.events.map(toV2Delivery)), - ), + readModels + .listDeliveryEvents(tenant.orgId, { limit, offset }) + .pipe(Effect.map((response) => response.events.map(toV2Delivery))), ) return { object: "list" as const, ...page } }), diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index cab79178d..c1a4064d8 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -5,6 +5,7 @@ import { DiscordAlertDestinationConfig, EmailAlertDestinationConfig, HazelOAuthAlertDestinationConfig, + AlertNotFoundError, PagerDutyAlertDestinationConfig, SlackBotAlertDestinationConfig, WebhookAlertDestinationConfig, @@ -15,10 +16,9 @@ import type { V2AlertDestinationMutationResponse, V2AlertDestinationUpdateParams, } from "@maple/domain/http/v2" -import { MapleApiV2, paginateArray, resourceNotFound } from "@maple/domain/http/v2" +import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" import { Effect } from "effect" import { AlertDestinationsService } from "@/services/alerts/AlertDestinationsService" -import { mapAlertError } from "./alerts-error-map" const toV2Destination = (doc: AlertDestinationDocument): V2AlertDestination => ({ id: doc.id, @@ -162,9 +162,8 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale .handle("list", ({ query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* destinations - .listDestinations(tenant.orgId) - .pipe(mapAlertError("destination_list")) + const response = yield* destinations.listDestinations(tenant.orgId) + const page = yield* paginateArray(response.destinations.map(toV2Destination), query) return { object: "list" as const, ...page } }), @@ -172,13 +171,16 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* destinations - .listDestinations(tenant.orgId) - .pipe(mapAlertError("destination_list")) + const response = yield* destinations.listDestinations(tenant.orgId) + const destination = response.destinations.find((doc) => doc.id === params.id) if (destination === undefined) return yield* Effect.fail( - resourceNotFound("alert_destination", "No such alert destination."), + new AlertNotFoundError({ + message: "No such alert destination.", + resourceType: "destination", + resourceId: params.id, + }), ) return toV2Destination(destination) }), @@ -186,38 +188,39 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const created = yield* destinations - .createDestination( - tenant.orgId, - tenant.userId, - tenant.roles, - toCreateRequest(payload), - ) - .pipe(mapAlertError("destination_create")) + const created = yield* destinations.createDestination( + tenant.orgId, + tenant.userId, + tenant.roles, + toCreateRequest(payload), + ) + return toV2DestinationMutation(created) }), ) .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const updated = yield* destinations - .updateDestination( - tenant.orgId, - tenant.userId, - tenant.roles, - params.id, - toUpdateRequest(payload), - ) - .pipe(mapAlertError("destination_update")) + const updated = yield* destinations.updateDestination( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + toUpdateRequest(payload), + ) + return toV2DestinationMutation(updated) }), ) .handle("delete", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const deleted = yield* destinations - .deleteDestination(tenant.orgId, tenant.roles, params.id) - .pipe(mapAlertError("destination_delete")) + const deleted = yield* destinations.deleteDestination( + tenant.orgId, + tenant.roles, + params.id, + ) + return { id: deleted.id, object: "alert_destination" as const, @@ -229,9 +232,13 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale .handle("test", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const result = yield* destinations - .testDestination(tenant.orgId, tenant.userId, tenant.roles, params.id) - .pipe(mapAlertError("destination_test")) + const result = yield* destinations.testDestination( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + ) + return { object: "alert_destination.test_result" as const, success: result.success, diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index 1a17c787c..b4610ec3e 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -5,7 +5,6 @@ import type { V2AlertIncident } from "@maple/domain/http/v2" import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2" import { Effect } from "effect" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" -import { mapAlertError } from "./alerts-error-map" const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ id: doc.id, @@ -46,10 +45,7 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI limit, offset, }) - .pipe( - mapAlertError("incident_list"), - Effect.map((response) => response.incidents.map(toV2Incident)), - ), + .pipe(Effect.map((response) => response.incidents.map(toV2Incident))), ) return { object: "list" as const, ...page } }), @@ -57,9 +53,8 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const incident = yield* readModels - .getIncident(tenant.orgId, params.id) - .pipe(mapAlertError("incident_retrieve")) + const incident = yield* readModels.getIncident(tenant.orgId, params.id) + return toV2Incident(incident) }), ) diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index 5ff0a650f..29cf8a087 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -3,6 +3,7 @@ import type { AlertCheckDocument, AlertRuleDocument, AlertRulePreviewResponse } import { AlertRulePreviewRequest, AlertRuleUpsertRequest, + AlertNotFoundError, CurrentTenant, IsoDateTimeString, QueryBuilderQueryDraftSchema, @@ -14,22 +15,13 @@ import type { V2AlertRuleMutationResponse, V2AlertRulePreviewResult, V2AlertRuleUpdateParams, - V2InvalidRequestError, -} from "@maple/domain/http/v2" -import { - MapleApiV2, - invalidRequest, - paginateArray, - resourceNotFound, - scopeAllows, - timestamp, } from "@maple/domain/http/v2" +import { MapleApiV2, paginateArray, scopeAllows, timestamp, V2ParameterInvalid } from "@maple/domain/http/v2" import { AlertForbiddenError } from "@maple/domain/http" import { Effect, Encoding, Result, Schema } from "effect" import { AlertsService } from "@/services/alerts/AlertsService" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@/services/alerts/AlertRulesService" -import { mapAlertError } from "./alerts-error-map" const decodeIsoDateTime = Schema.decodeUnknownSync(IsoDateTimeString) @@ -39,11 +31,11 @@ const encodeChecksCursor = (check: AlertCheckDocument): string => const decodeChecksCursor = (value: string | undefined) => { if (value === undefined) return Effect.succeed(undefined) if (!value.startsWith("chk_")) { - return Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + return Effect.fail(V2ParameterInvalid.make("Invalid pagination cursor.", { param: "cursor" })) } const decoded = Encoding.decodeBase64UrlString(value.slice(4)) if (Result.isFailure(decoded)) { - return Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + return Effect.fail(V2ParameterInvalid.make("Invalid pagination cursor.", { param: "cursor" })) } try { const parts = JSON.parse(decoded.success) as unknown @@ -58,7 +50,7 @@ const decodeChecksCursor = (value: string | undefined) => { } return Effect.succeed([parts[0], parts[1]] as const) } catch { - return Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + return Effect.fail(V2ParameterInvalid.make("Invalid pagination cursor.", { param: "cursor" })) } } @@ -137,17 +129,15 @@ const toV2Check = (check: AlertCheckDocument): V2AlertCheck => ({ const decodeDraft = (draft: Record) => Schema.decodeUnknownEffect(QueryBuilderQueryDraftSchema)(draft).pipe( Effect.mapError(() => - invalidRequest( - "parameter_invalid", - "query_builder_draft is not a valid query-builder draft document.", - "query_builder_draft", - ), + V2ParameterInvalid.make("query_builder_draft is not a valid query-builder draft document.", { + param: "query_builder_draft", + }), ), ) const toUpsertRequest = ( params: V2AlertRuleCreateParams, -): Effect.Effect => +): Effect.Effect> => Effect.gen(function* () { const draftField = params.query_builder_draft === undefined @@ -205,7 +195,7 @@ const toUpsertRequest = ( const mergeUpsertRequest = ( doc: AlertRuleDocument, patch: V2AlertRuleUpdateParams, -): Effect.Effect => +): Effect.Effect> => Effect.gen(function* () { const signalType = patch.signal_type ?? doc.signalType const queryBuilderDraft = @@ -300,10 +290,16 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules const findRule = (orgId: Parameters[0], ruleId: AlertRuleDocument["id"]) => Effect.gen(function* () { - const response = yield* rules.listRules(orgId).pipe(mapAlertError("rule_list")) + const response = yield* rules.listRules(orgId) const rule = response.rules.find((doc) => doc.id === ruleId) if (rule === undefined) - return yield* Effect.fail(resourceNotFound("alert_rule", "No such alert rule.")) + return yield* Effect.fail( + new AlertNotFoundError({ + message: "No such alert rule.", + resourceType: "alert_rule", + resourceId: ruleId, + }), + ) return rule }) @@ -311,7 +307,7 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules .handle("list", ({ query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* rules.listRules(tenant.orgId).pipe(mapAlertError("rule_list")) + const response = yield* rules.listRules(tenant.orgId) const page = yield* paginateArray(response.rules.map(toV2Rule), query) return { object: "list" as const, ...page } }), @@ -327,9 +323,13 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const request = yield* toUpsertRequest(payload) - const created = yield* rules - .createRule(tenant.orgId, tenant.userId, tenant.roles, request) - .pipe(mapAlertError("rule_create")) + const created = yield* rules.createRule( + tenant.orgId, + tenant.userId, + tenant.roles, + request, + ) + return toV2RuleMutationResponse(created) }), ) @@ -338,18 +338,22 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules const tenant = yield* CurrentTenant.Context const current = yield* findRule(tenant.orgId, params.id) const request = yield* mergeUpsertRequest(current, payload) - const updated = yield* alerts - .updateRule(tenant.orgId, tenant.userId, tenant.roles, params.id, request) - .pipe(mapAlertError("rule_update")) + const updated = yield* alerts.updateRule( + tenant.orgId, + tenant.userId, + tenant.roles, + params.id, + request, + ) + return toV2RuleMutationResponse(updated) }), ) .handle("delete", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const deleted = yield* rules - .deleteRule(tenant.orgId, tenant.roles, params.id) - .pipe(mapAlertError("rule_delete")) + const deleted = yield* rules.deleteRule(tenant.orgId, tenant.roles, params.id) + return { id: deleted.id, object: "alert_rule" as const, @@ -362,9 +366,14 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const rule = yield* toUpsertRequest(payload.rule) - const result = yield* alerts - .testRule(tenant.orgId, tenant.userId, tenant.roles, rule, payload.send_notification) - .pipe(mapAlertError("rule_test")) + const result = yield* alerts.testRule( + tenant.orgId, + tenant.userId, + tenant.roles, + rule, + payload.send_notification, + ) + return { object: "alert_rule.test_result" as const, status: result.status, @@ -393,19 +402,18 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules return yield* new AlertForbiddenError({ message: 'Previewing a raw SQL alert requires the "alerts:write" scope, because it executes your query against the warehouse.', - }).pipe(mapAlertError("rule_preview")) + }) } - const preview = yield* alerts - .previewRule( - tenant.orgId, - tenant.roles, - new AlertRulePreviewRequest({ - rule, - startTime: decodeIsoDateTime(payload.start_time), - endTime: decodeIsoDateTime(payload.end_time), - }), - ) - .pipe(mapAlertError("rule_preview")) + const preview = yield* alerts.previewRule( + tenant.orgId, + tenant.roles, + new AlertRulePreviewRequest({ + rule, + startTime: decodeIsoDateTime(payload.start_time), + endTime: decodeIsoDateTime(payload.end_time), + }), + ) + return toV2PreviewResult(preview) }), ) @@ -414,18 +422,17 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules const tenant = yield* CurrentTenant.Context const cursor = yield* decodeChecksCursor(query.cursor) const limit = query.limit ?? 20 - const response = yield* readModels - .listRuleChecks(tenant.orgId, params.id, { - ...(query.group_key !== undefined ? { groupKey: query.group_key } : {}), - ...(query.status !== undefined ? { status: query.status } : {}), - ...(query.since !== undefined ? { since: query.since } : {}), - ...(query.until !== undefined ? { until: query.until } : {}), - ...(cursor !== undefined - ? { beforeTimestamp: cursor[0], beforeGroupKey: cursor[1] } - : {}), - limit: limit + 1, - }) - .pipe(mapAlertError("rule_checks_list")) + const response = yield* readModels.listRuleChecks(tenant.orgId, params.id, { + ...(query.group_key !== undefined ? { groupKey: query.group_key } : {}), + ...(query.status !== undefined ? { status: query.status } : {}), + ...(query.since !== undefined ? { since: query.since } : {}), + ...(query.until !== undefined ? { until: query.until } : {}), + ...(cursor !== undefined + ? { beforeTimestamp: cursor[0], beforeGroupKey: cursor[1] } + : {}), + limit: limit + 1, + }) + const hasMore = response.checks.length > limit const checks = hasMore ? response.checks.slice(0, limit) : response.checks const last = checks.at(-1) @@ -440,12 +447,11 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules .handle("checksSummary", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const summary = yield* readModels - .summarizeRuleChecks(tenant.orgId, params.id, { - since: query.since, - until: query.until, - }) - .pipe(mapAlertError("rule_checks_list")) + const summary = yield* readModels.summarizeRuleChecks(tenant.orgId, params.id, { + since: query.since, + until: query.until, + }) + return { object: "alert_check.summary" as const, bucket_seconds: summary.bucketSeconds, diff --git a/apps/api/src/routes/v2/alerts-error-map.test.ts b/apps/api/src/routes/v2/alerts-error-map.test.ts deleted file mode 100644 index 68c7e98bf..000000000 --- a/apps/api/src/routes/v2/alerts-error-map.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { assert, describe, it } from "@effect/vitest" -import { Effect } from "effect" -import { WarehouseQuotaExceededError, WarehouseUpstreamError } from "@maple/domain/http" -import { V2RateLimitError, V2ServiceUnavailableError } from "@maple/domain/http/v2" -import { mapAlertError } from "./alerts-error-map" - -describe("mapAlertError", () => { - it.effect("preserves transient warehouse failures as HTTP 503", () => - Effect.gen(function* () { - const error = yield* Effect.fail( - new WarehouseUpstreamError({ - message: "warehouse temporarily unavailable", - pipeName: "listRuleChecks", - upstreamStatus: 503, - }), - ).pipe(mapAlertError("rule_checks_list"), Effect.flip) - - assert.instanceOf(error, V2ServiceUnavailableError) - assert.strictEqual(error.error.code, "alert_rule_checks_list_unavailable") - }), - ) - - it.effect("keeps warehouse quota failures as HTTP 429", () => - Effect.gen(function* () { - const error = yield* Effect.fail( - new WarehouseQuotaExceededError({ - message: "query quota exceeded", - pipeName: "listRuleChecks", - setting: "max_execution_time", - }), - ).pipe(mapAlertError("rule_checks_list"), Effect.flip) - - assert.instanceOf(error, V2RateLimitError) - }), - ) -}) diff --git a/apps/api/src/routes/v2/alerts-error-map.ts b/apps/api/src/routes/v2/alerts-error-map.ts deleted file mode 100644 index dddfa1cb1..000000000 --- a/apps/api/src/routes/v2/alerts-error-map.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { - AlertDeliveryError, - AlertDestinationInUseError, - AlertForbiddenError, - AlertNotFoundError, - AlertPersistenceError, - AlertValidationError, - WarehouseError, - WarehouseQuotaExceededError, - WarehouseUpstreamError, - WarehouseValidationError, -} from "@maple/domain/http" -import { presentWarehouseErrorPublic, type WarehouseErrorLike } from "@maple/domain/http" -import { - conflict, - dependencyUnavailable, - invalidRequest, - permissionError, - rateLimited, - resourceNotFound, - upstreamError, -} from "@maple/domain/http/v2" -import type { - V2ConflictError, - V2InvalidRequestError, - V2NotFoundError, - V2PermissionError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "@maple/domain/http/v2" -import { Effect, Match } from "effect" - -type V2ReachableAlertError = - | AlertForbiddenError - | AlertValidationError - | AlertNotFoundError - | AlertDestinationInUseError - | AlertPersistenceError - | AlertDeliveryError - | WarehouseError - -type V2AlertReadError = V2InvalidRequestError | V2RateLimitError | V2ServiceUnavailableError - -type V2AlertCommonError = V2AlertReadError | V2UpstreamError - -type V2AlertWriteError = V2AlertCommonError | V2PermissionError - -type V2AlertMutationError = V2AlertWriteError | V2NotFoundError - -type V2AlertMappedError = V2AlertMutationError | V2ConflictError | V2RateLimitError - -const normalizeAlertResourceType = (resourceType: string) => - Match.value(resourceType).pipe( - Match.when("destination", () => "alert_destination"), - Match.when("rule", () => "alert_rule"), - Match.when("alert_incident", () => "alert_incident"), - Match.when("alert_rule", () => "alert_rule"), - Match.orElse(() => "alert_resource"), - ) - -const makeAlertErrorMatcher = (operation: string) => { - // Forward the shared per-tag copy instead of one fixed string: a missing - // column on the customer's cluster and a Maple SQL bug used to be - // indistinguishable "The alert query could not be completed." envelopes. - // Redacted presentation: raw ClickHouse diagnostics stay off the public API. - const warehouseFailure = (error: WarehouseErrorLike) => - upstreamError(`alert_${operation}_upstream_failed`, presentWarehouseErrorPublic(error).description) - return Match.type().pipe( - Match.tagsExhaustive({ - "@maple/http/errors/AlertForbiddenError": () => - permissionError( - "insufficient_permissions", - "You do not have permission to perform this alert operation.", - ), - "@maple/http/errors/AlertValidationError": (error: AlertValidationError) => - invalidRequest("parameter_invalid", error.message), - "@maple/http/errors/AlertNotFoundError": (error: AlertNotFoundError) => { - const resource = normalizeAlertResourceType(error.resourceType) - return resourceNotFound(resource, `No such ${resource.replaceAll("_", " ")}.`) - }, - "@maple/http/errors/AlertDestinationInUseError": () => - conflict( - "alert_destination_in_use", - "The alert destination is currently used by one or more alert rules.", - ), - "@maple/http/errors/AlertPersistenceError": () => - dependencyUnavailable(`alert_${operation}_unavailable`), - "@maple/http/errors/AlertDeliveryError": () => - upstreamError(`alert_${operation}_upstream_failed`, "The alert provider request failed."), - "@maple/http/errors/WarehouseQueryError": warehouseFailure, - "@maple/http/errors/WarehouseUpstreamError": () => - dependencyUnavailable(`alert_${operation}_unavailable`), - "@maple/http/errors/WarehouseAuthError": warehouseFailure, - "@maple/http/errors/WarehouseConfigError": warehouseFailure, - "@maple/http/errors/WarehouseClientError": warehouseFailure, - "@maple/http/errors/WarehouseSchemaDriftError": warehouseFailure, - // Maple generated SQL its own warehouse refused to plan: a server fault, - // not the caller's, so it stays a 5xx rather than becoming a 400 — but - // under its own code so on-call stops chasing the customer's warehouse. - "@maple/http/errors/WarehouseMalformedQueryError": (error: WarehouseErrorLike) => - upstreamError(`alert_${operation}_query_bug`, presentWarehouseErrorPublic(error).description), - // A quota breach is the caller exceeding cost limits (429), and a - // validation failure is a malformed request (400) — neither is an - // upstream outage. - "@maple/http/errors/WarehouseQuotaExceededError": () => rateLimited(), - "@maple/http/errors/WarehouseValidationError": (error: WarehouseValidationError) => - invalidRequest("parameter_invalid", error.message), - }), - ) -} - -/** Exhaustive, tag-local v1 alert error translation for v2 handlers. */ -export function mapAlertError( - operation: "delivery_list" | "incident_list", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError( - operation: "incident_retrieve", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError( - operation: "destination_list" | "rule_list", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError( - operation: - | "destination_update" - | "destination_test" - | "rule_create" - | "rule_update" - | "rule_delete" - | "rule_test", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError( - operation: "destination_create", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError( - operation: "destination_delete", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError( - operation: "rule_preview" | "rule_checks_list", -): ( - effect: Effect.Effect, -) => Effect.Effect -export function mapAlertError(operation: string) { - const match = makeAlertErrorMatcher(operation) - return ( - effect: Effect.Effect, - ): Effect.Effect => effect.pipe(Effect.mapError(match)) -} diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 82f1e8f1f..9b53b3c59 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -24,7 +24,7 @@ import { HazelOAuthService } from "@/services/auth/HazelOAuthService" import { OrgClickHouseSettingsService } from "@/services/org/OrgClickHouseSettingsService" import { OrgMembersService } from "@/services/org/OrgMembersService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, @@ -147,7 +147,7 @@ const makeHarness = (warehouseService: WarehouseQueryServiceShape = warehouseStu Layer.provide(AllV2GroupLayersLive), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/anomalies.http.ts b/apps/api/src/routes/v2/anomalies.http.ts index 857a579d3..d47a3d3d4 100644 --- a/apps/api/src/routes/v2/anomalies.http.ts +++ b/apps/api/src/routes/v2/anomalies.http.ts @@ -10,19 +10,9 @@ import type { import { AnomalyDetectorSettingsUpdateRequest, AnomalyForbiddenError, - type AnomalyIncidentNotFoundError, - type AnomalyLinkedIssueNotFoundError, - AnomalyPersistenceError, CurrentTenant, - type ErrorPersistenceError, } from "@maple/domain/http" -import { - MapleApiV2, - dependencyUnavailable, - paginateOffsetQuery, - permissionError, - resourceNotFound, -} from "@maple/domain/http/v2" +import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2" import type { V2AnomalyIncident, V2AnomalyIncidentTimeseries, V2AnomalySettings } from "@maple/domain/http/v2" import { Effect } from "effect" import { requireAdmin } from "@/services/auth/auth" @@ -83,53 +73,6 @@ const toV2Settings = (s: AnomalyDetectorSettingsDocument): V2AnomalySettings => updated_by: s.updatedBy, }) -/** Service tagged errors → v2 envelope errors (no 404). */ -const mapCommonError = - (operation: string) => - (effect: Effect.Effect) => - effect.pipe( - Effect.catchTags({ - "@maple/http/anomalies/AnomalyPersistenceError": () => - Effect.fail(dependencyUnavailable(`anomaly_${operation}_unavailable`)), - "@maple/http/errors/ErrorPersistenceError": () => - Effect.fail(dependencyUnavailable(`anomaly_${operation}_unavailable`)), - }), - ) - -/** Service tagged errors → v2 envelope errors (incident/linked-issue 404s). */ -const mapWith404 = - (operation: string) => - ( - effect: Effect.Effect< - A, - AnomalyPersistenceError | AnomalyIncidentNotFoundError | AnomalyLinkedIssueNotFoundError, - R - >, - ) => - effect.pipe( - Effect.catchTags({ - "@maple/http/anomalies/AnomalyIncidentNotFoundError": () => - Effect.fail(resourceNotFound("anomaly_incident", "No such anomaly incident.")), - "@maple/http/anomalies/AnomalyLinkedIssueNotFoundError": () => - Effect.fail(resourceNotFound("error_issue", "No such error issue.", "issue_id")), - "@maple/http/anomalies/AnomalyPersistenceError": () => - Effect.fail(dependencyUnavailable(`anomaly_${operation}_unavailable`)), - }), - ) - -/** Settings mutation: forbidden → 403, else 503. */ -const mapSettingsError = ( - effect: Effect.Effect, -) => - effect.pipe( - Effect.catchTags({ - "@maple/http/anomalies/AnomalyForbiddenError": (error) => - Effect.fail(permissionError("insufficient_permissions", error.message)), - "@maple/http/anomalies/AnomalyPersistenceError": () => - Effect.fail(dependencyUnavailable("anomaly_settings_update_unavailable")), - }), - ) - export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", (handlers) => Effect.gen(function* () { const anomalies = yield* AnomalyDetectionService @@ -181,10 +124,7 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", limit, offset, }) - .pipe( - mapCommonError("list"), - Effect.map((response) => response.incidents.map(toV2Incident)), - ), + .pipe(Effect.map((response) => response.incidents.map(toV2Incident))), ) return { object: "list" as const, ...page } }), @@ -192,42 +132,41 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", .handle("getIncident", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const incident = yield* anomalies - .getIncident(tenant.orgId, params.id) - .pipe(mapWith404("retrieve")) + const incident = yield* anomalies.getIncident(tenant.orgId, params.id) + return toV2Incident(incident) }), ) .handle("getIncidentTimeseries", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* anomalies - .getIncidentTimeseries(tenant, params.id, { - ...(query.start_time !== undefined ? { startTime: query.start_time } : {}), - ...(query.end_time !== undefined ? { endTime: query.end_time } : {}), - }) - .pipe(mapWith404("timeseries")) + const response = yield* anomalies.getIncidentTimeseries(tenant, params.id, { + ...(query.start_time !== undefined ? { startTime: query.start_time } : {}), + ...(query.end_time !== undefined ? { endTime: query.end_time } : {}), + }) + return toV2Timeseries(response) }), ) .handle("resolveIncident", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const incident = yield* anomalies - .resolveIncidentManually(tenant.orgId, params.id) - .pipe(mapWith404("resolve")) + const incident = yield* anomalies.resolveIncidentManually(tenant.orgId, params.id) + return toV2Incident(incident) }), ) .handle("setIncidentIssue", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const actor = yield* errors - .ensureUserActor(tenant.orgId, tenant.userId) - .pipe(mapCommonError("link_issue")) - const { incident, previousIssueId } = yield* anomalies - .setIncidentIssue(tenant.orgId, params.id, payload.issue_id) - .pipe(mapWith404("link_issue")) + const actor = yield* errors.ensureUserActor(tenant.orgId, tenant.userId) + + const { incident, previousIssueId } = yield* anomalies.setIncidentIssue( + tenant.orgId, + params.id, + payload.issue_id, + ) + if (previousIssueId !== null && previousIssueId !== payload.issue_id) { yield* recordLinkEvent(tenant.orgId, actor.id, previousIssueId, "unlinked", incident) } @@ -240,9 +179,8 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", .handle("getSettings", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const settings = yield* anomalies - .getSettings(tenant.orgId) - .pipe(mapCommonError("settings_retrieve")) + const settings = yield* anomalies.getSettings(tenant.orgId) + return toV2Settings(settings) }), ) @@ -255,22 +193,21 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", new AnomalyForbiddenError({ message: "Only org admins can manage anomaly detector settings", }), - ).pipe(mapSettingsError) - const settings = yield* anomalies - .updateSettings( - tenant.orgId, - tenant.userId, - new AnomalyDetectorSettingsUpdateRequest({ - ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), - ...(payload.sensitivity !== undefined - ? { sensitivity: payload.sensitivity } - : {}), - ...(payload.muted_signals !== undefined - ? { mutedSignals: payload.muted_signals } - : {}), - }), - ) - .pipe(mapSettingsError) + ) + const settings = yield* anomalies.updateSettings( + tenant.orgId, + tenant.userId, + new AnomalyDetectorSettingsUpdateRequest({ + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + ...(payload.sensitivity !== undefined + ? { sensitivity: payload.sensitivity } + : {}), + ...(payload.muted_signals !== undefined + ? { mutedSignals: payload.muted_signals } + : {}), + }), + ) + return toV2Settings(settings) }), ) diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index 24f6e3171..e0823be1d 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -12,7 +12,7 @@ import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" import { ApiV2RateLimiter, type ApiV2RateLimiterShape } from "@/services/auth/ApiV2RateLimiter" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -60,7 +60,7 @@ const makeHarness = ( const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), @@ -229,11 +229,11 @@ describe("v2 api_keys over HTTP", () => { expect(response.status).toBe(429) expect(response.body).toEqual({ error: { - _tag: "@maple/http/v2/rate_limited", + _tag: "@maple/http/v2/RateLimitError", type: "rate_limit_error", code: "rate_limited", title: "Too many requests", - message: "Too many requests. Retry after 60 seconds.", + message: "Too many requests. Retry after the interval in the Retry-After header.", retryable: true, recovery: "retry", retry_after_seconds: 60, @@ -313,7 +313,7 @@ describe("v2 api_keys over HTTP", () => { const { status, body } = await harness.request("GET", "/v2/api_keys", { token: key.secret }) expect(status).toBe(503) expect(body.error).toEqual({ - _tag: "@maple/http/v2/api_key_lookup_unavailable", + _tag: "@maple/http/errors/ApiKeyLookupPersistenceError", type: "api_error", code: "api_key_lookup_unavailable", title: "Service temporarily unavailable", @@ -337,7 +337,7 @@ describe("v2 api_keys over HTTP", () => { }) expect(create.status).toBe(403) expect(create.body.error).toEqual({ - _tag: "@maple/http/v2/insufficient_scope", + _tag: "@maple/http/v2/InsufficientScopeError", type: "permission_error", code: "insufficient_scope", title: "Permission required", @@ -419,7 +419,7 @@ describe("v2 api_keys over HTTP", () => { }) expect(status).toBe(403) expect(body.error).toEqual({ - _tag: "@maple/http/v2/insufficient_permissions", + _tag: "@maple/http/v2/InsufficientPermissionsError", type: "permission_error", code: "insufficient_permissions", title: "Permission required", @@ -468,7 +468,7 @@ describe("v2 api_keys over HTTP", () => { expect(malformed.body.error.type).toBe("invalid_request_error") expect(malformed.body.error.param).toBe("id") expect(malformed.body.error.code).toBe("parameter_invalid") - expect(malformed.body.error._tag).toBe("@maple/http/v2/apiKeys/retrieve/InvalidRequestError") + expect(malformed.body.error._tag).toBe("@maple/http/v2/InvalidRequestError") // valid key_ encoding of a UUID that doesn't exist const { encodePublicId } = await import("@maple/domain/http/v2") diff --git a/apps/api/src/routes/v2/api-keys.http.ts b/apps/api/src/routes/v2/api-keys.http.ts index 2322e3596..1ffeef9db 100644 --- a/apps/api/src/routes/v2/api-keys.http.ts +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -1,15 +1,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { ApiKeyCreatedResponse, ApiKeyResponse } from "@maple/domain/http" -import type { ApiKeyNotFoundError, ApiKeyPersistenceError } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2, - dependencyUnavailable, isoTimestamp, isoTimestampOrNull, paginateArray, - permissionError, - resourceNotFound, + V2InsufficientPermissions, } from "@maple/domain/http/v2" import type { V2ApiKey, V2ApiKeyMutationResponse, V2ApiKeyWithSecret } from "@maple/domain/http/v2" import { Effect } from "effect" @@ -18,7 +15,7 @@ import { AuthService } from "@/services/auth/AuthService" import { requireAdmin } from "@/services/auth/auth" const adminOnly = (action: string) => () => - permissionError("insufficient_permissions", `Only org admins can ${action} API keys`) + V2InsufficientPermissions.make(`Only org admins can ${action} API keys`) type ApiKeyFields = Pick< ApiKeyResponse, @@ -65,22 +62,6 @@ const toV2ApiKeyMutationResponse = (key: ApiKeyResponse): V2ApiKeyMutationRespon ...(key.txid !== undefined ? { txid: key.txid } : {}), }) -/** Service tagged errors → v2 envelope errors. */ -const mapServiceError = - (operation: string) => - (effect: Effect.Effect) => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/ApiKeyNotFoundError": () => - Effect.fail(resourceNotFound("api_key", "No such API key.")), - "@maple/http/errors/ApiKeyPersistenceError": () => - Effect.fail(dependencyUnavailable(`api_key_${operation}_unavailable`)), - }), - ) - -const mapPersistenceError = (operation: string) => () => - dependencyUnavailable(`api_key_${operation}_unavailable`) - export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (handlers) => Effect.gen(function* () { const apiKeysService = yield* ApiKeysService @@ -90,9 +71,7 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha .handle("list", ({ query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* apiKeysService - .list(tenant.orgId) - .pipe(Effect.mapError(mapPersistenceError("list"))) + const response = yield* apiKeysService.list(tenant.orgId) const page = yield* paginateArray(response.keys.map(toV2ApiKey), query) return { object: "list" as const, ...page } }), @@ -100,9 +79,7 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const key = yield* apiKeysService - .get(tenant.orgId, params.id) - .pipe(mapServiceError("retrieve")) + const key = yield* apiKeysService.get(tenant.orgId, params.id) return toV2ApiKey(key) }), ) @@ -118,19 +95,17 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha yield* requireAdmin(tenant.roles, adminOnly("create")) } const createdByEmail = yield* auth.getUserEmail(tenant.userId) - const created = yield* apiKeysService - .create(tenant.orgId, tenant.userId, { - name: payload.name, - description: payload.description, - expiresInSeconds: payload.expires_in_seconds, - kind: payload.kind, - scopes: payload.scopes, - createdByEmail, - ...(isMcpKey - ? { metadataJson: { source: "maple_mcp", roles: [...tenant.roles] } } - : {}), - }) - .pipe(Effect.mapError(mapPersistenceError("create"))) + const created = yield* apiKeysService.create(tenant.orgId, tenant.userId, { + name: payload.name, + description: payload.description, + expiresInSeconds: payload.expires_in_seconds, + kind: payload.kind, + scopes: payload.scopes, + createdByEmail, + ...(isMcpKey + ? { metadataJson: { source: "maple_mcp", roles: [...tenant.roles] } } + : {}), + }) return toV2ApiKeyWithSecret(created) }), ) @@ -139,9 +114,9 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("roll")) const createdByEmail = yield* auth.getUserEmail(tenant.userId) - const rolled = yield* apiKeysService - .roll(tenant.orgId, tenant.userId, params.id, { createdByEmail }) - .pipe(mapServiceError("roll")) + const rolled = yield* apiKeysService.roll(tenant.orgId, tenant.userId, params.id, { + createdByEmail, + }) return toV2ApiKeyWithSecret(rolled) }), ) @@ -151,16 +126,12 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha // Whoever can mint a key must be able to kill it: a member may // revoke an MCP key they created themselves. Everything else // stays admin-only. - const existing = yield* apiKeysService - .get(tenant.orgId, params.id) - .pipe(mapServiceError("revoke")) + const existing = yield* apiKeysService.get(tenant.orgId, params.id) const isOwnMcpKey = existing.kind === "mcp" && existing.createdBy === tenant.userId if (!isOwnMcpKey) { yield* requireAdmin(tenant.roles, adminOnly("revoke")) } - const revoked = yield* apiKeysService - .revoke(tenant.orgId, params.id) - .pipe(mapServiceError("revoke")) + const revoked = yield* apiKeysService.revoke(tenant.orgId, params.id) return toV2ApiKeyMutationResponse(revoked) }), ) diff --git a/apps/api/src/routes/v2/attribute-mappings.http.ts b/apps/api/src/routes/v2/attribute-mappings.http.ts index b56305363..f7f933ba9 100644 --- a/apps/api/src/routes/v2/attribute-mappings.http.ts +++ b/apps/api/src/routes/v2/attribute-mappings.http.ts @@ -1,30 +1,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" -import type { - IngestAttributeMapping, - IngestAttributeMappingId, - IngestAttributeMappingNotFoundError, - IngestAttributeMappingPersistenceError, - IngestAttributeMappingValidationError, - OrgId, -} from "@maple/domain/http" +import type { IngestAttributeMapping, IngestAttributeMappingId, OrgId } from "@maple/domain/http" import { CreateIngestAttributeMappingRequest, CurrentTenant, + IngestAttributeMappingNotFoundError, UpdateIngestAttributeMappingRequest, } from "@maple/domain/http" -import { - MapleApiV2, - dependencyUnavailable, - invalidRequest, - paginateArray, - resourceNotFound, -} from "@maple/domain/http/v2" -import type { - V2AttributeMapping, - V2InvalidRequestError, - V2NotFoundError, - V2ServiceUnavailableError, -} from "@maple/domain/http/v2" +import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" +import type { V2AttributeMapping } from "@maple/domain/http/v2" import { Array as Arr, Effect, Option } from "effect" import { IngestAttributeMappingService } from "@/services/org/IngestAttributeMappingService" @@ -41,62 +24,11 @@ const toV2AttributeMapping = (mapping: IngestAttributeMapping): V2AttributeMappi updated_at: mapping.updatedAt, }) -/** Service tagged errors → v2 envelope errors (endpoints without a 404). */ -const mapCommonError = - (operation: string) => - ( - effect: Effect.Effect< - A, - IngestAttributeMappingValidationError | IngestAttributeMappingPersistenceError, - R - >, - ): Effect.Effect => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/IngestAttributeMappingValidationError": (error) => - Effect.fail(invalidRequest("parameter_invalid", error.message)), - "@maple/http/errors/IngestAttributeMappingPersistenceError": () => - Effect.fail(dependencyUnavailable(`attribute_mapping_${operation}_unavailable`)), - }), - ) - -/** Service tagged errors → v2 envelope errors (endpoints with a 404). */ -const mapMutationError = - (operation: string) => - ( - effect: Effect.Effect< - A, - | IngestAttributeMappingNotFoundError - | IngestAttributeMappingValidationError - | IngestAttributeMappingPersistenceError, - R - >, - ): Effect.Effect => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/IngestAttributeMappingNotFoundError": () => - Effect.fail(resourceNotFound("attribute_mapping", "No such attribute mapping.")), - "@maple/http/errors/IngestAttributeMappingValidationError": (error) => - Effect.fail(invalidRequest("parameter_invalid", error.message)), - "@maple/http/errors/IngestAttributeMappingPersistenceError": () => - Effect.fail(dependencyUnavailable(`attribute_mapping_${operation}_unavailable`)), - }), - ) - -const mapPersistenceError = ( - effect: Effect.Effect, -): Effect.Effect => - effect.pipe( - Effect.catchTag("@maple/http/errors/IngestAttributeMappingPersistenceError", () => - Effect.fail(dependencyUnavailable("attribute_mapping_list_unavailable")), - ), - ) - export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "attributeMappings", (handlers) => Effect.gen(function* () { const service = yield* IngestAttributeMappingService - const listMappings = (orgId: OrgId) => service.list(orgId).pipe(mapPersistenceError) + const listMappings = (orgId: OrgId) => service.list(orgId) const findMapping = (orgId: OrgId, id: IngestAttributeMappingId) => listMappings(orgId).pipe( @@ -106,7 +38,10 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att { onNone: () => Effect.fail( - resourceNotFound("attribute_mapping", "No such attribute mapping."), + new IngestAttributeMappingNotFoundError({ + mappingId: id, + message: "No such attribute mapping.", + }), ), onSome: Effect.succeed, }, @@ -133,54 +68,47 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const created = yield* service - .create( - tenant.orgId, - new CreateIngestAttributeMappingRequest({ - name: payload.name, - sourceContext: payload.source_context, - sourceKey: payload.source_key, - targetKey: payload.target_key, - operation: payload.operation, - ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), - }), - ) - .pipe(mapCommonError("create")) + const created = yield* service.create( + tenant.orgId, + new CreateIngestAttributeMappingRequest({ + name: payload.name, + sourceContext: payload.source_context, + sourceKey: payload.source_key, + targetKey: payload.target_key, + operation: payload.operation, + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + }), + ) + return toV2AttributeMapping(created) }), ) .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const updated = yield* service - .update( - tenant.orgId, - params.id, - new UpdateIngestAttributeMappingRequest({ - ...(payload.name !== undefined ? { name: payload.name } : {}), - ...(payload.source_context !== undefined - ? { sourceContext: payload.source_context } - : {}), - ...(payload.source_key !== undefined - ? { sourceKey: payload.source_key } - : {}), - ...(payload.target_key !== undefined - ? { targetKey: payload.target_key } - : {}), - ...(payload.operation !== undefined ? { operation: payload.operation } : {}), - ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), - }), - ) - .pipe(mapMutationError("update")) + const updated = yield* service.update( + tenant.orgId, + params.id, + new UpdateIngestAttributeMappingRequest({ + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.source_context !== undefined + ? { sourceContext: payload.source_context } + : {}), + ...(payload.source_key !== undefined ? { sourceKey: payload.source_key } : {}), + ...(payload.target_key !== undefined ? { targetKey: payload.target_key } : {}), + ...(payload.operation !== undefined ? { operation: payload.operation } : {}), + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + }), + ) + return toV2AttributeMapping(updated) }), ) .handle("delete", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const deleted = yield* service - .delete(tenant.orgId, params.id) - .pipe(mapMutationError("delete")) + const deleted = yield* service.delete(tenant.orgId, params.id) + return { id: deleted.id, object: "attribute_mapping" as const, deleted: true as const } }), ) diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index 92070a450..e2d2a3673 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -18,7 +18,7 @@ import { PlanetScaleDiscoveryService } from "@/services/integrations/PlanetScale import { PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService" import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -105,7 +105,7 @@ const makeHarness = () => { const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), @@ -463,7 +463,7 @@ describe("v2 unexpected-error envelope", () => { expect(response.status).toBe(500) expect(response.body).toEqual({ error: { - _tag: "@maple/http/v2/organization/retrieve/UnexpectedError", + _tag: "@maple/http/v2/UnexpectedError", type: "api_error", code: "internal_error", title: "Something went wrong", diff --git a/apps/api/src/routes/v2/dashboards.http.test.ts b/apps/api/src/routes/v2/dashboards.http.test.ts index cffbb4868..2c27a7d57 100644 --- a/apps/api/src/routes/v2/dashboards.http.test.ts +++ b/apps/api/src/routes/v2/dashboards.http.test.ts @@ -10,7 +10,7 @@ import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -51,7 +51,7 @@ const makeHarness = () => { const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), diff --git a/apps/api/src/routes/v2/dashboards.http.ts b/apps/api/src/routes/v2/dashboards.http.ts index 3fa813223..dc10eb911 100644 --- a/apps/api/src/routes/v2/dashboards.http.ts +++ b/apps/api/src/routes/v2/dashboards.http.ts @@ -1,24 +1,18 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant, - DashboardConcurrencyError, DashboardDocument, - DashboardNotFoundError, - DashboardPersistenceError, DashboardTemplateMetadata, - DashboardValidationError, - DashboardVersionNotFoundError, + DashboardTemplateNotFoundError, IsoDateTimeString, PortableDashboardDocument, } from "@maple/domain/http" import { MapleApiV2, LIST_LIMIT_DEFAULT, - conflict, - dependencyUnavailable, - invalidRequest, paginateArray, - resourceNotFound, + V2ParameterInvalid, + V2ParameterMissing, } from "@maple/domain/http/v2" import type { V2Dashboard, @@ -29,7 +23,7 @@ import type { V2DashboardVersion, V2DashboardVersionDetail, } from "@maple/domain/http/v2" -import { Clock, Effect, Match, Schema } from "effect" +import { Clock, Effect, Schema } from "effect" import { getTemplateById, listTemplateMetadata } from "@/dashboard-templates" import type { TemplateParameterValues } from "@/dashboard-templates" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -161,71 +155,6 @@ const applyUpdate = ( }) } -const mapPersistenceError = () => dependencyUnavailable("dashboard_list_unavailable") - -const mapReadError = (error: DashboardNotFoundError | DashboardPersistenceError) => - error instanceof DashboardNotFoundError - ? resourceNotFound("dashboard", "No such dashboard.") - : dependencyUnavailable("dashboard_retrieve_unavailable") - -const mapWriteError = - (operation: string) => - (error: DashboardValidationError | DashboardPersistenceError | DashboardConcurrencyError) => - Match.value(error).pipe( - Match.tagsExhaustive({ - "@maple/http/errors/DashboardValidationError": (validation) => - invalidRequest("parameter_invalid", validation.message), - "@maple/http/errors/DashboardConcurrencyError": (concurrency) => - conflict("dashboard_concurrent_update", concurrency.message), - "@maple/http/errors/DashboardPersistenceError": () => - dependencyUnavailable(`dashboard_${operation}_unavailable`), - }), - ) - -const mapUpdateError = - (operation: string) => - ( - error: - | DashboardNotFoundError - | DashboardValidationError - | DashboardPersistenceError - | DashboardConcurrencyError, - ) => - error instanceof DashboardNotFoundError - ? resourceNotFound("dashboard", "No such dashboard.") - : mapWriteError(operation)(error) - -const mapVersionError = ( - error: DashboardNotFoundError | DashboardVersionNotFoundError | DashboardPersistenceError, -) => - error instanceof DashboardNotFoundError || error instanceof DashboardVersionNotFoundError - ? resourceNotFound( - error instanceof DashboardVersionNotFoundError ? "dashboard_version" : "dashboard", - error instanceof DashboardVersionNotFoundError - ? "No such dashboard version." - : "No such dashboard.", - ) - : dependencyUnavailable("dashboard_version_retrieve_unavailable") - -const mapRestoreError = - (operation: string) => - ( - error: - | DashboardNotFoundError - | DashboardVersionNotFoundError - | DashboardValidationError - | DashboardPersistenceError - | DashboardConcurrencyError, - ) => - error instanceof DashboardNotFoundError || error instanceof DashboardVersionNotFoundError - ? resourceNotFound( - error instanceof DashboardVersionNotFoundError ? "dashboard_version" : "dashboard", - error instanceof DashboardVersionNotFoundError - ? "No such dashboard version." - : "No such dashboard.", - ) - : mapWriteError(operation)(error) - const encodeVersionCursor = (versionNumber: number): string => `ver_${versionNumber.toString(36)}` const decodeVersionCursor = (cursor: string): number | null => { @@ -244,9 +173,8 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards .handle("list", ({ query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* persistence - .list(tenant.orgId) - .pipe(Effect.mapError(mapPersistenceError)) + const response = yield* persistence.list(tenant.orgId) + const page = yield* paginateArray(response.dashboards.map(toV2Dashboard), query) return { object: "list" as const, ...page } }), @@ -254,18 +182,20 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const dashboard = yield* persistence - .get(tenant.orgId, params.id) - .pipe(Effect.mapError(mapReadError)) + const dashboard = yield* persistence.get(tenant.orgId, params.id) + return toV2Dashboard(dashboard) }), ) .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const dashboard = yield* persistence - .create(tenant.orgId, tenant.userId, toPortable(payload)) - .pipe(Effect.mapError(mapWriteError("create"))) + const dashboard = yield* persistence.create( + tenant.orgId, + tenant.userId, + toPortable(payload), + ) + return toV2DashboardMutation(dashboard) }), ) @@ -275,20 +205,21 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const updatedAt = asIsoDateTime( new Date(yield* Clock.currentTimeMillis).toISOString(), ) - const dashboard = yield* persistence - .mutate(tenant.orgId, tenant.userId, params.id, (current) => - Effect.succeed(applyUpdate(current, payload, updatedAt)), - ) - .pipe(Effect.mapError(mapUpdateError("update"))) + const dashboard = yield* persistence.mutate( + tenant.orgId, + tenant.userId, + params.id, + (current) => Effect.succeed(applyUpdate(current, payload, updatedAt)), + ) + return toV2DashboardMutation(dashboard) }), ) .handle("delete", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const deleted = yield* persistence - .delete(tenant.orgId, params.id) - .pipe(Effect.mapError(mapReadError)) + const deleted = yield* persistence.delete(tenant.orgId, params.id) + return { id: deleted.id, object: "dashboard" as const, @@ -299,13 +230,14 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards ) .handle("importPerses", ({ payload }) => Effect.gen(function* () { - const converted = yield* convertPersesDashboardToPortable(payload.dashboard).pipe( - Effect.mapError((error) => invalidRequest("parameter_invalid", error.message)), - ) + const converted = yield* convertPersesDashboardToPortable(payload.dashboard) const tenant = yield* CurrentTenant.Context - const dashboard = yield* persistence - .create(tenant.orgId, tenant.userId, converted.dashboard) - .pipe(Effect.mapError(mapWriteError("import"))) + const dashboard = yield* persistence.create( + tenant.orgId, + tenant.userId, + converted.dashboard, + ) + return { object: "dashboard_import" as const, dashboard: toV2DashboardMutation(dashboard), @@ -319,20 +251,17 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards query.cursor === undefined ? undefined : decodeVersionCursor(query.cursor) if (query.cursor !== undefined && before === null) { return yield* Effect.fail( - invalidRequest( - "parameter_invalid", - "Invalid dashboard version cursor", - "cursor", - ), + V2ParameterInvalid.make("Invalid dashboard version cursor", { + param: "cursor", + }), ) } const tenant = yield* CurrentTenant.Context - const response = yield* persistence - .listVersions(tenant.orgId, params.id, { - limit: query.limit ?? LIST_LIMIT_DEFAULT, - ...(before !== undefined && before !== null ? { before } : {}), - }) - .pipe(Effect.mapError(mapReadError)) + const response = yield* persistence.listVersions(tenant.orgId, params.id, { + limit: query.limit ?? LIST_LIMIT_DEFAULT, + ...(before !== undefined && before !== null ? { before } : {}), + }) + const data = response.versions.map(toV2Version) return { object: "list" as const, @@ -348,18 +277,25 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards .handle("retrieveVersion", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const version = yield* persistence - .getVersion(tenant.orgId, params.id, params.version_id) - .pipe(Effect.mapError(mapVersionError)) + const version = yield* persistence.getVersion( + tenant.orgId, + params.id, + params.version_id, + ) + return toV2VersionDetail(version) }), ) .handle("restoreVersion", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const dashboard = yield* persistence - .restoreVersion(tenant.orgId, tenant.userId, params.id, params.version_id) - .pipe(Effect.mapError(mapRestoreError("restore_version"))) + const dashboard = yield* persistence.restoreVersion( + tenant.orgId, + tenant.userId, + params.id, + params.version_id, + ) + return toV2DashboardMutation(dashboard) }), ) @@ -383,20 +319,18 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const template = getTemplateById(params.template_id) if (!template) return yield* Effect.fail( - resourceNotFound( - "dashboard_template", - "No such dashboard template.", - "template_id", - ), + new DashboardTemplateNotFoundError({ + templateId: params.template_id, + message: "No such dashboard template.", + }), ) const built = yield* Effect.try({ try: () => template.build(payload.parameters ?? {}), catch: (error) => - invalidRequest( - "parameter_invalid", + V2ParameterInvalid.make( error instanceof Error ? error.message : "Template build failed", - "parameters", + { param: "parameters" }, ), }) @@ -414,11 +348,10 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const template = getTemplateById(params.template_id) if (!template) return yield* Effect.fail( - resourceNotFound( - "dashboard_template", - "No such dashboard template.", - "template_id", - ), + new DashboardTemplateNotFoundError({ + templateId: params.template_id, + message: "No such dashboard template.", + }), ) const provided: TemplateParameterValues = payload.parameters ?? {} @@ -427,10 +360,9 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards .map((parameter) => parameter.key) if (missing.length > 0) { return yield* Effect.fail( - invalidRequest( - "parameter_missing", + V2ParameterMissing.make( `Missing required template parameters: ${missing.join(", ")}`, - "parameters", + { param: "parameters" }, ), ) } @@ -438,10 +370,9 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const built = yield* Effect.try({ try: () => template.build(provided), catch: (error) => - invalidRequest( - "parameter_invalid", + V2ParameterInvalid.make( error instanceof Error ? error.message : "Template build failed", - "parameters", + { param: "parameters" }, ), }) @@ -453,9 +384,8 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards widgets: built.widgets, }) const tenant = yield* CurrentTenant.Context - const dashboard = yield* persistence - .create(tenant.orgId, tenant.userId, portable) - .pipe(Effect.mapError(mapWriteError("instantiate_template"))) + const dashboard = yield* persistence.create(tenant.orgId, tenant.userId, portable) + return toV2DashboardMutation(dashboard) }), ) diff --git a/apps/api/src/routes/v2/error-envelope.test.ts b/apps/api/src/routes/v2/error-envelope.test.ts new file mode 100644 index 000000000..466a9de21 --- /dev/null +++ b/apps/api/src/routes/v2/error-envelope.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest" +import { Context, Effect, Layer, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { V2SchemaErrors, V2UnexpectedErrors } from "@maple/domain/http/v2" +import { V2TransportErrorBoundaryLive } from "./error-envelope" + +const ResponseSchemaGroup = HttpApiGroup.make("responseSchema").add( + HttpApiEndpoint.get("invalidResponse", "/invalid-response", { success: Schema.String }), +) + +class ResponseSchemaApi extends HttpApi.make("ResponseSchemaApi") + .add(ResponseSchemaGroup) + .middleware(V2SchemaErrors) + .middleware(V2UnexpectedErrors) {} + +const ResponseSchemaHandlersLive = HttpApiBuilder.group(ResponseSchemaApi, "responseSchema", (handlers) => + Effect.succeed(handlers.handle("invalidResponse", () => Effect.succeed(42 as never))), +) + +describe("v2 response schema boundary", () => { + it("logs response drift and returns a sanitized 500 envelope", async () => { + const routes = HttpApiBuilder.layer(ResponseSchemaApi).pipe( + Layer.provide(ResponseSchemaHandlersLive), + Layer.provide(V2TransportErrorBoundaryLive), + ) + const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + try { + const response = await handler( + new Request("http://maple.test/invalid-response"), + Context.empty() as never, + ) + const body = await response.json() + expect(response.status).toBe(500) + expect(body).toEqual({ + error: { + _tag: "@maple/http/v2/ResponseSchemaError", + type: "api_error", + code: "internal_error", + title: "Something went wrong", + message: "An unexpected error occurred on our end.", + retryable: false, + recovery: "contact_support", + }, + }) + expect(JSON.stringify(body)).not.toContain("42") + } finally { + await dispose() + } + }) +}) diff --git a/apps/api/src/routes/v2/error-envelope.ts b/apps/api/src/routes/v2/error-envelope.ts index 94f9e88e5..99043c3d9 100644 --- a/apps/api/src/routes/v2/error-envelope.ts +++ b/apps/api/src/routes/v2/error-envelope.ts @@ -1,7 +1,13 @@ import { Effect, Layer, Schema } from "effect" import { HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpEffect, HttpServerResponse } from "effect/unstable/http" -import { apiError, invalidRequest, V2SchemaErrors, V2UnexpectedErrors } from "@maple/domain/http/v2" +import { + V2InvalidRequest, + V2ResponseSchemaFailure, + V2SchemaErrors, + V2UnexpectedFailure, + V2UnexpectedErrors, +} from "@maple/domain/http/v2" import { describeSchemaIssue } from "@/routes/schema-error-detail" class V2RouteExecutionDefect extends Schema.TaggedError()( @@ -14,6 +20,22 @@ class V2RouteExecutionDefect extends Schema.TaggedError( }, ) {} +class V2ResponseSchemaError extends Schema.TaggedError()( + "@maple/api/routes/v2/V2ResponseSchemaError", + { + group: Schema.String, + operation: Schema.String, + component: Schema.Literals(["Body", "ResponseHeaders"]), + message: Schema.String, + details: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) {} + +type V2SchemaBoundaryError = + | ReturnType + | ReturnType + /** * Request-decode failures (params/query/payload) under /v2 are rewritten into * the v2 error envelope — `{ "error": { "type": "invalid_request_error", @@ -28,20 +50,33 @@ class V2RouteExecutionDefect extends Schema.TaggedError( const V2SchemaErrorTransformLive = HttpApiMiddleware.layerSchemaErrorTransform( V2SchemaErrors, (schemaError, { endpoint, group }) => - Effect.suspend(() => { - const metadata = { - tag: `@maple/http/v2/${group.identifier}/${endpoint.identifier}/InvalidRequestError`, - } as const + Effect.suspend((): Effect.Effect => { const details = describeSchemaIssue(schemaError.cause.issue) + if (schemaError.kind === "Body" || schemaError.kind === "ResponseHeaders") { + const error = new V2ResponseSchemaError({ + group: group.identifier, + operation: endpoint.identifier, + component: schemaError.kind, + message: "V2 response failed its declared HTTP schema", + details: details.map(({ line }) => line), + cause: schemaError.cause, + }) + return Effect.logError(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + group: error.group, + operation: error.operation, + component: error.component, + details: error.details, + cause: error.cause, + }), + Effect.andThen(Effect.fail(V2ResponseSchemaFailure.make())), + ) + } const first = details[0] if (first === undefined) { return Effect.fail( - invalidRequest( - "parameter_invalid", - `Invalid request ${schemaError.kind.toLowerCase()}.`, - undefined, - metadata, - ), + V2InvalidRequest.make(`Invalid request ${schemaError.kind.toLowerCase()}.`), ) } const remaining = details.length - 1 @@ -50,12 +85,9 @@ const V2SchemaErrorTransformLive = HttpApiMiddleware.layerSchemaErrorTransform( ? "" : ` (and ${remaining} other invalid ${remaining === 1 ? "field" : "fields"})` return Effect.fail( - invalidRequest( - "parameter_invalid", - `${first.line}${suffix}`, - first.path === "" ? undefined : first.path, - metadata, - ), + V2InvalidRequest.make(`${first.line}${suffix}`, { + ...(first.path === "" ? {} : { param: first.path }), + }), ) }), ) @@ -103,18 +135,16 @@ export const V2UnexpectedErrorsLive = Layer.succeed( defectType, cause: error.cause, }), - Effect.andThen( - Effect.fail( - apiError({ - tag: `@maple/http/v2/${group.identifier}/${endpoint.identifier}/UnexpectedError`, - }), - ), - ), + Effect.andThen(Effect.fail(V2UnexpectedFailure.make())), ) }), ), ), ) -/** Both cross-cutting v2 error middlewares; kept under the established layer name for harnesses. */ -export const V2SchemaErrorsLive = Layer.merge(V2SchemaErrorTransformLive, V2UnexpectedErrorsLive) +/** + * Transport-only failures and response headers, provided once for the API. + * Expected domain errors never pass through this layer; their classes expose + * their safe public body and endpoint schemas serialize them directly. + */ +export const V2TransportErrorBoundaryLive = Layer.merge(V2SchemaErrorTransformLive, V2UnexpectedErrorsLive) diff --git a/apps/api/src/routes/v2/error-issues.http.ts b/apps/api/src/routes/v2/error-issues.http.ts index 014f83f44..f4d3f2dcb 100644 --- a/apps/api/src/routes/v2/error-issues.http.ts +++ b/apps/api/src/routes/v2/error-issues.http.ts @@ -7,23 +7,14 @@ import type { IssueListCursorFields, IssueSeverityListCursorFields, } from "@maple/domain/http" -import { - CurrentTenant, - ErrorIssueNotFoundError, - ErrorPersistenceError, - IssueListCursor, - IssueSeverityListCursor, -} from "@maple/domain/http" +import { CurrentTenant, IssueListCursor, IssueSeverityListCursor } from "@maple/domain/http" import type { V2ErrorIncident, V2ErrorIssue, V2ErrorIssueActor, V2ErrorIssueDetail, - V2InvalidRequestError, - V2NotFoundError, - V2ServiceUnavailableError, } from "@maple/domain/http/v2" -import { dependencyUnavailable, invalidRequest, MapleApiV2, resourceNotFound } from "@maple/domain/http/v2" +import { MapleApiV2, V2CursorInvalid, V2CursorSortMismatch } from "@maple/domain/http/v2" import { Effect, Schema } from "effect" import { ErrorIssueReadModelsService } from "@/services/errors/ErrorIssueReadModelsService" @@ -95,52 +86,27 @@ export const toV2IssueDetail = (detail: ErrorIssueDetailResponse): V2ErrorIssueD incidents: detail.incidents.map(toV2Incident), }) -const mapPersistenceError = ( - effect: Effect.Effect, -): Effect.Effect => - effect.pipe( - Effect.catchTag("@maple/http/errors/ErrorPersistenceError", () => - Effect.fail(dependencyUnavailable("error_issue_query_unavailable")), - ), - ) - -const mapRetrieveError = ( - effect: Effect.Effect, -): Effect.Effect => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/ErrorIssueNotFoundError": () => - Effect.fail(resourceNotFound("error_issue", "No such error issue.")), - "@maple/http/errors/ErrorPersistenceError": () => - Effect.fail(dependencyUnavailable("error_issue_retrieve_unavailable")), - }), - ) - const decodeCursor = ( cursor: string | undefined, sort: "last_seen" | "severity", ): Effect.Effect< IssueListCursorFields | IssueSeverityListCursorFields | undefined, - V2InvalidRequestError + ReturnType | ReturnType > => { if (cursor === undefined) return Effect.succeed(undefined) if (sort === "severity") { if (!cursor.startsWith("sev_")) { - return Effect.fail( - invalidRequest("cursor_sort_mismatch", "Cursor does not match the selected sort.", "cursor"), - ) + return Effect.fail(V2CursorSortMismatch.make(undefined, { param: "cursor" })) } return Schema.decodeEffect(IssueSeverityListCursor)(cursor.slice(4)).pipe( - Effect.mapError(() => invalidRequest("cursor_invalid", "Invalid pagination cursor.", "cursor")), + Effect.mapError(() => V2CursorInvalid.make(undefined, { param: "cursor" })), ) } if (cursor.startsWith("sev_")) { - return Effect.fail( - invalidRequest("cursor_sort_mismatch", "Cursor does not match the selected sort.", "cursor"), - ) + return Effect.fail(V2CursorSortMismatch.make(undefined, { param: "cursor" })) } return Schema.decodeEffect(IssueListCursor)(cursor).pipe( - Effect.mapError(() => invalidRequest("cursor_invalid", "Invalid pagination cursor.", "cursor")), + Effect.mapError(() => V2CursorInvalid.make(undefined, { param: "cursor" })), ) } @@ -153,21 +119,20 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu const tenant = yield* CurrentTenant.Context const sort = query.sort ?? "last_seen" const cursor = yield* decodeCursor(query.cursor, sort) - const response = yield* readModels - .listIssues(tenant.orgId, { - workflowState: query.workflow_state, - severity: query.severity, - kind: query.kind, - service: query.service_name, - deploymentEnv: query.deployment_environment, - startTime: query.start_time, - endTime: query.end_time, - actionable: query.actionable === "true", - sort, - limit: query.limit ?? 20, - cursor, - }) - .pipe(mapPersistenceError) + const response = yield* readModels.listIssues(tenant.orgId, { + workflowState: query.workflow_state, + severity: query.severity, + kind: query.kind, + service: query.service_name, + deploymentEnv: query.deployment_environment, + startTime: query.start_time, + endTime: query.end_time, + actionable: query.actionable === "true", + sort, + limit: query.limit ?? 20, + cursor, + }) + return { object: "list" as const, data: response.issues.map(toV2Issue), @@ -179,9 +144,8 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu .handle("serviceCounts", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const counts = yield* readModels - .countOpenIssuesByService(tenant.orgId) - .pipe(mapPersistenceError) + const counts = yield* readModels.countOpenIssuesByService(tenant.orgId) + return { object: "list" as const, data: counts.map((row) => ({ @@ -196,14 +160,13 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu .handle("retrieve", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const detail = yield* readModels - .getIssue(tenant.orgId, params.id, { - startTime: query.start_time, - endTime: query.end_time, - bucketSeconds: query.bucket_seconds, - sampleLimit: query.sample_limit, - }) - .pipe(mapRetrieveError) + const detail = yield* readModels.getIssue(tenant.orgId, params.id, { + startTime: query.start_time, + endTime: query.end_time, + bucketSeconds: query.bucket_seconds, + sampleLimit: query.sample_limit, + }) + return toV2IssueDetail(detail) }), ) diff --git a/apps/api/src/routes/v2/ingest-keys.http.ts b/apps/api/src/routes/v2/ingest-keys.http.ts index a870a27de..cddf23be2 100644 --- a/apps/api/src/routes/v2/ingest-keys.http.ts +++ b/apps/api/src/routes/v2/ingest-keys.http.ts @@ -1,14 +1,14 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { IngestKeysResponse } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" -import { dependencyUnavailable, MapleApiV2, permissionError } from "@maple/domain/http/v2" +import { MapleApiV2, V2InsufficientPermissions } from "@maple/domain/http/v2" import type { V2IngestKeys } from "@maple/domain/http/v2" import { Effect } from "effect" import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" import { requireAdmin } from "@/services/auth/auth" const adminOnly = (action: string) => () => - permissionError("insufficient_permissions", `Only org admins can ${action} ingest keys`) + V2InsufficientPermissions.make(`Only org admins can ${action} ingest keys`) const toV2IngestKeys = (keys: IngestKeysResponse): V2IngestKeys => ({ object: "ingest_keys", @@ -18,10 +18,6 @@ const toV2IngestKeys = (keys: IngestKeysResponse): V2IngestKeys => ({ private_rotated_at: keys.privateRotatedAt, }) -/** Persistence/encryption failures → retryable v2 `service_unavailable`. */ -const mapServiceError = (operation: string) => () => - dependencyUnavailable(`ingest_key_${operation}_unavailable`) - export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys", (handlers) => Effect.gen(function* () { const ingestKeys = yield* OrgIngestKeysService @@ -31,9 +27,8 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("view")) - const keys = yield* ingestKeys - .getOrCreate(tenant.orgId, tenant.userId) - .pipe(Effect.mapError(mapServiceError("retrieve"))) + const keys = yield* ingestKeys.getOrCreate(tenant.orgId, tenant.userId) + return toV2IngestKeys(keys) }), ) @@ -41,9 +36,8 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("roll")) - const keys = yield* ingestKeys - .rerollPublic(tenant.orgId, tenant.userId) - .pipe(Effect.mapError(mapServiceError("roll_public"))) + const keys = yield* ingestKeys.rerollPublic(tenant.orgId, tenant.userId) + return toV2IngestKeys(keys) }), ) @@ -51,9 +45,8 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("roll")) - const keys = yield* ingestKeys - .rerollPrivate(tenant.orgId, tenant.userId) - .pipe(Effect.mapError(mapServiceError("roll_private"))) + const keys = yield* ingestKeys.rerollPrivate(tenant.orgId, tenant.userId) + return toV2IngestKeys(keys) }), ) diff --git a/apps/api/src/routes/v2/integrations.http.test.ts b/apps/api/src/routes/v2/integrations.http.test.ts index 82c575286..ecdf7aa56 100644 --- a/apps/api/src/routes/v2/integrations.http.test.ts +++ b/apps/api/src/routes/v2/integrations.http.test.ts @@ -3,6 +3,7 @@ import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from " import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { + IntegrationsConfigurationError, IntegrationsNotConnectedError, IntegrationsPersistenceError, IntegrationsUpstreamError, @@ -34,7 +35,7 @@ import { type PlanetScaleConnectionServiceShape, } from "@/services/integrations/PlanetScaleConnectionService" import { PlanetScaleService, type PlanetScaleServiceShape } from "@/services/integrations/PlanetScaleService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -160,7 +161,7 @@ const makeHarness = ( const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(slackServiceLayer(slack)), Layer.provide(planetscaleServiceLayer(planetscale)), Layer.provide(AlertsServiceStubLayer), @@ -315,7 +316,11 @@ describe("v2 slack integration over HTTP", () => { const { status, body } = await harness.request("GET", "/v2/integrations/slack", key.secret) expect(status).toBe(503) - expect(body.error).toMatchObject({ type: "api_error", code: "slack_unavailable" }) + expect(body.error).toMatchObject({ + _tag: "@maple/http/errors/IntegrationsPersistenceError", + type: "api_error", + code: "integration_persistence_unavailable", + }) await harness.dispose() }) @@ -360,7 +365,7 @@ describe("v2 slack integration over HTTP", () => { ) expect(status).toBe(403) expect(body.error).toEqual({ - _tag: "@maple/http/v2/insufficient_permissions", + _tag: "@maple/http/v2/InsufficientPermissionsError", type: "permission_error", code: "insufficient_permissions", title: "Permission required", @@ -389,16 +394,16 @@ describe("v2 slack integration over HTTP", () => { forwardedHost: "public.example.com", }) expect(status).toBe(503) - expect(body.error).toMatchObject({ type: "api_error", code: "service_unavailable" }) + expect(body.error).toMatchObject({ type: "api_error", code: "callback_host_unavailable" }) expect(called).toBe(false) await harness.dispose() }) - it("maps install validation and persistence failures to 503", async () => { + it("distinguishes server configuration from persistence failures", async () => { const unconfigured = makeHarness({ startInstall: () => Effect.fail( - new IntegrationsValidationError({ message: "Slack integration is not configured" }), + new IntegrationsConfigurationError({ message: "Slack integration is not configured" }), ), }) const unconfiguredKey = await unconfigured.bootstrapAdminKey() @@ -408,7 +413,10 @@ describe("v2 slack integration over HTTP", () => { unconfiguredKey.secret, ) expect(validation.status).toBe(503) - expect(validation.body.error.code).toBe("slack_unavailable") + expect(validation.body.error).toMatchObject({ + _tag: "@maple/http/errors/IntegrationsConfigurationError", + code: "integration_not_configured", + }) await unconfigured.dispose() const broken = makeHarness({ @@ -453,7 +461,7 @@ describe("v2 slack integration over HTTP", () => { const { status, body } = await harness.request("DELETE", "/v2/integrations/slack", member.secret) expect(status).toBe(403) expect(body.error).toEqual({ - _tag: "@maple/http/v2/insufficient_permissions", + _tag: "@maple/http/v2/InsufficientPermissionsError", type: "permission_error", code: "insufficient_permissions", title: "Permission required", @@ -474,7 +482,7 @@ describe("v2 slack integration over HTTP", () => { const { status, body } = await harness.request("DELETE", "/v2/integrations/slack", key.secret) expect(status).toBe(503) - expect(body.error.code).toBe("slack_unavailable") + expect(body.error.code).toBe("integration_persistence_unavailable") await harness.dispose() }) @@ -546,7 +554,7 @@ describe("v2 slack integration over HTTP", () => { ) expect(status).toBe(403) expect(body.error).toEqual({ - _tag: "@maple/http/v2/insufficient_permissions", + _tag: "@maple/http/v2/InsufficientPermissionsError", type: "permission_error", code: "insufficient_permissions", title: "Permission required", @@ -581,7 +589,7 @@ describe("v2 slack integration over HTTP", () => { await harness.dispose() }) - it("maps each channels service error tag to its status: 404, 502, 503", async () => { + it("maps each channels service tag without flattening its meaning", async () => { const notConnected = makeHarness({ listChannels: () => Effect.fail( @@ -596,10 +604,11 @@ describe("v2 slack integration over HTTP", () => { "/v2/integrations/slack/channels", notConnectedKey.secret, ) - expect(missing.status).toBe(404) + expect(missing.status).toBe(409) expect(missing.body.error).toMatchObject({ - type: "not_found_error", - code: "resource_missing", + _tag: "@maple/http/errors/IntegrationsNotConnectedError", + type: "conflict_error", + code: "integration_not_connected", message: "Slack is not connected for this organization", }) await notConnected.dispose() @@ -615,7 +624,11 @@ describe("v2 slack integration over HTTP", () => { const upstreamKey = await upstream.bootstrapAdminKey() const rejected = await upstream.request("GET", "/v2/integrations/slack/channels", upstreamKey.secret) expect(rejected.status).toBe(502) - expect(rejected.body.error).toMatchObject({ type: "api_error", code: "slack_upstream_error" }) + expect(rejected.body.error).toMatchObject({ + _tag: "@maple/http/errors/IntegrationsUpstreamError", + type: "api_error", + code: "integration_upstream_error", + }) await upstream.dispose() const persistence = makeHarness({ @@ -629,7 +642,7 @@ describe("v2 slack integration over HTTP", () => { persistenceKey.secret, ) expect(unavailable.status).toBe(503) - expect(unavailable.body.error.code).toBe("slack_unavailable") + expect(unavailable.body.error.code).toBe("integration_persistence_unavailable") await persistence.dispose() }) @@ -650,7 +663,7 @@ describe("v2 slack integration over HTTP", () => { const { status, body } = await harness.request("GET", "/v2/integrations/slack", key.secret) expect(status).toBe(503) - expect(body.error.code).toBe("slack_unavailable") + expect(body.error.code).toBe("integration_persistence_unavailable") const serialized = JSON.stringify(body) expect(serialized).not.toContain("select") expect(serialized).not.toContain("slack_installations") @@ -797,7 +810,7 @@ describe("v2 planetscale integration over HTTP", () => { expect(status).toBe(400) expect(body.error).toMatchObject({ type: "invalid_request_error", - code: "planetscale_request_rejected", + code: "integration_request_invalid", }) await harness.dispose() }) @@ -845,7 +858,7 @@ describe("v2 planetscale integration over HTTP", () => { await harness.dispose() }) - it("maps a missing connection to 404 and an upstream failure to 502", async () => { + it("maps a missing connection to 409 and an upstream failure to 502", async () => { const notConnected = makeHarness( {}, { @@ -866,7 +879,11 @@ describe("v2 planetscale integration over HTTP", () => { notConnectedKey.secret, { body: { token_id: "tok_1", token_secret: "pscale_tkn_secret" } }, ) - expect(missing.status).toBe(404) + expect(missing.status).toBe(409) + expect(missing.body.error).toMatchObject({ + _tag: "@maple/http/errors/IntegrationsNotConnectedError", + code: "integration_not_connected", + }) await notConnected.dispose() const upstream = makeHarness( @@ -885,7 +902,7 @@ describe("v2 planetscale integration over HTTP", () => { upstreamKey.secret, ) expect(failed.status).toBe(502) - expect(failed.body.error.code).toBe("planetscale_upstream_error") + expect(failed.body.error.code).toBe("integration_upstream_error") await upstream.dispose() }) @@ -1020,7 +1037,7 @@ describe("v2 planetscale integration over HTTP", () => { { body: { start_time: "2026-08-05T11:00:00.000Z", end_time: "2026-08-05T12:00:00.000Z" } }, ) expect(status).toBe(503) - expect(body.error.code).toBe("planetscale_unavailable") + expect(body.error.code).toBe("integration_persistence_unavailable") const serialized = JSON.stringify(body) expect(serialized).not.toContain("select") expect(serialized).not.toContain("planetscale_events") diff --git a/apps/api/src/routes/v2/integrations.http.ts b/apps/api/src/routes/v2/integrations.http.ts index dae8a1943..9edc26236 100644 --- a/apps/api/src/routes/v2/integrations.http.ts +++ b/apps/api/src/routes/v2/integrations.http.ts @@ -1,11 +1,7 @@ import { HttpServerRequest } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import type { - IntegrationsNotConnectedError, - IntegrationsPersistenceError, - IntegrationsRevokedError, - IntegrationsUpstreamError, - IntegrationsValidationError, + IntegrationHttpError, PlanetScaleIntegrationStatus, PlanetScaleQueryInsightsResponse, } from "@maple/domain/http" @@ -28,14 +24,11 @@ import { MapleApiV2, V2PlanetScaleEventList, V2PlanetScaleQueryInsightList, - dependencyUnavailable, - invalidRequest, isoTimestamp, isoTimestampOrNull, - notFound, - permissionError, - serviceUnavailable, - upstreamError, + V2CallbackHostUnavailable, + V2InsufficientPermissions, + V2TimeRangeInvalid, } from "@maple/domain/http/v2" import { Array as Arr, Effect, Option } from "effect" import { requireAdmin } from "@/services/auth/auth" @@ -191,44 +184,12 @@ const toQueryInsightList = (response: PlanetScaleQueryInsightsResponse): V2Plane unavailable_reason: response.unavailableReason, }) -/** - * The PlanetScale service failures, mapped once. Every OAuth-backed call can - * fail the same five ways, and the mapping is uniform: a missing connection or a - * revoked grant is a 404 (the resource isn't there for this org), a rejected - * input is a 400, PlanetScale itself failing is a 502, and our own persistence - * failing is a 503. - */ -const mapPlanetScaleErrors = ( - effect: Effect.Effect< - A, - | IntegrationsNotConnectedError - | IntegrationsRevokedError - | IntegrationsValidationError - | IntegrationsUpstreamError - | IntegrationsPersistenceError, - R - >, -) => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/IntegrationsNotConnectedError": (error) => - Effect.fail(notFound(error.message)), - "@maple/http/errors/IntegrationsRevokedError": (error) => Effect.fail(notFound(error.message)), - "@maple/http/errors/IntegrationsValidationError": (error) => - Effect.fail(invalidRequest("planetscale_request_rejected", error.message)), - "@maple/http/errors/IntegrationsUpstreamError": (error) => - Effect.fail(upstreamError("planetscale_upstream_error", error.message)), - // Sanitized, not `serviceUnavailable(error.message)`: a persistence - // failure's message is the driver's, and postgres.js puts the full - // failing SQL in it. That is fine in a v1 body the dashboard reads and - // wrong in a public one. Log the cause, return a stable code. - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe(Effect.andThen(Effect.fail(dependencyUnavailable("planetscale_unavailable")))), - }), - ) +const mapIntegrationErrors = + (context: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.tapError((error) => Effect.logError(context, { tag: error._tag, message: error.message })), + ) /** * Minute-aligned window, so panel refreshes inside the cache TTL share an entry. @@ -240,7 +201,7 @@ const resolveWindow = (startTime: string, endTime: string) => { const endMs = new Date(endTime).getTime() if (endMs <= startMs) { return Effect.fail( - invalidRequest("invalid_time_range", "end_time must be after start_time", "end_time"), + V2TimeRangeInvalid.make("end_time must be after start_time", { param: "end_time" }), ) } const MINUTE = 60_000 @@ -271,21 +232,9 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla .handle("status", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const status = yield* slack.getStatus(tenant.orgId).pipe( - Effect.tapError((error) => - Effect.logError("Slack integration status failed", { - tag: error._tag, - message: error.message, - }), - ), - Effect.catchTags({ - // Sanitized like the PlanetScale group: the message is the - // driver's, and postgres.js puts the full failing SQL in it. - // The `tapError` above already logged the real cause. - "@maple/http/errors/IntegrationsPersistenceError": () => - Effect.fail(dependencyUnavailable("slack_unavailable")), - }), - ) + const status = yield* slack + .getStatus(tenant.orgId) + .pipe(mapIntegrationErrors("Slack integration status failed")) return toSlackStatus(status) }), ) @@ -293,10 +242,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", - "Only org admins can install the Slack app", - ), + V2InsufficientPermissions.make("Only org admins can install the Slack app"), ) const req = yield* HttpServerRequest.HttpServerRequest const origin = resolveRequestOrigin(req) @@ -305,28 +251,13 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla origin, }) return yield* Effect.fail( - serviceUnavailable("Slack installs are not available from this host"), + V2CallbackHostUnavailable.make("Slack installs are not available from this host"), ) } const callbackUrl = `${origin}${SLACK_CALLBACK_PATH}` - const result = yield* slack.startInstall(tenant.orgId, tenant.userId, callbackUrl).pipe( - Effect.catchTags({ - "@maple/http/errors/IntegrationsValidationError": (error) => - Effect.logError("Slack install misconfigured", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen(Effect.fail(dependencyUnavailable("slack_unavailable"))), - ), - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("Slack persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen(Effect.fail(dependencyUnavailable("slack_unavailable"))), - ), - }), - ) + const result = yield* slack + .startInstall(tenant.orgId, tenant.userId, callbackUrl) + .pipe(mapIntegrationErrors("Slack install failed")) return { object: "slack_integration.install" as const, url: result.url, @@ -337,24 +268,11 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", - "Only org admins can uninstall the Slack app", - ), - ) - yield* slack.uninstall(tenant.orgId).pipe( - Effect.tapError((error) => - Effect.logError("Slack integration uninstall failed", { - tag: error._tag, - message: error.message, - }), - ), - Effect.catchTags({ - // Sanitized; the `tapError` above logged the real cause. - "@maple/http/errors/IntegrationsPersistenceError": () => - Effect.fail(dependencyUnavailable("slack_unavailable")), - }), + V2InsufficientPermissions.make("Only org admins can uninstall the Slack app"), ) + yield* slack + .uninstall(tenant.orgId) + .pipe(mapIntegrationErrors("Slack integration uninstall failed")) return { object: "slack_integration" as const, installed: false as const, @@ -370,26 +288,11 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla // to enumerate. `status` deliberately stays ungated — the Slack // integration card renders install state for everyone. yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", - "Only org admins can list Slack channels", - ), - ) - const list = yield* slack.listChannels(tenant.orgId).pipe( - Effect.catchTags({ - "@maple/http/errors/IntegrationsNotConnectedError": (error) => - Effect.fail(notFound(error.message)), - "@maple/http/errors/IntegrationsUpstreamError": (error) => - Effect.fail(upstreamError("slack_upstream_error", error.message)), - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("Slack persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen(Effect.fail(dependencyUnavailable("slack_unavailable"))), - ), - }), + V2InsufficientPermissions.make("Only org admins can list Slack channels"), ) + const list = yield* slack + .listChannels(tenant.orgId) + .pipe(mapIntegrationErrors("Slack channel list failed")) return toChannelList(list) }), ) @@ -412,19 +315,9 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( .handle("status", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const status = yield* planetscale.getStatus(tenant.orgId).pipe( - Effect.catchTags({ - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail(dependencyUnavailable("planetscale_unavailable")), - ), - ), - }), - ) + const status = yield* planetscale + .getStatus(tenant.orgId) + .pipe(mapIntegrationErrors("PlanetScale status failed")) return toPlanetScaleStatus(status) }), ) @@ -432,10 +325,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", - "Only org admins can connect PlanetScale", - ), + V2InsufficientPermissions.make("Only org admins can connect PlanetScale"), ) const req = yield* HttpServerRequest.HttpServerRequest const origin = resolveRequestOrigin(req) @@ -452,7 +342,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( }, ) return yield* Effect.fail( - serviceUnavailable( + V2CallbackHostUnavailable.make( "PlanetScale connections are not available from this host", ), ) @@ -462,37 +352,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( callbackUrl: `${origin}${PLANETSCALE_CALLBACK_PATH}`, returnTo: payload.return_to, }) - .pipe( - Effect.catchTags({ - // Both are misconfiguration or storage failures on our side — - // there is no caller input that produces either, so neither is - // a 4xx, and neither message is the caller's business. - // `startConnect` cannot fail upstream: nothing is sent to - // PlanetScale until the browser follows the authorize URL. - "@maple/http/errors/IntegrationsValidationError": (error) => - Effect.logError("PlanetScale connect misconfigured", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail( - dependencyUnavailable("planetscale_unavailable"), - ), - ), - ), - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail( - dependencyUnavailable("planetscale_unavailable"), - ), - ), - ), - }), - ) + .pipe(mapIntegrationErrors("PlanetScale connect failed")) return { object: "planetscale_integration.connect" as const, redirect_url: result.redirectUrl, @@ -506,14 +366,13 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( // Admin-gated like `select_organization`: this drives the org picker // while the connection is pending, and both are admin-only flows. yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", + V2InsufficientPermissions.make( "Only org admins can manage the PlanetScale integration", ), ) - const organizations = yield* mapPlanetScaleErrors( - planetscaleOAuth.listOrganizations(tenant.orgId), - ) + const organizations = yield* planetscaleOAuth + .listOrganizations(tenant.orgId) + .pipe(mapIntegrationErrors("PlanetScale organization list failed")) return { object: "planetscale_integration.organization_list" as const, organizations: Arr.map(organizations, (org) => ({ @@ -527,18 +386,17 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", + V2InsufficientPermissions.make( "Only org admins can manage the PlanetScale integration", ), ) - const status = yield* mapPlanetScaleErrors( - planetscale.finalizeOrgSelection(tenant.orgId, { + const status = yield* planetscale + .finalizeOrgSelection(tenant.orgId, { organization: payload.organization, includeBranches: payload.include_branches, excludeBranches: payload.exclude_branches, - }), - ) + }) + .pipe(mapIntegrationErrors("PlanetScale organization selection failed")) return toPlanetScaleStatus(status) }), ) @@ -546,17 +404,16 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", + V2InsufficientPermissions.make( "Only org admins can manage the PlanetScale integration", ), ) - const status = yield* mapPlanetScaleErrors( - planetscale.setMetricsToken(tenant.orgId, { + const status = yield* planetscale + .setMetricsToken(tenant.orgId, { tokenId: payload.token_id, tokenSecret: payload.token_secret, - }), - ) + }) + .pipe(mapIntegrationErrors("PlanetScale metrics token update failed")) return toPlanetScaleStatus(status) }), ) @@ -564,30 +421,11 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", - "Only org admins can disconnect PlanetScale", - ), - ) - yield* planetscale.disconnect(tenant.orgId).pipe( - Effect.tapError((error) => - Effect.logError("PlanetScale disconnect failed", { - tag: error._tag, - message: error.message, - }), - ), - Effect.catchTags({ - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail(dependencyUnavailable("planetscale_unavailable")), - ), - ), - }), + V2InsufficientPermissions.make("Only org admins can disconnect PlanetScale"), ) + yield* planetscale + .disconnect(tenant.orgId) + .pipe(mapIntegrationErrors("PlanetScale disconnect failed")) return { object: "planetscale_integration" as const, connected: false as const, @@ -602,19 +440,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( const [rows, connection] = yield* Effect.all([ inventory.listDatabases(tenant.orgId), planetscale.loadConnection(tenant.orgId), - ]).pipe( - Effect.catchTags({ - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail(dependencyUnavailable("planetscale_unavailable")), - ), - ), - }), - ) + ]).pipe(mapIntegrationErrors("PlanetScale database list failed")) return { object: "planetscale_integration.database_list" as const, databases: Arr.map(rows, toPlanetScaleDatabase), @@ -629,25 +455,14 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( const tenant = yield* CurrentTenant.Context // Admin-only: the response carries the webhook HMAC secret. yield* requireAdmin(tenant.roles, () => - permissionError( - "insufficient_permissions", + V2InsufficientPermissions.make( "Only org admins can read the PlanetScale webhook configuration", ), ) const req = yield* HttpServerRequest.HttpServerRequest - const config = yield* planetscale.webhookConfig(tenant.orgId).pipe( - Effect.catchTags({ - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail(dependencyUnavailable("planetscale_unavailable")), - ), - ), - }), - ) + const config = yield* planetscale + .webhookConfig(tenant.orgId) + .pipe(mapIntegrationErrors("PlanetScale webhook config failed")) return { object: "planetscale_integration.webhook_config" as const, configured: config.configured, @@ -680,17 +495,18 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( ttlSeconds: 60, schema: V2PlanetScaleQueryInsightList, }, - mapPlanetScaleErrors( - inventory - .queryInsights(tenant.orgId, { - database: payload.database, - branch: payload.branch, - startTime: startMs, - endTime: endMs, - limit, - }) - .pipe(Effect.map(toQueryInsightList)), - ), + inventory + .queryInsights(tenant.orgId, { + database: payload.database, + branch: payload.branch, + startTime: startMs, + endTime: endMs, + limit, + }) + .pipe( + Effect.map(toQueryInsightList), + mapIntegrationErrors("PlanetScale query insights failed"), + ), ) return cached.value }), @@ -744,19 +560,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( })), next_cursor: nextCursor, })), - Effect.catchTags({ - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.logError("PlanetScale persistence failure", { - tag: error._tag, - message: error.message, - }).pipe( - Effect.andThen( - Effect.fail( - dependencyUnavailable("planetscale_unavailable"), - ), - ), - ), - }), + mapIntegrationErrors("PlanetScale event list failed"), ), ) return cached.value diff --git a/apps/api/src/routes/v2/investigations.http.ts b/apps/api/src/routes/v2/investigations.http.ts index 9b10c6ce9..d82e1560e 100644 --- a/apps/api/src/routes/v2/investigations.http.ts +++ b/apps/api/src/routes/v2/investigations.http.ts @@ -6,45 +6,27 @@ import { CurrentTenant, ErrorIncidentId, InvestigationCreateRequest, + InvestigationDataCorruptionError, InvestigationFreeformSubject, InvestigationId, InvestigationIncidentSubject, InvestigationSubjectSnapshot, - type InvestigationHttpError, TraceId, } from "@maple/domain/http" -import { - dependencyUnavailable, - investigationErrorToV2, - MapleApiV2, - paginateOffsetQuery, -} from "@maple/domain/http/v2" +import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2" import type { V2Investigation, V2InvestigationCreateParams, V2InvestigationCreateSubject, - V2InvestigationErrorFor, V2InvestigationSubject, } from "@maple/domain/http/v2" import { Effect, Match, Schema } from "effect" import { InvestigationService } from "@/services/errors/InvestigationService" -class InvestigationSubjectDecodeError extends Schema.TaggedError()( - "@maple/api/routes/v2/InvestigationSubjectDecodeError", - { - investigationId: InvestigationId, - field: Schema.String, - value: Schema.String, - incidentKind: Schema.optionalKey(Schema.String), - incidentId: Schema.optionalKey(Schema.String), - message: Schema.String, - }, -) {} - const toWireSubject = Effect.fn("HttpV2Investigations.toWireSubject")(function* ( investigationId: InvestigationId, subject: InvestigationSubject, -): Effect.fn.Return { +): Effect.fn.Return { yield* Effect.annotateCurrentSpan( subject.type === "incident" ? { @@ -67,7 +49,7 @@ const toWireSubject = Effect.fn("HttpV2Investigations.toWireSubject")(function* issue_id: subject.issueId ?? null, } const decodeFailure = () => - new InvestigationSubjectDecodeError({ + new InvestigationDataCorruptionError({ investigationId, field: "subject.incident_id", value: subject.incidentId, @@ -78,7 +60,7 @@ const toWireSubject = Effect.fn("HttpV2Investigations.toWireSubject")(function* return yield* Match.value(subject.incidentKind).pipe( Match.when("error", () => Schema.decodeEffect(ErrorIncidentId)(subject.incidentId).pipe( - Effect.catchTag("SchemaError", () => Effect.fail(decodeFailure())), + Effect.mapError(decodeFailure), Effect.map((incidentId) => ({ ...shared, incident_kind: "error" as const, @@ -88,7 +70,7 @@ const toWireSubject = Effect.fn("HttpV2Investigations.toWireSubject")(function* ), Match.when("anomaly", () => Schema.decodeEffect(AnomalyIncidentId)(subject.incidentId).pipe( - Effect.catchTag("SchemaError", () => Effect.fail(decodeFailure())), + Effect.mapError(decodeFailure), Effect.map((incidentId) => ({ ...shared, incident_kind: "anomaly" as const, @@ -98,7 +80,7 @@ const toWireSubject = Effect.fn("HttpV2Investigations.toWireSubject")(function* ), Match.when("alert", () => Schema.decodeEffect(AlertIncidentId)(subject.incidentId).pipe( - Effect.catchTag("SchemaError", () => Effect.fail(decodeFailure())), + Effect.mapError(decodeFailure), Effect.map((incidentId) => ({ ...shared, incident_kind: "alert" as const, @@ -130,19 +112,18 @@ const toInternalSnapshot = (snapshot: V2InvestigationCreateParams["snapshot"] | const toV2Investigation = Effect.fn("HttpV2Investigations.toV2Investigation")(function* ( doc: InvestigationDocument, -): Effect.fn.Return { +): Effect.fn.Return { yield* Effect.annotateCurrentSpan("investigationId", doc.id) const decodeReportTraceId = (traceId: string) => Schema.decodeEffect(TraceId)(traceId).pipe( - Effect.catchTag("SchemaError", () => - Effect.fail( - new InvestigationSubjectDecodeError({ + Effect.mapError( + () => + new InvestigationDataCorruptionError({ investigationId: doc.id, field: "report.evidence.trace_ids", value: traceId, message: "Stored investigation report contains an invalid trace identifier", }), - ), ), ) const report = @@ -205,15 +186,7 @@ const toV2Investigation = Effect.fn("HttpV2Investigations.toV2Investigation")(fu } }) -/** One domain boundary for every typed investigation failure. */ -const mapInvestigationErrors = - (operation: string) => - ( - effect: Effect.Effect, - ): Effect.Effect, R> => - effect.pipe(Effect.mapError(investigationErrorToV2(operation))) - -const mapSubjectDecodeError = (error: InvestigationSubjectDecodeError) => +const logSubjectDecodeError = (error: InvestigationDataCorruptionError) => Effect.logError(error.message).pipe( Effect.annotateLogs({ investigationId: error.investigationId, @@ -222,9 +195,11 @@ const mapSubjectDecodeError = (error: InvestigationSubjectDecodeError) => ...(error.incidentKind !== undefined ? { incidentKind: error.incidentKind } : {}), ...(error.incidentId !== undefined ? { incidentId: error.incidentId } : {}), }), - Effect.andThen(Effect.fail(dependencyUnavailable("investigation_subject_decode_failed"))), ) +const serializeInvestigation = (doc: InvestigationDocument) => + toV2Investigation(doc).pipe(Effect.tapError(logSubjectDecodeError)) + export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "investigations", (handlers) => Effect.gen(function* () { const service = yield* InvestigationService @@ -246,13 +221,8 @@ export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "invest offset, }) .pipe( - mapInvestigationErrors("list"), Effect.flatMap((response) => - Effect.forEach(response.investigations, toV2Investigation), - ), - Effect.catchTag( - "@maple/api/routes/v2/InvestigationSubjectDecodeError", - mapSubjectDecodeError, + Effect.forEach(response.investigations, serializeInvestigation), ), ), ) @@ -262,67 +232,43 @@ export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "invest .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const doc = yield* service - .getInvestigation(tenant.orgId, params.id) - .pipe(mapInvestigationErrors("retrieve")) - return yield* toV2Investigation(doc).pipe( - Effect.catchTag( - "@maple/api/routes/v2/InvestigationSubjectDecodeError", - mapSubjectDecodeError, - ), - ) + const doc = yield* service.getInvestigation(tenant.orgId, params.id) + + return yield* serializeInvestigation(doc) }), ) .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const doc = yield* service - .createAndStartInvestigation( - tenant.orgId, - tenant.userId, - new InvestigationCreateRequest({ - subject: toInternalSubject(payload.subject), - ...(payload.snapshot !== undefined - ? { snapshot: toInternalSnapshot(payload.snapshot) } - : {}), - }), - { automatic: false }, - ) - .pipe(mapInvestigationErrors("start")) - return yield* toV2Investigation(doc).pipe( - Effect.catchTag( - "@maple/api/routes/v2/InvestigationSubjectDecodeError", - mapSubjectDecodeError, - ), + const doc = yield* service.createAndStartInvestigation( + tenant.orgId, + tenant.userId, + new InvestigationCreateRequest({ + subject: toInternalSubject(payload.subject), + ...(payload.snapshot !== undefined + ? { snapshot: toInternalSnapshot(payload.snapshot) } + : {}), + }), + { automatic: false }, ) + + return yield* serializeInvestigation(doc) }), ) .handle("restart", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const doc = yield* service - .restartInvestigation(tenant.orgId, params.id) - .pipe(mapInvestigationErrors("restart")) - return yield* toV2Investigation(doc).pipe( - Effect.catchTag( - "@maple/api/routes/v2/InvestigationSubjectDecodeError", - mapSubjectDecodeError, - ), - ) + const doc = yield* service.restartInvestigation(tenant.orgId, params.id) + + return yield* serializeInvestigation(doc) }), ) .handle("updateStatus", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const doc = yield* service - .updateStatus(tenant.orgId, params.id, payload.status) - .pipe(mapInvestigationErrors("update_status")) - return yield* toV2Investigation(doc).pipe( - Effect.catchTag( - "@maple/api/routes/v2/InvestigationSubjectDecodeError", - mapSubjectDecodeError, - ), - ) + const doc = yield* service.updateStatus(tenant.orgId, params.id, payload.status) + + return yield* serializeInvestigation(doc) }), ) }), diff --git a/apps/api/src/routes/v2/organization.http.ts b/apps/api/src/routes/v2/organization.http.ts index c22dffac6..5d421ac43 100644 --- a/apps/api/src/routes/v2/organization.http.ts +++ b/apps/api/src/routes/v2/organization.http.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant } from "@maple/domain/http" -import { dependencyUnavailable, MapleApiV2, isoTimestampOrNull } from "@maple/domain/http/v2" +import { isoTimestampOrNull, MapleApiV2 } from "@maple/domain/http/v2" import { Effect } from "effect" import { OrganizationService } from "@/services/org/OrganizationService" @@ -11,13 +11,7 @@ export const HttpV2OrganizationLive = HttpApiBuilder.group(MapleApiV2, "organiza return handlers.handle("retrieve", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const org = yield* service - .retrieve(tenant.orgId) - .pipe( - Effect.catchTag("@maple/http/errors/OrganizationProviderError", () => - Effect.fail(dependencyUnavailable("organization_retrieve_unavailable")), - ), - ) + const org = yield* service.retrieve(tenant.orgId) return { id: org.id, object: "organization" as const, diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index c0d0a1982..fbec58ea6 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -56,7 +56,7 @@ import { ErrorsService } from "@/services/errors/ErrorsService" import { ErrorIssueReadModelsService } from "@/services/errors/ErrorIssueReadModelsService" import { InvestigationService } from "@/services/errors/InvestigationService" import { OrganizationService } from "@/services/org/OrganizationService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -543,7 +543,7 @@ const makeHarness = ( const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), Layer.provide(functionalStubs), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), @@ -774,10 +774,11 @@ describe("v2 investigations over HTTP", () => { const response = await harness.request("GET", `/v2/investigations/${CORRUPT_INV_ID}`, { token: key.secret, }) - expect(response.status).toBe(503) + expect(response.status).toBe(500) expect(response.body.error).toMatchObject({ + _tag: "@maple/http/investigations/InvestigationDataCorruptionError", type: "api_error", - code: "investigation_subject_decode_failed", + code: "investigation_data_corrupt", }) expect(JSON.stringify(response.body)).not.toContain("legacy-invalid-incident-id") await harness.dispose() diff --git a/apps/api/src/routes/v2/recommendations.http.ts b/apps/api/src/routes/v2/recommendations.http.ts index caf5c9b05..0182b422c 100644 --- a/apps/api/src/routes/v2/recommendations.http.ts +++ b/apps/api/src/routes/v2/recommendations.http.ts @@ -1,13 +1,8 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" -import type { - RecommendationIssue, - RecommendationIssueId, - RecommendationIssueNotFoundError, - RecommendationIssuePersistenceError, -} from "@maple/domain/http" -import { CurrentTenant } from "@maple/domain/http" -import { MapleApiV2, dependencyUnavailable, paginateArray, resourceNotFound } from "@maple/domain/http/v2" -import type { V2NotFoundError, V2Recommendation, V2ServiceUnavailableError } from "@maple/domain/http/v2" +import type { RecommendationIssue, RecommendationIssueId } from "@maple/domain/http" +import { CurrentTenant, RecommendationIssueNotFoundError } from "@maple/domain/http" +import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" +import type { V2Recommendation } from "@maple/domain/http/v2" import { Effect } from "effect" import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" @@ -26,30 +21,6 @@ const toV2Recommendation = (issue: RecommendationIssue): V2Recommendation => ({ resolved_at: issue.resolvedAt ?? null, }) -/** Service tagged errors → v2 envelope errors. */ -const mapMutationError = - (operation: string) => - ( - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/RecommendationIssueNotFoundError": () => - Effect.fail(resourceNotFound("recommendation", "No such recommendation.")), - "@maple/http/errors/RecommendationIssuePersistenceError": () => - Effect.fail(dependencyUnavailable(`recommendation_${operation}_unavailable`)), - }), - ) - -const mapPersistenceError = ( - effect: Effect.Effect, -): Effect.Effect => - effect.pipe( - Effect.catchTag("@maple/http/errors/RecommendationIssuePersistenceError", () => - Effect.fail(dependencyUnavailable("recommendation_list_unavailable")), - ), - ) - /** * v1 mutations return the full reconciled list; v2 returns the mutated object. * The issue is always present after a successful mutation — the fallback guards @@ -58,7 +29,12 @@ const mapPersistenceError = ( const pickIssue = (issues: ReadonlyArray, id: RecommendationIssueId) => { const issue = issues.find((candidate) => candidate.id === id) return issue === undefined - ? Effect.fail(resourceNotFound("recommendation", "No such recommendation.")) + ? Effect.fail( + new RecommendationIssueNotFoundError({ + message: "No such recommendation.", + id, + }), + ) : Effect.succeed(issue) } @@ -73,7 +49,8 @@ export const HttpV2InstrumentationRecommendationsLive = HttpApiBuilder.group( .handle("list", ({ query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* service.listReconciled(tenant).pipe(mapPersistenceError) + const response = yield* service.listReconciled(tenant) + const page = yield* paginateArray(response.issues.map(toV2Recommendation), query) return { object: "list" as const, ...page } }), @@ -81,9 +58,8 @@ export const HttpV2InstrumentationRecommendationsLive = HttpApiBuilder.group( .handle("dismiss", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* service - .dismiss(tenant, params.id) - .pipe(mapMutationError("dismiss")) + const response = yield* service.dismiss(tenant, params.id) + const issue = yield* pickIssue(response.issues, params.id) return toV2Recommendation(issue) }), @@ -91,9 +67,8 @@ export const HttpV2InstrumentationRecommendationsLive = HttpApiBuilder.group( .handle("reopen", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* service - .reopen(tenant, params.id) - .pipe(mapMutationError("reopen")) + const response = yield* service.reopen(tenant, params.id) + const issue = yield* pickIssue(response.issues, params.id) return toV2Recommendation(issue) }), diff --git a/apps/api/src/routes/v2/scrape-targets.http.ts b/apps/api/src/routes/v2/scrape-targets.http.ts index ab5e66324..4d5e20d95 100644 --- a/apps/api/src/routes/v2/scrape-targets.http.ts +++ b/apps/api/src/routes/v2/scrape-targets.http.ts @@ -1,26 +1,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { ScrapeTargetResponse } from "@maple/domain/http" -import { - CreateScrapeTargetRequest, - CurrentTenant, - type ScrapeTargetAuthError, - type ScrapeTargetEncryptionError, - type ScrapeTargetNotFoundError, - type ScrapeTargetPersistenceError, - type ScrapeTargetUpstreamError, - type ScrapeTargetValidationError, - UpdateScrapeTargetRequest, -} from "@maple/domain/http" -import { - MapleApiV2, - dependencyUnavailable, - invalidRequest, - paginateArray, - paginateOffsetQuery, - resourceNotFound, - timestamp, - upstreamError, -} from "@maple/domain/http/v2" +import { CreateScrapeTargetRequest, CurrentTenant, UpdateScrapeTargetRequest } from "@maple/domain/http" +import { MapleApiV2, paginateArray, paginateOffsetQuery, timestamp } from "@maple/domain/http/v2" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" import { Effect } from "effect" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" @@ -47,92 +28,6 @@ const toV2ScrapeTarget = (target: ScrapeTargetResponse): V2ScrapeTarget => ({ updated_at: target.updatedAt, }) -/** Service tagged errors → v2 envelope errors (create: no 404 on the contract). */ -const mapCommonError = - (operation: string) => - ( - effect: Effect.Effect< - A, - ScrapeTargetValidationError | ScrapeTargetPersistenceError | ScrapeTargetEncryptionError, - R - >, - ) => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/ScrapeTargetValidationError": (error) => - Effect.fail(invalidRequest("parameter_invalid", error.message)), - "@maple/http/errors/ScrapeTargetPersistenceError": () => - Effect.fail(dependencyUnavailable(`scrape_target_${operation}_unavailable`)), - "@maple/http/errors/ScrapeTargetEncryptionError": () => - Effect.fail(dependencyUnavailable(`scrape_target_${operation}_unavailable`)), - }), - ) - -/** Service tagged errors → v2 envelope errors (endpoints with a 404). */ -const mapMutationError = - (operation: string) => - ( - effect: Effect.Effect< - A, - | ScrapeTargetNotFoundError - | ScrapeTargetValidationError - | ScrapeTargetPersistenceError - | ScrapeTargetEncryptionError, - R - >, - ) => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/ScrapeTargetNotFoundError": () => - Effect.fail(resourceNotFound("scrape_target", "No such scrape target.")), - "@maple/http/errors/ScrapeTargetValidationError": (error) => - Effect.fail(invalidRequest("parameter_invalid", error.message)), - "@maple/http/errors/ScrapeTargetPersistenceError": () => - Effect.fail(dependencyUnavailable(`scrape_target_${operation}_unavailable`)), - "@maple/http/errors/ScrapeTargetEncryptionError": () => - Effect.fail(dependencyUnavailable(`scrape_target_${operation}_unavailable`)), - }), - ) - -/** Probe can additionally surface upstream/auth failures as 502s. */ -const mapProbeError = ( - effect: Effect.Effect< - A, - | ScrapeTargetNotFoundError - | ScrapeTargetPersistenceError - | ScrapeTargetEncryptionError - | ScrapeTargetAuthError - | ScrapeTargetUpstreamError, - R - >, -) => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/ScrapeTargetNotFoundError": () => - Effect.fail(resourceNotFound("scrape_target", "No such scrape target.")), - "@maple/http/errors/ScrapeTargetPersistenceError": () => - Effect.fail(dependencyUnavailable("scrape_target_probe_unavailable")), - "@maple/http/errors/ScrapeTargetEncryptionError": () => - Effect.fail(dependencyUnavailable("scrape_target_probe_unavailable")), - "@maple/http/errors/ScrapeTargetAuthError": () => - Effect.fail( - upstreamError( - "scrape_target_probe_auth_failed", - "The scrape target rejected Maple's credentials.", - ), - ), - "@maple/http/errors/ScrapeTargetUpstreamError": () => - Effect.fail( - upstreamError( - "scrape_target_probe_upstream_failed", - "The scrape target could not complete the probe.", - ), - ), - }), - ) - -const mapPersistenceError = () => dependencyUnavailable("scrape_target_list_unavailable") - export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeTargets", (handlers) => Effect.gen(function* () { const service = yield* ScrapeTargetsService @@ -141,9 +36,7 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT .handle("list", ({ query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const response = yield* service - .list(tenant.orgId) - .pipe(Effect.mapError(mapPersistenceError)) + const response = yield* service.list(tenant.orgId) const page = yield* paginateArray(response.targets.map(toV2ScrapeTarget), query) return { object: "list" as const, ...page } }), @@ -151,105 +44,96 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const target = yield* service - .get(tenant.orgId, params.id) - .pipe(mapMutationError("retrieve")) + const target = yield* service.get(tenant.orgId, params.id) + return toV2ScrapeTarget(target) }), ) .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const created = yield* service - .create( - tenant.orgId, - new CreateScrapeTargetRequest({ - name: payload.name, - ...(payload.url !== undefined ? { url: payload.url } : {}), - ...(payload.target_type !== undefined - ? { targetType: payload.target_type } - : {}), - ...(payload.organization !== undefined - ? { organization: payload.organization } - : {}), - ...(payload.include_branches !== undefined - ? { includeBranches: payload.include_branches } - : {}), - ...(payload.exclude_branches !== undefined - ? { excludeBranches: payload.exclude_branches } - : {}), - ...(payload.scrape_interval_seconds !== undefined - ? { scrapeIntervalSeconds: payload.scrape_interval_seconds } - : {}), - ...(payload.labels_json !== undefined - ? { labelsJson: payload.labels_json } - : {}), - ...(payload.auth_type !== undefined ? { authType: payload.auth_type } : {}), - ...(payload.service_name !== undefined - ? { serviceName: payload.service_name } - : {}), - ...(payload.auth_credentials !== undefined - ? { authCredentials: payload.auth_credentials } - : {}), - ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), - }), - ) - .pipe(mapCommonError("create")) + const created = yield* service.create( + tenant.orgId, + new CreateScrapeTargetRequest({ + name: payload.name, + ...(payload.url !== undefined ? { url: payload.url } : {}), + ...(payload.target_type !== undefined ? { targetType: payload.target_type } : {}), + ...(payload.organization !== undefined + ? { organization: payload.organization } + : {}), + ...(payload.include_branches !== undefined + ? { includeBranches: payload.include_branches } + : {}), + ...(payload.exclude_branches !== undefined + ? { excludeBranches: payload.exclude_branches } + : {}), + ...(payload.scrape_interval_seconds !== undefined + ? { scrapeIntervalSeconds: payload.scrape_interval_seconds } + : {}), + ...(payload.labels_json !== undefined ? { labelsJson: payload.labels_json } : {}), + ...(payload.auth_type !== undefined ? { authType: payload.auth_type } : {}), + ...(payload.service_name !== undefined + ? { serviceName: payload.service_name } + : {}), + ...(payload.auth_credentials !== undefined + ? { authCredentials: payload.auth_credentials } + : {}), + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + }), + ) + return toV2ScrapeTarget(created) }), ) .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const updated = yield* service - .update( - tenant.orgId, - params.id, - new UpdateScrapeTargetRequest({ - ...(payload.name !== undefined ? { name: payload.name } : {}), - ...(payload.url !== undefined ? { url: payload.url } : {}), - ...(payload.organization !== undefined - ? { organization: payload.organization } - : {}), - ...(payload.include_branches !== undefined - ? { includeBranches: payload.include_branches } - : {}), - ...(payload.exclude_branches !== undefined - ? { excludeBranches: payload.exclude_branches } - : {}), - ...(payload.scrape_interval_seconds !== undefined - ? { scrapeIntervalSeconds: payload.scrape_interval_seconds } - : {}), - ...(payload.labels_json !== undefined - ? { labelsJson: payload.labels_json } - : {}), - ...(payload.auth_type !== undefined ? { authType: payload.auth_type } : {}), - ...(payload.service_name !== undefined - ? { serviceName: payload.service_name } - : {}), - ...(payload.auth_credentials !== undefined - ? { authCredentials: payload.auth_credentials } - : {}), - ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), - }), - ) - .pipe(mapMutationError("update")) + const updated = yield* service.update( + tenant.orgId, + params.id, + new UpdateScrapeTargetRequest({ + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.url !== undefined ? { url: payload.url } : {}), + ...(payload.organization !== undefined + ? { organization: payload.organization } + : {}), + ...(payload.include_branches !== undefined + ? { includeBranches: payload.include_branches } + : {}), + ...(payload.exclude_branches !== undefined + ? { excludeBranches: payload.exclude_branches } + : {}), + ...(payload.scrape_interval_seconds !== undefined + ? { scrapeIntervalSeconds: payload.scrape_interval_seconds } + : {}), + ...(payload.labels_json !== undefined ? { labelsJson: payload.labels_json } : {}), + ...(payload.auth_type !== undefined ? { authType: payload.auth_type } : {}), + ...(payload.service_name !== undefined + ? { serviceName: payload.service_name } + : {}), + ...(payload.auth_credentials !== undefined + ? { authCredentials: payload.auth_credentials } + : {}), + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + }), + ) + return toV2ScrapeTarget(updated) }), ) .handle("delete", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const deleted = yield* service - .delete(tenant.orgId, params.id) - .pipe(mapMutationError("delete")) + const deleted = yield* service.delete(tenant.orgId, params.id) + return { id: deleted.id, object: "scrape_target" as const, deleted: true as const } }), ) .handle("probe", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const result = yield* service.probe(tenant.orgId, params.id).pipe(mapProbeError) + const result = yield* service.probe(tenant.orgId, params.id) + return { object: "scrape_target.probe_result" as const, success: result.success, @@ -270,7 +154,6 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT offset, }) .pipe( - mapMutationError("list_checks"), Effect.map( (rows): ReadonlyArray => rows.map((row) => ({ diff --git a/apps/api/src/routes/v2/session-replays.http.ts b/apps/api/src/routes/v2/session-replays.http.ts index 262e1032c..b147d0351 100644 --- a/apps/api/src/routes/v2/session-replays.http.ts +++ b/apps/api/src/routes/v2/session-replays.http.ts @@ -6,17 +6,13 @@ import { MAX_REPLAY_MANIFEST_CHUNKS, LIST_LIMIT_DEFAULT, MapleApiV2, - dependencyUnavailable, - invalidRequest, - paginateArray, paginateOffsetQuery, - payloadTooLarge, - resourceNotFound, timestamp, + V2ParameterInvalid, + V2SessionReplayNotFound, + V2SessionReplayRangeTooLarge, } from "@maple/domain/http/v2" import type { Timestamp } from "@maple/domain/http/v2" -import type { WarehouseError } from "@maple/domain/http" -import type { WarehouseResponseLimitError } from "@maple/query-engine/execution" import type { V2SessionReplay, V2SessionReplayChunk, @@ -30,14 +26,10 @@ import { CH, formatWarehouseDateTime } from "@maple/query-engine" import { Effect, Layer, Option, Schema } from "effect" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { ReplayBlobStore, ReplayBlobStoreLive } from "@/platform/ReplayBlobStore" -import { warehouseToV2 } from "./warehouse-error-map" const decodeSessionId = Schema.decodeSync(SessionId) const decodeTraceId = Schema.decodeSync(TraceId) -/** Warehouse errors → the proper v2 envelope (400/429/502/503 per tag). */ -const mapWarehouseError = warehouseToV2("session_replay_query") - /** * Refuse a chunk range whose payload would blow the response budget, before a * byte of it is fetched. @@ -53,35 +45,14 @@ const assertRangeFitsBudget = (rows: ReadonlyArray<{ readonly byteSize: number } const total = rows.reduce((sum, row) => sum + Number(row.byteSize), 0) return total <= MAX_REPLAY_EVENTS_RESPONSE_BYTES ? Effect.void - : Effect.fail( - payloadTooLarge( - "That part of the recording is too large to load in one request. Request a narrower chunk range.", - "to_chunk_seq", - ), - ) + : Effect.fail(V2SessionReplayRangeTooLarge.make(undefined, { param: "to_chunk_seq" })) } -/** - * Warehouse errors → the v2 envelope, plus the bounded-read refusal. - * - * `range_too_large` keeps its message verbatim across the public boundary: it - * carries no database diagnostics — only the range asked for — and unlike the - * warehouse faults it is entirely actionable, so redacting it would strip the - * one useful thing it says. - */ -const mapReplayReadError = (error: WarehouseError | WarehouseResponseLimitError) => - error._tag === "@maple/query-engine/execution/WarehouseResponseLimitError" - ? payloadTooLarge( - "That part of the recording is too large to load in one request. Request a narrower chunk range.", - "to_chunk_seq", - ) - : mapWarehouseError(error) - /** ISO-8601 → Tinybird `YYYY-MM-DD HH:mm:ss` (UTC), validated. */ const toTinybird = (value: string, param: string) => { const ms = Date.parse(value) return Number.isNaN(ms) - ? Effect.fail(invalidRequest("parameter_invalid", `Invalid ISO-8601 timestamp for ${param}.`, param)) + ? Effect.fail(V2ParameterInvalid.make(`Invalid ISO-8601 timestamp for ${param}.`, { param })) : Effect.succeed(formatWarehouseDateTime(ms)) } @@ -116,11 +87,13 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla CH.getSessionReplayQuery({ startTime: windowStart, endTime: windowEnd }), { orgId: tenant.orgId, sessionId }, ) - const replay = yield* warehouse - .compiledQueryFirst(tenant, compiled, { profile: "discovery", context: "v2RequireReplay" }) - .pipe(Effect.mapError(mapWarehouseError)) + const replay = yield* warehouse.compiledQueryFirst(tenant, compiled, { + profile: "discovery", + context: "v2RequireReplay", + }) + if (Option.isNone(replay)) { - return yield* resourceNotFound("session_replay", "No such session replay.") + return yield* Effect.fail(V2SessionReplayNotFound.make()) } }) @@ -172,7 +145,6 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla return warehouse .compiledQuery(tenant, compiled, { profile: "list", context: "v2SearchReplays" }) .pipe( - Effect.mapError(mapWarehouseError), Effect.map( (rows): ReadonlyArray => rows.map((row) => ({ @@ -235,12 +207,10 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla }), ], { concurrency: 2 }, - ).pipe(Effect.mapError(mapWarehouseError)) + ) const data = Option.getOrNull(maybeData) if (!data) { - return yield* Effect.fail( - resourceNotFound("session_replay", "No such session replay."), - ) + return yield* Effect.fail(V2SessionReplayNotFound.make()) } const activity = Option.getOrNull(maybeActivity) const replay = { @@ -287,12 +257,11 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla // `discovery` is enough — this never reads the `Events` column, and // with payloads in R2 there is nothing to hydrate here either: the // manifest is a pure index read whatever the storage backend. - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "discovery", - context: "v2GetReplayManifest", - }) - .pipe(Effect.mapError(mapWarehouseError)) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "discovery", + context: "v2GetReplayManifest", + }) + if (rows.length === 0) { yield* requireSession(tenant, params.id, windowStart, windowEnd) } @@ -388,7 +357,15 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla }, }) .pipe( - Effect.mapError(mapReplayReadError), + Effect.catchTag( + "@maple/query-engine/execution/WarehouseResponseLimitError", + () => + Effect.fail( + V2SessionReplayRangeTooLarge.make(undefined, { + param: "to_chunk_seq", + }), + ), + ), Effect.tap((rows) => rows.length === 0 && offset === 0 ? requireSession(tenant, params.id, windowStart, windowEnd) @@ -437,7 +414,6 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla context: "v2SessionTranscript", }) .pipe( - Effect.mapError(mapWarehouseError), Effect.tap((rows) => rows.length === 0 && offset === 0 ? requireSession(tenant, params.id, windowStart, windowEnd) @@ -495,7 +471,6 @@ const HttpV2SessionReplaysGroup = HttpApiBuilder.group(MapleApiV2, "sessionRepla context: "v2ReplaysForTrace", }) .pipe( - Effect.mapError(mapWarehouseError), Effect.map( (rows): ReadonlyArray => rows.map((row) => ({ diff --git a/apps/api/src/routes/v2/setup-audit.http.test.ts b/apps/api/src/routes/v2/setup-audit.http.test.ts index 2331634fc..cc0a6b19c 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -20,7 +20,7 @@ import { PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" import { SetupAuditService } from "@/services/org/SetupAuditService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -129,7 +129,7 @@ const makeHarness = (warehouse: WarehouseQueryServiceShape = warehouseStub()) => const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(AlertsServiceStubLayer), Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), diff --git a/apps/api/src/routes/v2/setup-audit.http.ts b/apps/api/src/routes/v2/setup-audit.http.ts index 322d348e8..f015c1e20 100644 --- a/apps/api/src/routes/v2/setup-audit.http.ts +++ b/apps/api/src/routes/v2/setup-audit.http.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant } from "@maple/domain/http" -import { MapleApiV2, PublicIdPrefixes, dependencyUnavailable, encodePublicId } from "@maple/domain/http/v2" +import { MapleApiV2, PublicIdPrefixes, encodePublicId } from "@maple/domain/http/v2" import type { PublicIdPrefix, V2SetupAudit, V2SetupAuditAffectedEntity } from "@maple/domain/http/v2" import type { AuditAffectedEntity, AuditCheckResult, SetupAuditReport } from "@maple/domain/setup-audit" import { Effect } from "effect" @@ -63,13 +63,7 @@ export const HttpV2InstrumentationAuditLive = HttpApiBuilder.group( const tenant = yield* CurrentTenant.Context // Only a configuration read failure reaches here — a warehouse outage degrades to // skipped checks inside the service rather than failing the request. - const report = yield* service - .run(tenant) - .pipe( - Effect.catchTag("@maple/api/services/SetupAuditError", () => - Effect.fail(dependencyUnavailable("setup_audit_unavailable")), - ), - ) + const report = yield* service.run(tenant) return toV2SetupAudit(report) }), ) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index c37e21bad..eedb2cc8f 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -16,7 +16,7 @@ import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { QueryEngineService, type QueryEngineServiceShape } from "@/services/warehouse/QueryEngineService" -import { V2SchemaErrorsLive } from "./error-envelope" +import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, AllV2GroupLayersLive, @@ -235,7 +235,7 @@ const makeHarness = ( const routes = HttpApiBuilder.layer(MapleApiV2).pipe( Layer.provide(AllV2GroupLayersLive), Layer.provide(telemetryLive), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), diff --git a/apps/api/src/routes/v2/telemetry.http.ts b/apps/api/src/routes/v2/telemetry.http.ts index 7d461fbd4..cf9fcba8b 100644 --- a/apps/api/src/routes/v2/telemetry.http.ts +++ b/apps/api/src/routes/v2/telemetry.http.ts @@ -1,12 +1,29 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" -import { CurrentTenant, MetricName, ServiceName, SpanId, TraceId } from "@maple/domain/http" +import { + CurrentTenant, + MetricName, + QueryEngineResultMismatchError, + ServiceName, + SpanId, + TraceId, +} from "@maple/domain/http" import { MapleApiV2, - dependencyUnavailable, - invalidRequest, paginateOffsetQuery, - resourceNotFound, timestamp, + V2CursorInvalid, + V2LogIdInvalid, + V2LogNotFound, + V2LogQueryInvalid, + V2MetricQueryInvalid, + V2ServiceNotFound, + V2SpanNotFound, + V2TelemetryBreakdownFilterRequired, + V2TelemetryBucketCountTooLarge, + V2TelemetryRangeTooLarge, + V2TimeRangeInvalid, + V2TraceNotFound, + V2TraceQueryInvalid, type Timestamp, type V2Log, type V2LogFilters, @@ -37,7 +54,6 @@ import { import { Effect, Encoding, Option, Result, Schema } from "effect" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" -import { queryEngineToV2, warehouseToV2 } from "./warehouse-error-map" const decodeTraceId = Schema.decodeSync(TraceId) const decodeSpanId = Schema.decodeSync(SpanId) @@ -81,15 +97,6 @@ const MAX_SEARCH_RANGE_SECONDS = MAX_LIST_RANGE_SECONDS // they can span far wider than any query-engine kind — no shared equivalent. const MAX_SUMMARY_RANGE_SECONDS = 60 * 60 * 24 * 365 -const mapWarehouseError = warehouseToV2 - -const toWarehouseDateTime = (value: string, param: string) => { - const ms = Date.parse(value) - return Number.isNaN(ms) - ? Effect.fail(invalidRequest("parameter_invalid", `Invalid ISO-8601 timestamp for ${param}.`, param)) - : Effect.succeed(formatWarehouseDateTimeMs(ms)) -} - const parseWindow = ( start: string, end: string, @@ -100,23 +107,24 @@ const parseWindow = ( const endMs = Date.parse(end) if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) { return yield* Effect.fail( - invalidRequest("time_range_invalid", "end_time must be later than start_time.", "end_time"), + V2TimeRangeInvalid.make("end_time must be later than start_time.", { + param: "end_time", + }), ) } const rangeSeconds = (endMs - startMs) / 1000 const maxSeconds = options.maxSeconds ?? MAX_QUERY_RANGE_SECONDS if (rangeSeconds > maxSeconds) { return yield* Effect.fail( - invalidRequest( - "time_range_too_large", + V2TelemetryRangeTooLarge.make( `${options.rangeLabel ?? "Telemetry queries"} support a maximum time range of ${formatRangeSeconds(maxSeconds)}.`, - "start_time", + { param: "start_time" }, ), ) } return { - startTime: yield* toWarehouseDateTime(start, "start_time"), - endTime: yield* toWarehouseDateTime(end, "end_time"), + startTime: formatWarehouseDateTimeMs(startMs), + endTime: formatWarehouseDateTimeMs(endMs), rangeSeconds, } }) @@ -200,7 +208,7 @@ const parseLogKey = (value: string) => { expandHexId(parsed[1] as string).toUpperCase(), ] as const) } catch { - return Effect.fail(invalidRequest("log_id_invalid", "Malformed log ID.", "id")) + return Effect.fail(V2LogIdInvalid.make(undefined, { param: "id" })) } } @@ -210,11 +218,11 @@ const encodeKeysetCursor = (prefix: string, parts: ReadonlyArray) => const decodeKeysetCursor = (value: string | undefined, prefix: string, length: number) => { if (value === undefined) return Effect.succeed | undefined>(undefined) if (!value.startsWith(`${prefix}_`)) { - return Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + return Effect.fail(V2CursorInvalid.make(undefined, { param: "cursor" })) } const decoded = Encoding.decodeBase64UrlString(value.slice(prefix.length + 1)) if (Result.isFailure(decoded)) { - return Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + return Effect.fail(V2CursorInvalid.make(undefined, { param: "cursor" })) } try { const parts = JSON.parse(decoded.success) as unknown @@ -222,9 +230,9 @@ const decodeKeysetCursor = (value: string | undefined, prefix: string, length: n parts.length === length && parts.every((part) => typeof part === "string") ? Effect.succeed(parts as ReadonlyArray) - : Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + : Effect.fail(V2CursorInvalid.make(undefined, { param: "cursor" })) } catch { - return Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + return Effect.fail(V2CursorInvalid.make(undefined, { param: "cursor" })) } } @@ -387,14 +395,15 @@ const metricFilters = ( groupByResourceAttributeKey, }) -const queryError = (signal: "trace" | "log" | "metric") => queryEngineToV2(`${signal}_query`) +const decodeQueryEngineRequest = (input: unknown, onInvalid: () => E) => + Schema.decodeUnknownEffect(QueryEngineExecuteRequest)(input).pipe(Effect.mapError(onInvalid)) -const decodeQueryEngineRequest = (input: unknown, signal: "trace" | "log" | "metric") => - Schema.decodeUnknownEffect(QueryEngineExecuteRequest)(input).pipe( - Effect.mapError(() => - invalidRequest(`${signal}_query_invalid`, "The aggregation request is invalid.", "aggregation"), - ), - ) +const queryResultMismatch = (expectedKind: string, actualKind: string) => + new QueryEngineResultMismatchError({ + message: `Expected ${expectedKind} query result, received ${actualKind}`, + expectedKind, + actualKind, + }) const validateTimeseriesBucket = ( startTime: string, @@ -406,10 +415,9 @@ const validateTimeseriesBucket = ( requestedBucketSeconds ?? computeBucketSeconds(Date.parse(startTime), Date.parse(endTime)) return Math.floor(rangeSeconds / bucketSeconds) + 1 > MAX_TIMESERIES_BUCKETS ? Effect.fail( - invalidRequest( - "bucket_count_too_large", + V2TelemetryBucketCountTooLarge.make( `bucket_seconds produces more than ${MAX_TIMESERIES_BUCKETS.toLocaleString("en-US")} buckets.`, - "bucket_seconds", + { param: "bucket_seconds" }, ), ) : Effect.succeed(bucketSeconds) @@ -427,10 +435,9 @@ const validateBreakdownRange = (rangeSeconds: number, filters: unknown) => { return Effect.void } return Effect.fail( - invalidRequest( - "breakdown_filter_required", + V2TelemetryBreakdownFilterRequired.make( `Breakdowns over ${formatRangeSeconds(MAX_UNFILTERED_BREAKDOWN_RANGE_SECONDS)} require at least one narrowing filter.`, - "filters", + { param: "filters" }, ), ) } @@ -463,12 +470,10 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand orgId: tenant.orgId, }, ) - return yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "list", - context: "v2GetTrace", - }) - .pipe(Effect.mapError(mapWarehouseError("trace_query"))) + return yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "v2GetTrace", + }) }) return handlers @@ -506,9 +511,11 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand }), { orgId: tenant.orgId, ...window }, ) - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { profile: "list", context: "v2TraceSearch" }) - .pipe(Effect.mapError(mapWarehouseError("trace_search"))) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "v2TraceSearch", + }) + const dataRows = rows.slice(0, limit) const last = dataRows.at(-1) const hasMore = rows.length > limit @@ -554,13 +561,12 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand filters: traceFilters(payload.filters, payload.group_by_attribute_key), }, }, - "trace", + () => V2TraceQueryInvalid.make(undefined, { param: "aggregation" }), ) - const response = yield* queryEngine - .execute(tenant, request) - .pipe(Effect.mapError(queryError("trace"))) + const response = yield* queryEngine.execute(tenant, request) + if (response.result.kind !== "timeseries") { - return yield* Effect.fail(dependencyUnavailable("trace_query_unavailable")) + return yield* Effect.fail(queryResultMismatch("timeseries", response.result.kind)) } return { object: "trace_timeseries" as const, @@ -598,13 +604,12 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand filters: traceFilters(payload.filters, payload.group_by_attribute_key), }, }, - "trace", + () => V2TraceQueryInvalid.make(undefined, { param: "aggregation" }), ) - const response = yield* queryEngine - .execute(tenant, request) - .pipe(Effect.mapError(queryError("trace"))) + const response = yield* queryEngine.execute(tenant, request) + if (response.result.kind !== "breakdown") { - return yield* Effect.fail(dependencyUnavailable("trace_query_unavailable")) + return yield* Effect.fail(queryResultMismatch("breakdown", response.result.kind)) } return { object: "trace_breakdown" as const, @@ -623,7 +628,7 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const rows = yield* hierarchy(tenant, params.trace_id) - if (rows.length === 0) return yield* resourceNotFound("trace", "No such trace.") + if (rows.length === 0) return yield* Effect.fail(V2TraceNotFound.make()) const truncated = rows.length > CH.SPAN_HIERARCHY_MAX_SPANS const spans = rows.slice(0, CH.SPAN_HIERARCHY_MAX_SPANS).map(toSpan) const startMs = Math.min(...spans.map((span) => Date.parse(span.start_time))) @@ -658,8 +663,8 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand ), { profile: "discovery", context: "v2GetSpan" }, ) - .pipe(Effect.mapError(mapWarehouseError("span_query")), Effect.map(Option.getOrNull)) - if (!detail) return yield* resourceNotFound("span", "No such span.") + .pipe(Effect.map(Option.getOrNull)) + if (!detail) return yield* Effect.fail(V2SpanNotFound.make()) return toSpan(detail) }), ) @@ -698,13 +703,12 @@ export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers }), { orgId: tenant.orgId, ...window }, ) - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "list", - context: "v2LogSearch", - settings: filters?.body_search ? LOGS_BODY_SEARCH_SETTINGS : undefined, - }) - .pipe(Effect.mapError(mapWarehouseError("log_search"))) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "v2LogSearch", + settings: filters?.body_search ? LOGS_BODY_SEARCH_SETTINGS : undefined, + }) + const dataRows = rows.slice(0, limit) const last = dataRows.at(-1) const hasMore = rows.length > limit @@ -752,13 +756,12 @@ export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers filters: logFilters(payload.filters), }, }, - "log", + () => V2LogQueryInvalid.make(undefined, { param: "aggregation" }), ) - const response = yield* queryEngine - .execute(tenant, request) - .pipe(Effect.mapError(queryError("log"))) + const response = yield* queryEngine.execute(tenant, request) + if (response.result.kind !== "timeseries") { - return yield* Effect.fail(dependencyUnavailable("log_query_unavailable")) + return yield* Effect.fail(queryResultMismatch("timeseries", response.result.kind)) } return { object: "log_timeseries" as const, @@ -792,13 +795,12 @@ export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers filters: logFilters(payload.filters), }, }, - "log", + () => V2LogQueryInvalid.make(undefined, { param: "aggregation" }), ) - const response = yield* queryEngine - .execute(tenant, request) - .pipe(Effect.mapError(queryError("log"))) + const response = yield* queryEngine.execute(tenant, request) + if (response.result.kind !== "breakdown") { - return yield* Effect.fail(dependencyUnavailable("log_query_unavailable")) + return yield* Effect.fail(queryResultMismatch("breakdown", response.result.kind)) } return { object: "log_breakdown" as const, @@ -832,8 +834,8 @@ export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers profile: "list", context: "v2GetLog", }) - .pipe(Effect.mapError(mapWarehouseError("log_query")), Effect.map(Option.getOrNull)) - if (!row) return yield* resourceNotFound("log", "No such log.") + .pipe(Effect.map(Option.getOrNull)) + if (!row) return yield* Effect.fail(V2LogNotFound.make()) return toLog(row) }), ) @@ -867,7 +869,6 @@ export const HttpV2MetricsLive = HttpApiBuilder.group(MapleApiV2, "metrics", (ha context: "v2ListMetrics", }) .pipe( - Effect.mapError(mapWarehouseError("metric_catalog")), Effect.map( (rows): ReadonlyArray => rows.map((row) => ({ @@ -919,13 +920,12 @@ export const HttpV2MetricsLive = HttpApiBuilder.group(MapleApiV2, "metrics", (ha ), }, }, - "metric", + () => V2MetricQueryInvalid.make(undefined, { param: "aggregation" }), ) - const response = yield* queryEngine - .execute(tenant, request) - .pipe(Effect.mapError(queryError("metric"))) + const response = yield* queryEngine.execute(tenant, request) + if (response.result.kind !== "timeseries") { - return yield* Effect.fail(dependencyUnavailable("metric_query_unavailable")) + return yield* Effect.fail(queryResultMismatch("timeseries", response.result.kind)) } return { object: "metric_timeseries" as const, @@ -963,13 +963,12 @@ export const HttpV2MetricsLive = HttpApiBuilder.group(MapleApiV2, "metrics", (ha ), }, }, - "metric", + () => V2MetricQueryInvalid.make(undefined, { param: "aggregation" }), ) - const response = yield* queryEngine - .execute(tenant, request) - .pipe(Effect.mapError(queryError("metric"))) + const response = yield* queryEngine.execute(tenant, request) + if (response.result.kind !== "breakdown") { - return yield* Effect.fail(dependencyUnavailable("metric_query_unavailable")) + return yield* Effect.fail(queryResultMismatch("breakdown", response.result.kind)) } return { object: "metric_breakdown" as const, @@ -1041,10 +1040,7 @@ export const HttpV2ServicesLive = HttpApiBuilder.group(MapleApiV2, "services", ( profile: "aggregation", context: "v2ServiceCatalog", }) - .pipe( - Effect.mapError(mapWarehouseError("service_query")), - Effect.map((rows) => rows.map((row) => toService(row, window.rangeSeconds))), - ) + .pipe(Effect.map((rows) => rows.map((row) => toService(row, window.rangeSeconds)))) } return handlers .handle("list", ({ query }) => @@ -1076,7 +1072,7 @@ export const HttpV2ServicesLive = HttpApiBuilder.group(MapleApiV2, "services", ( serviceName: params.name, limit: 1, }) - if (!rows[0]) return yield* resourceNotFound("service", "No such service.") + if (!rows[0]) return yield* Effect.fail(V2ServiceNotFound.make()) return rows[0] }), ) @@ -1132,12 +1128,11 @@ export const HttpV2ServiceMapLive = HttpApiBuilder.group(MapleApiV2, "serviceMap { deploymentEnv: query.deployment_environment }, { orgId: tenant.orgId, ...window }, ) - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "aggregation", - context: "v2ServiceMap", - }) - .pipe(Effect.mapError(mapWarehouseError("service_map_query"))) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "aggregation", + context: "v2ServiceMap", + }) + return { object: "service_map" as const, start_time: timestamp(query.start_time), diff --git a/apps/api/src/routes/v2/warehouse-error-map.test.ts b/apps/api/src/routes/v2/warehouse-error-map.test.ts deleted file mode 100644 index b25faf2e7..000000000 --- a/apps/api/src/routes/v2/warehouse-error-map.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { - WAREHOUSE_ERROR_TAGS, - WarehouseMalformedQueryError, - WarehouseQuotaExceededError, - WarehouseSchemaDriftError, - WarehouseUpstreamError, - WarehouseValidationError, - QueryEngineExecutionError, - QueryEngineTimeoutError, - QueryEngineValidationError, - type WarehouseError, -} from "@maple/domain/http" -import { - V2InvalidRequestError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "@maple/domain/http/v2" -import { queryEngineToV2, warehouseToV2 } from "./warehouse-error-map" - -const map = warehouseToV2("trace_search") - -describe("warehouseToV2", () => { - it("maps a validation failure to a 400, not a 503", () => { - const mapped = map( - new WarehouseValidationError({ pipeName: "sqlQuery", message: "start_time is after end_time" }), - ) - expect(mapped).toBeInstanceOf(V2InvalidRequestError) - expect(mapped.error.message).toContain("start_time") - expect(mapped.error._tag).toBe("@maple/http/errors/WarehouseValidationError") - expect(mapped.error.retryable).toBe(false) - }) - - it("maps a quota breach to a 429, not a 503", () => { - const mapped = map( - new WarehouseQuotaExceededError({ - pipeName: "listTraces", - message: "TIMEOUT_EXCEEDED", - setting: "max_execution_time", - }), - ) - expect(mapped).toBeInstanceOf(V2RateLimitError) - expect(mapped.error._tag).toBe("@maple/http/errors/WarehouseQuotaExceededError") - expect(mapped.error.retryable).toBe(false) - expect(mapped.error.recovery).toBe("fix_request") - }) - - it("keeps a genuine outage a 503 with the operation code", () => { - const mapped = map( - new WarehouseUpstreamError({ pipeName: "listTraces", message: "521 error", upstreamStatus: 521 }), - ) - expect(mapped).toBeInstanceOf(V2ServiceUnavailableError) - expect(mapped.error.code).toBe("trace_search_unavailable") - expect(mapped.error._tag).toBe("@maple/http/errors/WarehouseUpstreamError") - expect(mapped.error.retryable).toBe(true) - }) - - it("maps a Maple SQL bug to a 502 under its own code", () => { - const mapped = map( - new WarehouseMalformedQueryError({ pipeName: "traces_timeseries", message: "NO_COMMON_TYPE" }), - ) - expect(mapped).toBeInstanceOf(V2UpstreamError) - expect(mapped.error.code).toBe("warehouse_malformed_query") - expect(mapped.error.message).toContain("our fault") - }) - - it("carries the schema-drift remediation instead of a fixed string", () => { - const mapped = map( - new WarehouseSchemaDriftError({ - pipeName: "service_overview", - message: "Unknown column SampleRate", - }), - ) - expect(mapped).toBeInstanceOf(V2UpstreamError) - expect(mapped.error.code).toBe("warehouse_schema_drift") - expect(mapped.error.message).toContain("schema apply") - }) - - it("handles every warehouse tag", () => { - for (const tag of WAREHOUSE_ERROR_TAGS) { - // Structural stand-in per tag; Match dispatches on _tag alone. - const error = { - _tag: tag, - pipeName: "p", - message: "boom", - setting: "max_execution_time", - } as unknown as WarehouseError - expect(() => map(error), tag).not.toThrow() - } - }) -}) - -describe("queryEngineToV2", () => { - const mapQueryError = queryEngineToV2("trace_query") - - it("keeps query validation as a 400", () => { - const mapped = mapQueryError( - new QueryEngineValidationError({ message: "invalid aggregation", details: [] }), - ) - expect(mapped).toBeInstanceOf(V2InvalidRequestError) - expect(mapped.error.code).toBe("trace_query_invalid") - }) - - it("maps query execution faults to 502", () => { - const mapped = mapQueryError(new QueryEngineExecutionError({ message: "execution failed" })) - expect(mapped).toBeInstanceOf(V2UpstreamError) - expect(mapped.error.code).toBe("trace_query_failed") - }) - - it("keeps timeouts within the v2 503 contract", () => { - const mapped = mapQueryError(new QueryEngineTimeoutError({ message: "timed out" })) - expect(mapped).toBeInstanceOf(V2ServiceUnavailableError) - expect(mapped.error.code).toBe("trace_query_unavailable") - }) - - it("delegates warehouse quota and outage semantics", () => { - expect( - mapQueryError( - new WarehouseQuotaExceededError({ - pipeName: "traces_timeseries", - message: "TIMEOUT_EXCEEDED", - setting: "max_execution_time", - }), - ), - ).toBeInstanceOf(V2RateLimitError) - expect( - mapQueryError( - new WarehouseUpstreamError({ pipeName: "traces_timeseries", message: "unavailable" }), - ), - ).toBeInstanceOf(V2ServiceUnavailableError) - }) -}) diff --git a/apps/api/src/routes/v2/warehouse-error-map.ts b/apps/api/src/routes/v2/warehouse-error-map.ts deleted file mode 100644 index 059c8c5bb..000000000 --- a/apps/api/src/routes/v2/warehouse-error-map.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Match } from "effect" -import { - httpErrorMetadata, - presentWarehouseErrorPublic, - warehouseErrorMeta, - type QueryEngineExecutionError, - type QueryEngineTimeoutError, - type QueryEngineValidationError, - type WarehouseError, -} from "@maple/domain/http" -import { dependencyUnavailable, invalidRequest, rateLimitError, upstreamError } from "@maple/domain/http/v2" -import type { - V2InvalidRequestError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "@maple/domain/http/v2" - -export type V2WarehouseError = - | V2InvalidRequestError - | V2RateLimitError - | V2ServiceUnavailableError - | V2UpstreamError - -export type QueryEngineRouteError = - | QueryEngineValidationError - | QueryEngineExecutionError - | QueryEngineTimeoutError - | WarehouseError - -/** - * Exhaustive warehouse → v2 envelope translation for public telemetry-style - * endpoints. Replaces the blanket `() => dependencyUnavailable(...)` lambdas - * that mapped all nine warehouse tags — including 400 validation failures and - * 429 quota breaches — to one fixed 503, so an API user sending a bad time - * range was told the service was down. - * - * Statuses follow `httpApiStatus` on the error classes: 400 → invalid_request, - * 429 → rate_limited, 503 (retryable outage) → `${operation}_unavailable`, - * everything else → 502 with the tag's stable meta code and shared copy. - * - * Messages use the REDACTED presentation: raw ClickHouse diagnostics never - * cross the public API boundary (pinned by telemetry.http.test.ts). - */ -export const warehouseToV2 = (operation: string): ((error: WarehouseError) => V2WarehouseError) => { - const fault = (error: WarehouseError): V2WarehouseError => - upstreamError( - warehouseErrorMeta[error._tag].code, - presentWarehouseErrorPublic(error).description, - httpErrorMetadata(error._tag, warehouseErrorMeta[error._tag]), - ) - return Match.type().pipe( - Match.tagsExhaustive({ - "@maple/http/errors/WarehouseValidationError": (error) => - invalidRequest( - "parameter_invalid", - error.message, - undefined, - httpErrorMetadata(error._tag, warehouseErrorMeta[error._tag]), - ), - "@maple/http/errors/WarehouseQuotaExceededError": (error) => - rateLimitError( - "rate_limited", - presentWarehouseErrorPublic(error).description, - httpErrorMetadata(error._tag, warehouseErrorMeta[error._tag]), - ), - "@maple/http/errors/WarehouseUpstreamError": (error) => - dependencyUnavailable( - `${operation}_unavailable`, - httpErrorMetadata(error._tag, warehouseErrorMeta[error._tag]), - ), - "@maple/http/errors/WarehouseQueryError": fault, - "@maple/http/errors/WarehouseAuthError": fault, - "@maple/http/errors/WarehouseConfigError": fault, - "@maple/http/errors/WarehouseClientError": fault, - "@maple/http/errors/WarehouseSchemaDriftError": fault, - "@maple/http/errors/WarehouseMalformedQueryError": fault, - }), - ) -} - -/** - * Query-engine aggregation endpoints add validation, execution, and timeout - * failures around the warehouse union. Keep that distinction at the public - * boundary instead of inferring it from a tag substring and flattening every - * other failure to 503. - */ -export const queryEngineToV2 = (operation: string): ((error: QueryEngineRouteError) => V2WarehouseError) => { - const warehouse = warehouseToV2(operation) - return Match.type().pipe( - Match.tagsExhaustive({ - "@maple/http/errors/QueryEngineValidationError": (error) => - invalidRequest(`${operation}_invalid`, "The aggregation request is invalid.", "aggregation", { - tag: error._tag, - title: "Invalid query", - }), - "@maple/http/errors/QueryEngineExecutionError": (error) => - upstreamError(`${operation}_failed`, "The aggregation query could not be completed.", { - tag: error._tag, - title: "Query failed", - recovery: "contact_support", - }), - "@maple/http/errors/QueryEngineTimeoutError": (error) => - dependencyUnavailable(`${operation}_unavailable`, { - tag: error._tag, - title: "Query timed out", - }), - "@maple/http/errors/WarehouseValidationError": warehouse, - "@maple/http/errors/WarehouseQuotaExceededError": warehouse, - "@maple/http/errors/WarehouseUpstreamError": warehouse, - "@maple/http/errors/WarehouseQueryError": warehouse, - "@maple/http/errors/WarehouseAuthError": warehouse, - "@maple/http/errors/WarehouseConfigError": warehouse, - "@maple/http/errors/WarehouseClientError": warehouse, - "@maple/http/errors/WarehouseSchemaDriftError": warehouse, - "@maple/http/errors/WarehouseMalformedQueryError": warehouse, - }), - ) -} diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index b3218b894..0a4d2f486 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -13,7 +13,8 @@ import { HttpAuthLive, HttpAuthPublicLive } from "@/routes/v1/auth.http" import { HttpBillingLive, HttpBillingPublicLive } from "@/routes/v1/billing.http" import { ChatSessionsRouter } from "@/routes/v1/chat-sessions.http" import { HttpChatLive } from "@/routes/v1/chat.http" -import { HttpDashboardSchemaErrorsLive, HttpDashboardsLive } from "@/routes/v1/dashboards.http" +import { HttpDashboardsLive } from "@/routes/v1/dashboards.http" +import { V1ErrorBoundaryLive } from "@/routes/v1/error-boundary" import { HttpDemoLive } from "@/routes/v1/demo.http" import { HttpDigestLive } from "@/routes/v1/digest.http" import { HttpErrorsLive } from "@/routes/v1/errors.http" @@ -44,7 +45,7 @@ import { HttpV2AnomaliesLive } from "@/routes/v2/anomalies.http" import { HttpV2ApiKeysLive } from "@/routes/v2/api-keys.http" import { HttpV2AttributeMappingsLive } from "@/routes/v2/attribute-mappings.http" import { HttpV2DashboardsLive } from "@/routes/v2/dashboards.http" -import { V2SchemaErrorsLive } from "@/routes/v2/error-envelope" +import { V2TransportErrorBoundaryLive } from "@/routes/v2/error-envelope" import { HttpV2ErrorIssuesLive } from "@/routes/v2/error-issues.http" import { HttpV2IngestKeysLive } from "@/routes/v2/ingest-keys.http" import { HttpV2PlanetScaleIntegrationsLive, HttpV2SlackIntegrationsLive } from "@/routes/v2/integrations.http" @@ -90,7 +91,6 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( Layer.provide(Layer.mergeAll(HttpBillingLive, HttpBillingPublicLive)), Layer.provide(HttpErrorsLive), Layer.provide(HttpDashboardsLive), - Layer.provide(HttpDashboardSchemaErrorsLive), Layer.provide(HttpDemoLive), Layer.provide(HttpDigestLive), Layer.provide(HttpIngestAttributeMappingsLive), @@ -109,6 +109,7 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( HttpWarehouseLive, ), ), + Layer.provide(V1ErrorBoundaryLive), ) const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( @@ -139,7 +140,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2ServiceMapLive, ), ), - Layer.provide(V2SchemaErrorsLive), + Layer.provide(V2TransportErrorBoundaryLive), ) export const AllRoutes = Layer.mergeAll( diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index f8bddf26c..a2d8f4bf3 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -462,11 +462,9 @@ export class AlertsService extends Context.Service( effect: Effect.Effect< A, diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 1a0424ea6..10638b29f 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -2,12 +2,11 @@ import { HttpServerRequest } from "effect/unstable/http" import { CurrentTenant, RoleName } from "@maple/domain/http" import { AuthorizationV2, - authenticationError, - dependencyUnavailable, - permissionError, - rateLimited, requiredScopeForRequest, scopeAllows, + V2InsufficientScope, + V2InvalidCredentials, + V2RateLimited, } from "@maple/domain/http/v2" import { Effect, Layer, Option, Schema } from "effect" import { ApiKeysService } from "@/services/org/ApiKeysService" @@ -57,22 +56,13 @@ export const ApiAuthorizationV2Layer = Layer.effect( const request = yield* HttpServerRequest.HttpServerRequest const token = getBearerToken(request.headers) - const apiKeyResolved = yield* apiKeys - .resolveByBearer(token) - .pipe( - Effect.catchTag("@maple/http/errors/ApiKeyLookupPersistenceError", () => - Effect.fail(dependencyUnavailable("api_key_lookup_unavailable")), - ), - ) + const apiKeyResolved = yield* apiKeys.resolveByBearer(token) if (Option.isSome(apiKeyResolved)) { const resolved = apiKeyResolved.value if (resolved.kind !== "standard") { return yield* Effect.fail( - authenticationError( - "invalid_credentials", - "This API key is only valid for the MCP server.", - ), + V2InvalidCredentials.make("This API key is only valid for the MCP server."), ) } @@ -93,15 +83,16 @@ export const ApiAuthorizationV2Layer = Layer.effect( if (rateLimitOutcome === "limited") { return yield* Effect.fail( - rateLimited({ retryAfterSeconds: API_V2_RATE_LIMIT_PERIOD_SECONDS }), + V2RateLimited.make(undefined, { + retryAfterSeconds: API_V2_RATE_LIMIT_PERIOD_SECONDS, + }), ) } const required = requiredScopeForRequest(request.method, requestPath(request.url)) if (required !== null && !scopeAllows(resolved.scopes, required)) { return yield* Effect.fail( - permissionError( - "insufficient_scope", + V2InsufficientScope.make( `This API key does not have the "${required.family}:${required.access}" scope required for this request.`, ), ) @@ -118,9 +109,7 @@ export const ApiAuthorizationV2Layer = Layer.effect( } const tenant = yield* resolveTenant(request.headers).pipe( - Effect.mapError(() => - authenticationError("invalid_credentials", "Invalid or missing credentials."), - ), + Effect.mapError(() => V2InvalidCredentials.make()), ) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) return yield* Effect.provideService( diff --git a/apps/api/src/services/auth/PlanetScaleOAuthService.test.ts b/apps/api/src/services/auth/PlanetScaleOAuthService.test.ts index b0b9b3d06..a8574827d 100644 --- a/apps/api/src/services/auth/PlanetScaleOAuthService.test.ts +++ b/apps/api/src/services/auth/PlanetScaleOAuthService.test.ts @@ -175,7 +175,7 @@ describe("PlanetScaleOAuthService", () => { const error = yield* service .startConnect(asOrgId("org_a"), asUserId("user_a"), { callbackUrl: CALLBACK_URL }) .pipe(Effect.flip) - assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsValidationError") + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsConfigurationError") }).pipe(Effect.provide(makeLayer(testDb))) }) @@ -186,8 +186,8 @@ describe("PlanetScaleOAuthService", () => { const error = yield* service .startConnect(asOrgId("org_a"), asUserId("user_a"), { callbackUrl: CALLBACK_URL }) .pipe(Effect.flip) - assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsValidationError") - if (error._tag === "@maple/http/errors/IntegrationsValidationError") { + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsConfigurationError") + if (error._tag === "@maple/http/errors/IntegrationsConfigurationError") { assert.include(error.message, "PLANETSCALE_OAUTH_CLIENT_SECRET") } }).pipe(Effect.provide(makeLayer(testDb, { PLANETSCALE_OAUTH_CLIENT_ID: "ps-client-id" }))) diff --git a/apps/api/src/services/auth/PlanetScaleOAuthService.ts b/apps/api/src/services/auth/PlanetScaleOAuthService.ts index a3f052994..cfc3de770 100644 --- a/apps/api/src/services/auth/PlanetScaleOAuthService.ts +++ b/apps/api/src/services/auth/PlanetScaleOAuthService.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto" import { + IntegrationsConfigurationError, IntegrationsNotConnectedError, IntegrationsPersistenceError, IntegrationsRevokedError, @@ -58,7 +59,7 @@ const resolveConfig = Effect.fn("PlanetScaleOAuthService.resolveConfig")(functio const clientId = yield* Option.match(env.PLANETSCALE_OAUTH_CLIENT_ID, { onNone: () => Effect.fail( - new IntegrationsValidationError({ + new IntegrationsConfigurationError({ message: "PLANETSCALE_OAUTH_CLIENT_ID is required to use the PlanetScale integration", }), ), @@ -67,7 +68,7 @@ const resolveConfig = Effect.fn("PlanetScaleOAuthService.resolveConfig")(functio const clientSecret = yield* Option.match(env.PLANETSCALE_OAUTH_CLIENT_SECRET, { onNone: () => Effect.fail( - new IntegrationsValidationError({ + new IntegrationsConfigurationError({ message: "PLANETSCALE_OAUTH_CLIENT_SECRET is required to use the PlanetScale integration", }), ), @@ -122,7 +123,7 @@ export interface PlanetScaleOAuthServiceShape { options: { readonly callbackUrl: string; readonly returnTo?: string }, ) => Effect.Effect< { readonly redirectUrl: string; readonly state: string }, - IntegrationsValidationError | IntegrationsPersistenceError + IntegrationsConfigurationError | IntegrationsPersistenceError > /** * Exchange the callback code and persist the grant. Does NOT bind a @@ -141,6 +142,7 @@ export interface PlanetScaleOAuthServiceShape { readonly organizations: ReadonlyArray }, | IntegrationsValidationError + | IntegrationsConfigurationError | IntegrationsRevokedError | IntegrationsUpstreamError | IntegrationsPersistenceError @@ -154,6 +156,7 @@ export interface PlanetScaleOAuthServiceShape { | IntegrationsUpstreamError | IntegrationsPersistenceError | IntegrationsValidationError + | IntegrationsConfigurationError > /** Organizations the stored grant can access — org-picker material. */ readonly listOrganizations: ( @@ -165,6 +168,7 @@ export interface PlanetScaleOAuthServiceShape { | IntegrationsUpstreamError | IntegrationsPersistenceError | IntegrationsValidationError + | IntegrationsConfigurationError > /** Whether a grant is stored for the org (drives pendingOrgSelection). */ readonly hasConnection: (orgId: OrgId) => Effect.Effect diff --git a/apps/api/src/services/auth/scrape-auth.ts b/apps/api/src/services/auth/scrape-auth.ts index b7e048897..811769212 100644 --- a/apps/api/src/services/auth/scrape-auth.ts +++ b/apps/api/src/services/auth/scrape-auth.ts @@ -2,6 +2,7 @@ import { ScrapeTargetAuthError, ScrapeTargetEncryptionError, ScrapeTargetPersistenceError, + type IntegrationsConfigurationError, type IntegrationsNotConnectedError, type IntegrationsPersistenceError, type IntegrationsRevokedError, @@ -51,6 +52,8 @@ const toEncryptionError = (message: string) => new ScrapeTargetEncryptionError({ * error dashboards. */ export const catchOAuthTokenFailure = { + "@maple/http/errors/IntegrationsConfigurationError": (error: IntegrationsConfigurationError) => + Effect.fail(new ScrapeTargetAuthError({ reason: "config", message: error.message })), "@maple/http/errors/IntegrationsNotConnectedError": (error: IntegrationsNotConnectedError) => Effect.fail(new ScrapeTargetAuthError({ reason: "not_connected", message: error.message })), "@maple/http/errors/IntegrationsRevokedError": (error: IntegrationsRevokedError) => diff --git a/apps/api/src/services/integrations/PlanetScaleConnectionService.ts b/apps/api/src/services/integrations/PlanetScaleConnectionService.ts index 0ae4a4216..d5c1470fc 100644 --- a/apps/api/src/services/integrations/PlanetScaleConnectionService.ts +++ b/apps/api/src/services/integrations/PlanetScaleConnectionService.ts @@ -1,5 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto" import { + IntegrationsConfigurationError, IntegrationsNotConnectedError, IntegrationsPersistenceError, IntegrationsRevokedError, @@ -75,6 +76,7 @@ export interface PlanetScaleConnectionServiceShape { | IntegrationsNotConnectedError | IntegrationsRevokedError | IntegrationsValidationError + | IntegrationsConfigurationError | IntegrationsUpstreamError | IntegrationsPersistenceError > diff --git a/apps/api/src/services/integrations/PlanetScaleService.ts b/apps/api/src/services/integrations/PlanetScaleService.ts index 1489e9d79..2f5281b35 100644 --- a/apps/api/src/services/integrations/PlanetScaleService.ts +++ b/apps/api/src/services/integrations/PlanetScaleService.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import { + IntegrationsConfigurationError, IntegrationsNotConnectedError, IntegrationsPersistenceError, IntegrationsRevokedError, @@ -123,6 +124,7 @@ export interface PlanetScaleServiceShape { options: PlanetScaleQueryInsightsOptions, ) => Effect.Effect< PlanetScaleQueryInsightsResponse, + | IntegrationsConfigurationError | IntegrationsNotConnectedError | IntegrationsRevokedError | IntegrationsValidationError diff --git a/apps/api/src/services/integrations/SlackIntegrationService.test.ts b/apps/api/src/services/integrations/SlackIntegrationService.test.ts index 382bb007b..990fb4fdf 100644 --- a/apps/api/src/services/integrations/SlackIntegrationService.test.ts +++ b/apps/api/src/services/integrations/SlackIntegrationService.test.ts @@ -320,7 +320,7 @@ describe("SlackIntegrationService", () => { const error = yield* Effect.flip( slack.startInstall(asOrgId("org_a"), asUserId("user_a"), "https://cb"), ) - assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsValidationError") + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsConfigurationError") }).pipe(Effect.provide(makeLayer(testDb, false))) }) diff --git a/apps/api/src/services/integrations/SlackIntegrationService.ts b/apps/api/src/services/integrations/SlackIntegrationService.ts index 04a0b692b..80fd5ddb8 100644 --- a/apps/api/src/services/integrations/SlackIntegrationService.ts +++ b/apps/api/src/services/integrations/SlackIntegrationService.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID } from "node:crypto" import { ApiKeyId, + IntegrationsConfigurationError, IntegrationsForbiddenError, IntegrationsNotConnectedError, IntegrationsPersistenceError, @@ -297,13 +298,17 @@ export interface SlackIntegrationServiceShape { orgId: OrgId, userId: UserId, callbackUrl: string, - ) => Effect.Effect<{ readonly url: string }, IntegrationsValidationError | IntegrationsPersistenceError> + ) => Effect.Effect< + { readonly url: string }, + IntegrationsConfigurationError | IntegrationsPersistenceError + > readonly completeInstall: ( code: string, state: string, ) => Effect.Effect< { readonly orgId: OrgId; readonly teamName: string | null; readonly updated: boolean }, | IntegrationsValidationError + | IntegrationsConfigurationError | IntegrationsForbiddenError | IntegrationsUpstreamError | IntegrationsPersistenceError @@ -354,7 +359,7 @@ export interface SlackIntegrationServiceShape { // class's base expression circular. const make: Effect.Effect< SlackIntegrationServiceShape, - IntegrationsValidationError, + IntegrationsConfigurationError, Database | Env | ApiKeysService | OAuthStateRepository | HttpClient.HttpClient > = Effect.gen(function* () { const database = yield* Database @@ -366,7 +371,7 @@ const make: Effect.Effect< const encryptionKey = yield* parseBase64Aes256GcmKey( Redacted.value(env.MAPLE_INGEST_KEY_ENCRYPTION_KEY), (message) => - new IntegrationsValidationError({ + new IntegrationsConfigurationError({ message: message === "Expected a non-empty base64 encryption key" ? "MAPLE_INGEST_KEY_ENCRYPTION_KEY is required" @@ -442,7 +447,7 @@ const make: Effect.Effect< }) if (!clientId || !clientSecret) { return yield* Effect.fail( - new IntegrationsValidationError({ + new IntegrationsConfigurationError({ message: "Slack integration is not configured (SLACK_CLIENT_ID / SLACK_CLIENT_SECRET)", }), ) diff --git a/apps/api/src/services/org/SetupAuditService.ts b/apps/api/src/services/org/SetupAuditService.ts index eb0e92627..eebf05dd1 100644 --- a/apps/api/src/services/org/SetupAuditService.ts +++ b/apps/api/src/services/org/SetupAuditService.ts @@ -19,6 +19,7 @@ import { clickHouseSchemaVersion } from "@maple/domain/clickhouse" import type { OrgId } from "@maple/domain/primitives" import { type ConfigAuditInputs, + SetupAuditUnavailableError, type SetupAuditReport, type TraceCompletenessInputs, type WarehouseAuditInputs, @@ -26,7 +27,7 @@ import { } from "@maple/domain/setup-audit" import { CH, formatWarehouseDateTime } from "@maple/query-engine" import { and, eq, sql } from "drizzle-orm" -import { Clock, Context, Effect, Layer, Schema } from "effect" +import { Clock, Context, Effect, Layer } from "effect" import type { TenantContext } from "@/services/auth/AuthService" import { Database, type DatabaseError } from "@/platform/DatabaseLive" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" @@ -44,14 +45,9 @@ const TRACE_WINDOW_MINUTES = 60 const TRACE_WINDOW_LAG_MINUTES = 15 const TRACE_PARENT_LOOKBACK_MINUTES = 60 -export class SetupAuditError extends Schema.TaggedError()( - "@maple/api/services/SetupAuditError", - { message: Schema.String }, -) {} - export interface SetupAuditServiceShape { /** Runs every check against a fresh snapshot of config + telemetry. */ - readonly run: (tenant: TenantContext) => Effect.Effect + readonly run: (tenant: TenantContext) => Effect.Effect } const msOrNull = (value: Date | null | undefined): number | null => value?.getTime() ?? null @@ -64,14 +60,21 @@ const make: Effect.Effect( operation: string, effect: Effect.Effect, - ): Effect.Effect => + ): Effect.Effect => effect.pipe( Effect.tapCause((cause) => Effect.logError("Setup audit database read failed").pipe( Effect.annotateLogs({ operation, cause }), ), ), - Effect.mapError((error) => new SetupAuditError({ message: error.message })), + Effect.mapError( + (error) => + new SetupAuditUnavailableError({ + message: "Setup audit configuration could not be read", + operation, + cause: error, + }), + ), ) /** diff --git a/apps/web/src/api/warehouse/effect-utils.test.ts b/apps/web/src/api/warehouse/effect-utils.test.ts index 0f4ae26a8..fbdda8e61 100644 --- a/apps/web/src/api/warehouse/effect-utils.test.ts +++ b/apps/web/src/api/warehouse/effect-utils.test.ts @@ -1,23 +1,30 @@ import { describe, expect, it } from "vitest" +import { WarehouseQuotaExceededError } from "@maple/domain/http" import { WarehouseDecodeError, WarehouseQueryError, normalizeWarehouseError } from "./effect-utils" describe("normalizeWarehouseError", () => { it("preserves a v2 error envelope", () => { + const quota = new WarehouseQuotaExceededError({ + message: "internal", + pipeName: "getReplayEvents", + setting: "max_execution_time", + }) const error = { - error: { - type: "invalid_request_error", - code: "range_too_large", - message: "Narrow the chunk range.", - }, + error: quota.error, } expect(normalizeWarehouseError("getReplayEvents", error)).toBe(error) }) - it("preserves tagged backend and local warehouse errors", () => { - const backend = { _tag: "@maple/http/errors/WarehouseQuotaExceededError", message: "quota" } + it("preserves self-describing backend and local warehouse errors", () => { + const backend = new WarehouseQuotaExceededError({ + message: "internal", + pipeName: "query", + setting: "max_memory_usage", + }) const local = new WarehouseDecodeError({ operation: "decode", message: "invalid input" }) expect(normalizeWarehouseError("query", backend)).toBe(backend) expect(normalizeWarehouseError("query", local)).toBe(local) + expect(local.error.title).toBe("Query data could not be read") }) it("wraps an unstructured failure exactly once", () => { diff --git a/apps/web/src/api/warehouse/effect-utils.ts b/apps/web/src/api/warehouse/effect-utils.ts index ba32b3b6c..4aded1e23 100644 --- a/apps/web/src/api/warehouse/effect-utils.ts +++ b/apps/web/src/api/warehouse/effect-utils.ts @@ -8,47 +8,85 @@ import { type AttributeValueItem, } from "@maple/query-engine" import { Effect, Schema } from "effect" +import { PublicHttpErrorBodySchema, type AnyPublicHttpErrorBody } from "@maple/domain/http" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { mapleApiClientLayer, mapleApiV2ClientLayer, mapleRuntime } from "@/lib/registry" +import { makeClientErrorBody } from "@/lib/error-messages" import { makeExecuteBatcher } from "./execute-batcher" export const WarehouseDateTimeString = TinybirdDateTime export class WarehouseDecodeError extends Schema.TaggedError()( - "@maple/web/api/warehouse/WarehouseDecodeError", + "@maple/web/errors/WarehouseDecodeError", { operation: Schema.String, message: Schema.String, cause: Schema.optional(Schema.Unknown), }, -) {} +) { + readonly error = makeClientErrorBody({ + _tag: this._tag, + code: "warehouse_decode_failed", + title: "Query data could not be read", + message: "Maple could not read the query response.", + retryable: false, + recovery: "contact_support", + }) +} export class WarehouseQueryError extends Schema.TaggedError()( - "@maple/web/api/warehouse/WarehouseQueryError", + "@maple/web/errors/WarehouseQueryError", { operation: Schema.String, message: Schema.String, cause: Schema.optional(Schema.Unknown), }, -) {} +) { + readonly error = makeClientErrorBody({ + _tag: this._tag, + code: "warehouse_query_failed", + title: "Warehouse query failed", + message: "Maple could not complete the warehouse query.", + retryable: true, + recovery: "retry", + }) +} export class WarehouseTransformError extends Schema.TaggedError()( - "@maple/web/api/warehouse/WarehouseTransformError", + "@maple/web/errors/WarehouseTransformError", { operation: Schema.String, message: Schema.String, cause: Schema.optional(Schema.Unknown), }, -) {} +) { + readonly error = makeClientErrorBody({ + _tag: this._tag, + code: "warehouse_transform_failed", + title: "Query data could not be displayed", + message: "Maple could not prepare the query data.", + retryable: false, + recovery: "contact_support", + }) +} export class WarehouseInvalidInputError extends Schema.TaggedError()( - "@maple/web/api/warehouse/WarehouseInvalidInputError", + "@maple/web/errors/WarehouseInvalidInputError", { operation: Schema.String, message: Schema.String, }, -) {} +) { + readonly error = makeClientErrorBody({ + _tag: this._tag, + code: "warehouse_input_invalid", + title: "Invalid query", + message: this.message, + retryable: false, + recovery: "fix_request", + }) +} export type WarehouseApiError = | WarehouseDecodeError @@ -56,65 +94,29 @@ export type WarehouseApiError = | WarehouseTransformError | WarehouseInvalidInputError -/** Tagged v1 backend error surfaced by the Maple API client. */ -export interface TaggedBackendError { - readonly _tag: string -} - -/** Public v2 error envelope. Semantic domain tags live inside `error` on v2. */ -export interface V2BackendError { - readonly error: { - readonly _tag?: string - readonly type: string - readonly code: string - readonly message: string - readonly title?: string - readonly retryable?: boolean - readonly recovery?: string - readonly retry_after_seconds?: number - readonly retry_at?: string - readonly param?: string - readonly doc_url?: string - } -} - -export type BackendError = TaggedBackendError | V2BackendError +/** Backend failures are either a public body or an error carrying that same body. */ +export type BackendError = AnyPublicHttpErrorBody | { readonly error: AnyPublicHttpErrorBody } function toMessage(cause: unknown, fallback: string): string { return cause instanceof Error ? cause.message : fallback } -const isTaggedBackendError = (cause: unknown): cause is TaggedBackendError => - typeof cause === "object" && - cause !== null && - "_tag" in cause && - typeof (cause as { _tag: unknown })._tag === "string" && - (cause as { _tag: string })._tag.startsWith("@maple/http/errors/") +const isPublicErrorBody = Schema.is(PublicHttpErrorBodySchema) -const isV2BackendError = (cause: unknown): cause is V2BackendError => { +const isPublicErrorEnvelope = (cause: unknown): cause is { readonly error: AnyPublicHttpErrorBody } => { if (typeof cause !== "object" || cause === null || !("error" in cause)) return false - const error = (cause as { error: unknown }).error - return ( - typeof error === "object" && - error !== null && - "type" in error && - typeof error.type === "string" && - "code" in error && - typeof error.code === "string" && - "message" in error && - typeof error.message === "string" - ) + return isPublicErrorBody((cause as { readonly error: unknown }).error) } export const isBackendError = (cause: unknown): cause is BackendError => - isTaggedBackendError(cause) || isV2BackendError(cause) + isPublicErrorBody(cause) || isPublicErrorEnvelope(cause) export const isWarehouseApiError = (cause: unknown): cause is WarehouseApiError => typeof cause === "object" && cause !== null && "_tag" in cause && typeof cause._tag === "string" && - cause._tag.startsWith("@maple/web/api/warehouse/") + cause._tag.startsWith("@maple/web/errors/Warehouse") /** Preserve known errors; introduce a local query error only for an unstructured failure. */ export const normalizeWarehouseError = ( @@ -146,9 +148,9 @@ export function decodeInput( +export function runWarehouseQuery( operation: string, - execute: () => Effect.Effect, + execute: () => Effect.Effect, ): Effect.Effect { return Effect.suspend(execute).pipe( Effect.withSpan(operation), @@ -163,10 +165,9 @@ export function runWarehouseQuery( * `runWarehouseQuery` against the v2 client. * * Same span + error normalization, different client layer and a wider input - * error type: the v2 endpoints fail with the public envelope union - * (`V2InvalidRequestError`, `V2PayloadTooLargeError`, …) rather than the v1 - * warehouse tags. Both forms pass through unchanged so the UI retains the - * server's status, code, and remediation copy. + * error type: each v2 endpoint exposes its own literal `_tag` envelope union. + * Those envelopes pass through unchanged so the UI retains the server's exact + * semantic tag, status, code, and remediation copy. */ export function runWarehouseQueryV2( operation: string, diff --git a/apps/web/src/api/warehouse/execute-batcher.test.ts b/apps/web/src/api/warehouse/execute-batcher.test.ts index 2dbfd9fd6..1a5e72ea6 100644 --- a/apps/web/src/api/warehouse/execute-batcher.test.ts +++ b/apps/web/src/api/warehouse/execute-batcher.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { QueryEngineTimeoutError } from "@maple/domain/http" import { QueryEngineExecuteRequest, QUERY_ENGINE_BATCH_MAX, @@ -54,10 +55,7 @@ describe("makeExecuteBatcher", () => { index === 1 ? ({ outcome: "failure", - error: { - _tag: "@maple/http/errors/QueryEngineTimeoutError", - message: "too slow", - }, + error: new QueryEngineTimeoutError({ message: "too slow" }).error, } satisfies QueryEngineBatchOutcome) : countOutcome(index), ), @@ -74,10 +72,15 @@ describe("makeExecuteBatcher", () => { expect(settled.map((s) => s.status)).toEqual(["fulfilled", "rejected", "fulfilled"]) const rejected = settled[1] if (rejected?.status !== "rejected") throw new Error("expected a rejection") - // The server's tag survives, so normalizeWarehouseError passes it through. + // The server's complete public error body survives the successful batch response. expect(rejected.reason).toEqual({ _tag: "@maple/http/errors/QueryEngineTimeoutError", - message: "too slow", + type: "api_error", + code: "query_engine_timeout", + title: "Query timed out", + message: "The aggregation query timed out. Retry with a narrower time range.", + retryable: true, + recovery: "retry", }) }) diff --git a/apps/web/src/api/warehouse/execute-batcher.ts b/apps/web/src/api/warehouse/execute-batcher.ts index a6a0dd784..e27dea798 100644 --- a/apps/web/src/api/warehouse/execute-batcher.ts +++ b/apps/web/src/api/warehouse/execute-batcher.ts @@ -70,8 +70,7 @@ export const makeExecuteBatcher = ( if (outcome === undefined) { entry.reject(new Error("Batch response was missing a result for this query.")) } else if (outcome.outcome === "failure") { - // Carries the server's original `_tag`, so normalizeWarehouseError - // passes it through and the UI keeps its specific error copy. + // Per-item failures already use the complete public error body. entry.reject(outcome.error) } else { entry.resolve(new QueryEngineExecuteResponse({ result: outcome.result })) diff --git a/apps/web/src/api/warehouse/query-builder-breakdown.ts b/apps/web/src/api/warehouse/query-builder-breakdown.ts index 6c39b8822..bc5f1afaf 100644 --- a/apps/web/src/api/warehouse/query-builder-breakdown.ts +++ b/apps/web/src/api/warehouse/query-builder-breakdown.ts @@ -2,19 +2,8 @@ import { Effect, Result, Schema } from "effect" import { QueryBuilderQueryDraftSchema } from "@maple/domain/http" import { QueryEngineExecuteRequest } from "@maple/query-engine" import { buildBreakdownQuerySpec } from "@maple/query-engine/query-builder" -import { - type BackendError, - type WarehouseQueryError, - decodeInput, - executeQueryEngine, - invalidWarehouseInput, -} from "@/api/warehouse/effect-utils" - -// Read the message from either a local/tagged error or a public v2 envelope. -function queryEngineErrorMessage(error: WarehouseQueryError | BackendError, fallback: string): string { - if ("message" in error && typeof error.message === "string") return error.message - return "error" in error && typeof error.error.message === "string" ? error.error.message : fallback -} +import { decodeInput, executeQueryEngine, invalidWarehouseInput } from "@/api/warehouse/effect-utils" +import { displayError } from "@/lib/error-messages" const dateTimeString = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) @@ -79,7 +68,7 @@ const executeBreakdownQuery = Effect.fn("QueryEngine.executeBreakdownQuery")(fun queryId: query.id, queryName: query.name, status: "error", - error: queryEngineErrorMessage(error, "Breakdown query failed"), + error: displayError(error).message, data: [], } satisfies BreakdownQueryResult } diff --git a/apps/web/src/api/warehouse/query-builder-timeseries.test.ts b/apps/web/src/api/warehouse/query-builder-timeseries.test.ts index 314bddfc1..f5984f066 100644 --- a/apps/web/src/api/warehouse/query-builder-timeseries.test.ts +++ b/apps/web/src/api/warehouse/query-builder-timeseries.test.ts @@ -191,7 +191,7 @@ describe("query-builder timeseries strategy", () => { assert.deepStrictEqual(seenBucketSeconds, [60, 900, 3600]) assert.isTrue(result.fallbackUsed) assert.lengthOf(result.attempts, 3) - assert.include(result.attempts[1]?.error ?? "", "too expensive") + assert.strictEqual(result.attempts[1]?.error, "Maple could not complete the warehouse query.") assert.deepStrictEqual(result.points, [ { bucket: "2026-01-01T00:00:00.000Z", diff --git a/apps/web/src/api/warehouse/query-builder-timeseries.ts b/apps/web/src/api/warehouse/query-builder-timeseries.ts index 3c202a8d2..ed81f3440 100644 --- a/apps/web/src/api/warehouse/query-builder-timeseries.ts +++ b/apps/web/src/api/warehouse/query-builder-timeseries.ts @@ -18,19 +18,14 @@ import { decodeInput, executeQueryEngine, invalidWarehouseInput, - type WarehouseApiError, type BackendError, + type WarehouseApiError, } from "@/api/warehouse/effect-utils" +import { displayError } from "@/lib/error-messages" import { computeBucketSeconds } from "@/api/warehouse/timeseries-utils" type ExecuteError = WarehouseApiError | BackendError -// Read the message from either a local/tagged error or a public v2 envelope. -function executeErrorMessage(error: ExecuteError, fallback: string): string { - if ("message" in error && typeof error.message === "string") return error.message - return "error" in error && typeof error.error.message === "string" ? error.error.message : fallback -} - const dateTimeString = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) const COMPARISON_MODES = ["none", "previous_period"] as const @@ -321,7 +316,7 @@ const executeTimeseriesQueryWithFallbackUsing = Effect.fn("QueryEngine.executeTi if (Result.isFailure(outcome)) { const error = outcome.failure - const message = executeErrorMessage(error, "Query execution failed") + const message = displayError(error).message attempts.push({ startTime: window.startTime, @@ -625,7 +620,7 @@ const runQueryWindow = Effect.fn("QueryEngine.runQueryWindow")(function* ( queryName: query.name, source: query.dataSource, status: "error", - error: executeErrorMessage(error, "Query execution failed"), + error: displayError(error).message, warnings: built.warnings, data: [], } satisfies QueryRunResult diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx index 3394a1868..b193c55c3 100644 --- a/apps/web/src/components/alerts/destination-dialog.tsx +++ b/apps/web/src/components/alerts/destination-dialog.tsx @@ -26,7 +26,7 @@ import { } from "@/components/alerts/slack-channel-search" import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" -import { v2ErrorInfo } from "@/lib/error-messages" +import { publicError } from "@/lib/error-messages" import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import type { HazelChannelsListResponse } from "@maple/domain/http" @@ -635,10 +635,10 @@ function SlackBotFields({ // `GET /v2/integrations/slack/channels` is admin-gated (`requireAdmin`), so a // regular member gets a 403 that no amount of retrying will clear. The v2 // envelope ({ error: { type, code, message } }) survives into the Result's - // cause, and `v2ErrorInfo` unwraps it — branch on the closed `type` enum, never + // cause, and `publicError` unwraps it — branch on the closed `type` enum, never // on the human-readable message. const channelsPermissionDenied = - Result.isFailure(channelsResult) && v2ErrorInfo(channelsResult.cause)?.type === "permission_error" + Result.isFailure(channelsResult) && publicError(channelsResult.cause)?.type === "permission_error" // Requires no prior success, matching the `statusFailed` rule below: if we // already have channels in hand (a role change mid-edit), keep the picker // rather than yanking the user's selection away. diff --git a/apps/web/src/components/alerts/overview/settings-tab.tsx b/apps/web/src/components/alerts/overview/settings-tab.tsx index f14f386cf..468b000b1 100644 --- a/apps/web/src/components/alerts/overview/settings-tab.tsx +++ b/apps/web/src/components/alerts/overview/settings-tab.tsx @@ -22,7 +22,7 @@ import { v2DeliveryToDocument, type DestinationFormState, } from "@/lib/alerts/form-utils" -import { v2ErrorInfo } from "@/lib/error-messages" +import { publicError } from "@/lib/error-messages" import { useAlertDestinationsList } from "@/hooks/use-alerts-list" import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom" @@ -88,7 +88,7 @@ export function useDestinationManager(): DestinationManager { // `as never`: the generated client collapses the discriminated-union payload // to a single member in its inferred signature; the builders return the // correctly-typed union, so the cast only bridges that inference gap. - const result = editing + const result: unknown = editing ? await updateDestination({ params: { id: editing.id }, payload: buildDestinationUpdateParamsV2(form) as never, @@ -99,7 +99,7 @@ export function useDestinationManager(): DestinationManager { reactivityKeys: ["alertDestinations"], }) - if (Exit.isSuccess(result)) { + if (Exit.isExit(result) && Exit.isSuccess(result)) { toastManager.add({ title: editing ? "Destination updated" : "Destination created", type: "success", @@ -164,7 +164,7 @@ export function useDestinationManager(): DestinationManager { // A destination still referenced by rules deletes with a 409 // conflict_error whose message already names the referencing rules. const failure = Option.getOrUndefined(Exit.findErrorOption(result)) - const v2 = v2ErrorInfo(failure) + const v2 = publicError(failure) if (v2 !== null && v2.type === "conflict_error") { toastManager.add({ title: v2.message, type: "error" }) } else { diff --git a/apps/web/src/components/app-error-boundary.tsx b/apps/web/src/components/app-error-boundary.tsx index 08b79111c..a2decb360 100644 --- a/apps/web/src/components/app-error-boundary.tsx +++ b/apps/web/src/components/app-error-boundary.tsx @@ -6,7 +6,7 @@ import { Component, type ReactNode } from "react" import { buttonVariants } from "@maple/ui/components/ui/button" import { isChunkLoadError, shouldAttemptChunkReload } from "@/lib/chunk-reload" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" interface AppErrorBoundaryProps { children: ReactNode @@ -45,7 +45,7 @@ export class AppErrorBoundary extends Component

- {presentation.description} Your telemetry is safe — reloading usually recovers it. + {presentation.message} Your telemetry is safe — reloading usually recovers it.

diff --git a/apps/web/src/components/common/error-state.test.tsx b/apps/web/src/components/common/error-state.test.tsx index 7faa496d6..1e3547f53 100644 --- a/apps/web/src/components/common/error-state.test.tsx +++ b/apps/web/src/components/common/error-state.test.tsx @@ -1,9 +1,18 @@ // @vitest-environment jsdom import { act, cleanup, render, screen } from "@testing-library/react" +import { HttpClientError, HttpClientRequest } from "effect/unstable/http" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ErrorState } from "./error-state" +const transportError = (cause?: unknown) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.get("https://api.maple.dev/v2/services"), + ...(cause === undefined ? {} : { cause }), + }), + }) + describe("ErrorState recovery actions", () => { beforeEach(() => vi.useFakeTimers()) @@ -17,9 +26,13 @@ describe("ErrorState recovery actions", () => { { it("offers retry and communicates bounded automatic recovery for connectivity loss", () => { const retry = vi.fn() - render() + render() expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy() expect(screen.getByRole("alert").textContent).toContain("Retrying automatically") @@ -44,7 +57,12 @@ describe("ErrorState recovery actions", () => { }) it("keeps timeouts manual instead of treating them as offline", () => { - render() + render( + , + ) expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy() expect(screen.getByRole("alert").textContent).not.toContain("Retrying automatically") diff --git a/apps/web/src/components/common/error-state.tsx b/apps/web/src/components/common/error-state.tsx index 03aecabe9..b29ea80f6 100644 --- a/apps/web/src/components/common/error-state.tsx +++ b/apps/web/src/components/common/error-state.tsx @@ -1,6 +1,6 @@ import { Button } from "@maple/ui/components/ui/button" import { useNetworkAutoRetry } from "@/hooks/use-network-auto-retry" -import { formatBackendError } from "@/lib/error-messages" +import { displayError, isAutomaticRetryError } from "@/lib/error-messages" import { cn } from "@maple/ui/lib/utils" /** @@ -9,7 +9,7 @@ import { cn } from "@maple/ui/lib/utils" * Speaks the product's own language: a "dropped signal" readout — a live * signal line that cuts out mid-stream, with a dashed trail where the data * should be — the same mini-monitor idiom as the alerts empty state's - * QuietMonitor. Copy comes from `formatBackendError`, plus a recovery action + * QuietMonitor. Copy comes from `displayError`, plus a recovery action * when the caller can offer one. * * - `panel` — centered block for main content areas (tables, detail views, @@ -32,19 +32,14 @@ interface ErrorStateProps { } export function ErrorState({ error, title, onRetry, variant = "panel", className }: ErrorStateProps) { - const formatted = formatBackendError(error) + const formatted = displayError(error) const heading = title ?? formatted.title const canRetry = - onRetry !== undefined && (formatted.recovery.kind === "retry" || formatted.recovery.kind === "reload") + onRetry !== undefined && (formatted.recovery === "retry" || formatted.recovery === "refresh") // Connectivity blips self-heal: probe on a backoff poll + the `online` // event, so recovery doesn't require the user to click or reload. - const autoRetrying = useNetworkAutoRetry( - formatted.recovery.kind === "retry" && formatted.recovery.automatic && canRetry, - onRetry, - ) - const description = autoRetrying - ? `${formatted.description} Retrying automatically…` - : formatted.description + const autoRetrying = useNetworkAutoRetry(isAutomaticRetryError(formatted) && canRetry, onRetry) + const description = autoRetrying ? `${formatted.message} Retrying automatically…` : formatted.message if (variant === "inline") { return ( diff --git a/apps/web/src/components/infra/host-detail-chart.tsx b/apps/web/src/components/infra/host-detail-chart.tsx index e615cfe83..63eeb1754 100644 --- a/apps/web/src/components/infra/host-detail-chart.tsx +++ b/apps/web/src/components/infra/host-detail-chart.tsx @@ -23,7 +23,7 @@ import { UNNAMED_SERIES_KEY, } from "./chart-utils" import { InfraTooltipItem } from "./chart-tooltip" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { LinkedCursorOverlay, linkedCursorChartProps } from "@/hooks/use-linked-cursor" interface HostDetailChartProps { @@ -71,7 +71,7 @@ export function HostDetailChart({ .onInitial(() => ) .onError((err) => (
- {formatBackendError(err).description} + {displayError(err).message}
)) .onSuccess((response, holder) => ( diff --git a/apps/web/src/components/infra/k8s-detail-chart.tsx b/apps/web/src/components/infra/k8s-detail-chart.tsx index 31d42c93d..d37f64cd8 100644 --- a/apps/web/src/components/infra/k8s-detail-chart.tsx +++ b/apps/web/src/components/infra/k8s-detail-chart.tsx @@ -32,7 +32,7 @@ import { UNNAMED_SERIES_KEY, } from "./chart-utils" import { InfraTooltipItem } from "./chart-tooltip" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { LinkedCursorOverlay, linkedCursorChartProps } from "@/hooks/use-linked-cursor" const CHART_HEIGHT = 280 @@ -354,7 +354,7 @@ export function PodDetailChart({ .onInitial(() => ) .onError((err) => (
- {formatBackendError(err).description} + {displayError(err).message}
)) .onSuccess((response, holder) => ( @@ -398,7 +398,7 @@ export function NodeDetailChart({ .onInitial(() => ) .onError((err) => (
- {formatBackendError(err).description} + {displayError(err).message}
)) .onSuccess((response, holder) => ( @@ -456,7 +456,7 @@ export function WorkloadDetailChart({ .onInitial(() => ) .onError((err) => (
- {formatBackendError(err).description} + {displayError(err).message}
)) .onSuccess((response, holder) => ( diff --git a/apps/web/src/components/investigations/investigation-view.tsx b/apps/web/src/components/investigations/investigation-view.tsx index 952abe2a8..de64c5f79 100644 --- a/apps/web/src/components/investigations/investigation-view.tsx +++ b/apps/web/src/components/investigations/investigation-view.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react" import { useNavigate } from "@tanstack/react-router" import { Exit } from "effect" import { useAtomSet } from "@/lib/effect-atom" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import type { V2Investigation } from "@maple/domain/http/v2" import { toastManager } from "@maple/ui/components/ui/toast" @@ -136,8 +136,8 @@ export function InvestigationView({ } else { // The server's reason is the whole message — a daily-budget 429 says which // ceiling was hit and when it resets, and a fixed title threw all of it away. - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } } @@ -152,8 +152,8 @@ export function InvestigationView({ if (Exit.isSuccess(result)) { toastManager.add({ title: "Investigation resolved", type: "success" }) } else { - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } } diff --git a/apps/web/src/components/metrics/metric-breakdown.tsx b/apps/web/src/components/metrics/metric-breakdown.tsx index 00a4b785f..4299c470e 100644 --- a/apps/web/src/components/metrics/metric-breakdown.tsx +++ b/apps/web/src/components/metrics/metric-breakdown.tsx @@ -14,7 +14,7 @@ import { getQueryBuilderBreakdownResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import type { MetricsQueryDraft } from "@maple/query-engine/query-builder" const SERVICE_KEY = "service.name" @@ -171,9 +171,7 @@ function BreakdownBars({ )) .onError((error) => ( -

- {formatBackendError(error).description} -

+

{displayError(error).message}

)) .onSuccess((response) => { const rows = response.data diff --git a/apps/web/src/components/query-builder/query-builder-lab.tsx b/apps/web/src/components/query-builder/query-builder-lab.tsx index 544da80de..0ec552863 100644 --- a/apps/web/src/components/query-builder/query-builder-lab.tsx +++ b/apps/web/src/components/query-builder/query-builder-lab.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Result } from "@/lib/effect-atom" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { PulseIcon, XmarkIcon, PlusIcon, MagnifierIcon } from "@/components/icons" import { Badge } from "@maple/ui/components/ui/badge" @@ -247,9 +247,7 @@ function QueryBuilderAtomResults({ input }: { input: QueryBuilderTimeseriesInput error query_engine -

- {formatBackendError(error).description} -

+

{displayError(error).message}

)) .onSuccess((response) => { diff --git a/apps/web/src/components/route-error.tsx b/apps/web/src/components/route-error.tsx index c2f232cb4..5beaf6596 100644 --- a/apps/web/src/components/route-error.tsx +++ b/apps/web/src/components/route-error.tsx @@ -5,14 +5,14 @@ import { Button, buttonVariants } from "@maple/ui/components/ui/button" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { useNetworkAutoRetry } from "@/hooks/use-network-auto-retry" import { useMountEffect } from "@/hooks/use-mount-effect" -import { formatBackendError } from "@/lib/error-messages" +import { displayError, isAutomaticRetryError } from "@/lib/error-messages" import { isChunkLoadError, shouldAttemptChunkReload } from "@/lib/chunk-reload" function RouteError({ error, reset }: ErrorComponentProps) { const router = useRouter() const isStaleChunk = isChunkLoadError(error) - const formatted = formatBackendError(error) + const formatted = displayError(error) const { title } = formatted const stack = error instanceof Error ? error.stack : undefined @@ -20,15 +20,10 @@ function RouteError({ error, reset }: ErrorComponentProps) { reset() router.invalidate() } - const autoRetrying = useNetworkAutoRetry( - formatted.recovery.kind === "retry" && formatted.recovery.automatic && !isStaleChunk, - retry, - ) - const description = autoRetrying - ? `${formatted.description} Retrying automatically…` - : formatted.description - const canRetry = formatted.recovery.kind === "retry" || formatted.recovery.kind === "reload" - const shouldReload = isStaleChunk || formatted.recovery.kind === "reload" + const autoRetrying = useNetworkAutoRetry(isAutomaticRetryError(formatted) && !isStaleChunk, retry) + const description = autoRetrying ? `${formatted.message} Retrying automatically…` : formatted.message + const canRetry = formatted.recovery === "retry" || formatted.recovery === "refresh" + const shouldReload = isStaleChunk || formatted.recovery === "refresh" return ( diff --git a/apps/web/src/components/service-map/service-map-view.tsx b/apps/web/src/components/service-map/service-map-view.tsx index fcae22ac6..f3864ed3c 100644 --- a/apps/web/src/components/service-map/service-map-view.tsx +++ b/apps/web/src/components/service-map/service-map-view.tsx @@ -26,7 +26,7 @@ import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { serviceMapLayoutAtomFamily, upsertSnapshot } from "@/atoms/service-map-layout-atoms" import { serviceMapViewPrefsAtomFamily } from "@/atoms/service-map-view-prefs-atoms" import { Link } from "@tanstack/react-router" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { logClientError } from "@/lib/services/common/telemetry" import { Bar, BarChart, CartesianGrid, Line, XAxis, YAxis } from "recharts" @@ -1053,11 +1053,11 @@ function PlanetScaleSection({ {Result.builder(branchStatsResult) .onError((error) => { - const formatted = formatBackendError(error) + const formatted = displayError(error) return (

{formatted.title}

-

{formatted.description}

+

{formatted.message}

) }) @@ -1409,11 +1409,11 @@ function DatabaseDetailPanel({ {Result.builder(summaryResult) .onError((error) => { - const formatted = formatBackendError(error) + const formatted = displayError(error) return (

{formatted.title}

-

{formatted.description}

+

{formatted.message}

) }) @@ -2578,12 +2578,12 @@ export function ServiceMapView({ return Result.builder(bundleResult) .onInitial(() => ) .onError((error) => { - const formatted = formatBackendError(error) + const formatted = displayError(error) return (

{formatted.title}

-

{formatted.description}

+

{formatted.message}

) diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index f1aa29ef8..f42a0ca58 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -46,7 +46,7 @@ import { } from "@/components/icons" import { useApiKeyMutationSync, useApiKeysList } from "@/hooks/use-api-keys" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { CreateApiKeyDialog } from "./create-api-key-dialog" import { RollApiKeyDialog } from "./roll-api-key-dialog" @@ -101,8 +101,8 @@ export function ApiKeysSection() { toastManager.add({ title: "API key revoked", type: "success" }) void reconcileTxid(result.value.txid) } else { - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } setIsRevoking(false) setRevokeOpen(false) diff --git a/apps/web/src/components/settings/create-api-key-dialog.tsx b/apps/web/src/components/settings/create-api-key-dialog.tsx index ef073d374..909dd7c82 100644 --- a/apps/web/src/components/settings/create-api-key-dialog.tsx +++ b/apps/web/src/components/settings/create-api-key-dialog.tsx @@ -21,7 +21,7 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { useApiKeyMutationSync } from "@/hooks/use-api-keys" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { trackProduct } from "@/lib/analytics" import { buildApiKeyCreatePayload } from "./api-key-create-payload" @@ -118,8 +118,8 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea onCreated?.(result.value.secret) void reconcileTxid(result.value.txid) } else { - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } setIsCreating(false) } diff --git a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx index e479055c9..2a5af1383 100644 --- a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx +++ b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx @@ -2,7 +2,7 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import { useEffect, useMemo, useRef, useState } from "react" import { Exit, Option } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { Button } from "@maple/ui/components/ui/button" @@ -38,8 +38,8 @@ import { DataPlatformUsageSection } from "@/components/settings/data-platform-us function getExitErrorMessage(exit: Exit.Exit, fallback: string): string { if (Exit.isSuccess(exit)) return fallback const failure = Option.getOrUndefined(Exit.findErrorOption(exit)) - const formatted = formatBackendError(failure ?? exit) - return formatted.description || formatted.title || fallback + const formatted = displayError(failure ?? exit) + return formatted.message || formatted.title || fallback } const syncDateFormatter = new Intl.DateTimeFormat("en-US", { diff --git a/apps/web/src/components/settings/roll-api-key-dialog.tsx b/apps/web/src/components/settings/roll-api-key-dialog.tsx index 1b831a3a5..d1ff95845 100644 --- a/apps/web/src/components/settings/roll-api-key-dialog.tsx +++ b/apps/web/src/components/settings/roll-api-key-dialog.tsx @@ -15,7 +15,7 @@ import { DialogTitle, } from "@maple/ui/components/ui/dialog" import { useApiKeyMutationSync } from "@/hooks/use-api-keys" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { ApiKeySecretReveal } from "./api-key-secret-reveal" @@ -45,8 +45,8 @@ export function RollApiKeyDialog({ open, onOpenChange, apiKey, onRolled }: RollA onRolled?.() void reconcileTxid(result.value.txid) } else { - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } setIsRolling(false) } diff --git a/apps/web/src/hooks/use-alert-rule-preview.ts b/apps/web/src/hooks/use-alert-rule-preview.ts index de717aeff..4934bde64 100644 --- a/apps/web/src/hooks/use-alert-rule-preview.ts +++ b/apps/web/src/hooks/use-alert-rule-preview.ts @@ -11,7 +11,7 @@ import { type RuleFormState, } from "@/lib/alerts/form-utils" import { mapBuilderChartFailure } from "@/lib/alerts/preview-failure" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { normalizeTimestampInput } from "@/lib/timezone-format" const emptyPreviewAtom = Atom.make(Result.initial()) @@ -108,7 +108,7 @@ export function useAlertRulePreview( (error): AlertRulePreviewState => ({ preview: null, previewLoading: false, - previewError: mapBuilderChartFailure(formatBackendError(error).description), + previewError: mapBuilderChartFailure(displayError(error).message), }), ) .orElse( diff --git a/apps/web/src/hooks/use-widget-data.ts b/apps/web/src/hooks/use-widget-data.ts index b853dc3b7..d73623d92 100644 --- a/apps/web/src/hooks/use-widget-data.ts +++ b/apps/web/src/hooks/use-widget-data.ts @@ -26,7 +26,7 @@ import type { WidgetDataState } from "@/components/dashboard-builder/types" import { encodeKey, encodeOrgScopedKey, identityFromKey, orgScopedKeyPayload } from "@/lib/cache-key" import { nextRetentionNamespace, withRetention } from "@/lib/services/atoms/retained-atom" import { getActiveOrgId } from "@/lib/services/common/auth-headers" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { Cause, Option } from "effect" import { WarehouseDecodeError, type BackendError, type WarehouseApiError } from "@/api/warehouse/effect-utils" import { QueryEngineValidationError } from "@maple/domain/http" @@ -290,7 +290,7 @@ const isExpectedEmptyDataError = (error: unknown): boolean => { // The error channel the widget-fetch atom exposes: every failure is either a // `WidgetDataAtomError` (parse / unknown endpoint) or the server function's -// existing local/v1/v2 error. Preserve those states for `formatBackendError`. +// existing local/v1/v2 error. Preserve those states for `displayError`. type WidgetFetchError = WidgetDataAtomError | WarehouseApiError | BackendError const toWidgetDataAtomError = (error: unknown): WidgetDataAtomError => { @@ -533,9 +533,9 @@ export function useWidgetDataSource( message: "No query data found in selected time range", } as const } - const { title, description } = formatBackendError(error) + const { title, message } = displayError(error) const kind = classifyWidgetErrorKind(error) - return { status: "error", title, message: description, kind } as const + return { status: "error", title, message, kind } as const }) .onSuccess((rawData) => ({ status: "ready", data: applyTransform(rawData, transform) }) as const) .orElse(() => ({ status: "error", message: "Unknown error" }) as const) diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts index 736fb8d8b..d1240c2b5 100644 --- a/apps/web/src/lib/alerts/form-utils.ts +++ b/apps/web/src/lib/alerts/form-utils.ts @@ -142,8 +142,8 @@ export const isRangeComparator = (c: AlertComparator): c is "between" | "not_bet export { destinationTypeLabels } from "@/components/alerts/destination-provider" -export function getExitErrorMessage(exit: Exit.Exit, fallback: string): string { - if (Exit.isSuccess(exit)) return fallback +export function getExitErrorMessage(exit: unknown, fallback: string): string { + if (!Exit.isExit(exit) || Exit.isSuccess(exit)) return fallback return errorMessage(exit, fallback) } diff --git a/apps/web/src/lib/error-messages.test.ts b/apps/web/src/lib/error-messages.test.ts index cab0a6d19..e7f7922c0 100644 --- a/apps/web/src/lib/error-messages.test.ts +++ b/apps/web/src/lib/error-messages.test.ts @@ -1,392 +1,212 @@ +import { Cause } from "effect" import { HttpClientError, HttpClientRequest } from "effect/unstable/http" import { describe, expect, it } from "vitest" -import { WAREHOUSE_ERROR_TAGS } from "@maple/domain" -import { formatBackendError, humanizeInstants, normalizeAppError, v2ErrorInfo } from "./error-messages" - -describe("humanizeInstants", () => { - const NOW = Date.parse("2026-08-09T17:00:00.000Z") - - it("rewrites an ISO instant as a relative time, keeping the sentence", () => { - expect( - humanizeInstants( - "Daily limit of 90 model passes reached. Resets at 2026-08-10T00:00:00.000Z.", - NOW, - ), - ).toBe("Daily limit of 90 model passes reached. Resets in 7h.") - }) - - it("leaves a message without an instant alone", () => { - expect(humanizeInstants("No such investigation.", NOW)).toBe("No such investigation.") - }) +import { QueryEngineExecutionError, WarehouseQuotaExceededError } from "@maple/domain" +import { + NetworkErrorTag, + UnexpectedErrorTag, + displayError, + isAutomaticRetryError, + isUnexpectedError, + publicError, +} from "./error-messages" + +const errorEnvelope = ( + overrides: Partial<{ + _tag: string + type: + | "invalid_request_error" + | "authentication_error" + | "permission_error" + | "not_found_error" + | "conflict_error" + | "rate_limit_error" + | "api_error" + code: string + title: string + message: string + retryable: boolean + recovery: + | "none" + | "fix_request" + | "reauthenticate" + | "request_access" + | "reconnect" + | "refresh" + | "retry" + | "contact_support" + retry_after_seconds: number + retry_at: string + param: string + doc_url: string + }> = {}, +) => ({ + error: { + _tag: "@maple/http/v2/test_error", + type: "api_error" as const, + code: "test_error", + title: "Maple could not complete the request", + message: "Maple could not complete the request.", + retryable: false, + recovery: "contact_support" as const, + ...overrides, + }, }) -describe("formatBackendError", () => { - /** - * The v2 envelope is checked first, and its message is the whole point: a - * hardcoded toast title threw away which ceiling was hit and when it resets. - */ - it("surfaces a v2 rate-limit message as the description", () => { - const result = formatBackendError({ - error: { - type: "rate_limit_error", - code: "investigation_daily_quota", - message: "Daily limit of 90 model passes reached. Resets at 2026-08-10T00:00:00.000Z.", - }, +describe("publicError", () => { + it("returns the public body without translating it", () => { + const input = errorEnvelope({ + _tag: "@maple/http/errors/InvalidTimeRangeError", + type: "invalid_request_error", + code: "invalid_time_range", + title: "Invalid time range", + message: "End time must be after start time.", + recovery: "fix_request", + param: "end_time", + doc_url: "https://api.maple.dev/v2/docs#time-range", + retry_after_seconds: 15, + retry_at: "2026-08-10T00:00:00.000Z", }) - expect(result.title).toBe("Investigation limit reached") - expect(result.description).toContain("Daily limit of 90 model passes reached") - expect(result.description).not.toContain("2026-08-10T00:00:00.000Z") - expect(result.category).toBe("rate-limit") - expect(result.code).toBe("investigation_daily_quota") - expect(result.recovery).toEqual({ kind: "retry", automatic: false }) - }) - it("trusts the backend tag, title, and recovery instead of inferring from status", () => { - const result = normalizeAppError({ - error: { - _tag: "@maple/http/errors/WarehouseQuotaExceededError", - type: "rate_limit_error", - code: "rate_limited", - title: "Query was too expensive", - message: "Narrow the time range or add filters.", - retryable: false, - recovery: "fix_request", - retry_after_seconds: 15, - }, - }) - expect(result.title).toBe("Query was too expensive") - expect(result.recovery).toEqual({ kind: "fix-input" }) - expect(result.retryAfterSeconds).toBe(15) - expect(result.tag).toBe("@maple/http/errors/WarehouseQuotaExceededError") - expect(result.diagnostics.tag).toBe("@maple/http/errors/WarehouseQuotaExceededError") + expect(publicError(input)).toBe(input.error) + expect(publicError(input.error)).toBe(input.error) }) - it("uses an explicit non-retryable flag even during a partial metadata rollout", () => { - const result = formatBackendError({ - error: { - type: "rate_limit_error", - code: "query_limit", - message: "Narrow the query before trying again.", - retryable: false, - }, - }) - expect(result.recovery).toEqual({ kind: "none" }) + it("rejects incomplete lookalikes", () => { + expect( + publicError({ + error: { + type: "not_found_error", + code: "dashboard_not_found", + message: "No such dashboard.", + }, + }), + ).toBeNull() }) +}) - it("keeps v2 parameter and documentation metadata for field-level UI", () => { - const input = { - error: { - type: "invalid_request_error", - code: "invalid_time_range", - message: "End time must be after start time.", - param: "end_time", - doc_url: "https://api.maple.dev/v2/docs#time-range", - }, - } - expect(v2ErrorInfo(input)).toMatchObject({ - code: "invalid_time_range", - param: "end_time", - docUrl: "https://api.maple.dev/v2/docs#time-range", - }) - expect(formatBackendError(input)).toMatchObject({ - category: "validation", - param: "end_time", - recovery: { kind: "fix-input", param: "end_time" }, +describe("displayError", () => { + it("passes a declared API error through unchanged", () => { + const input = errorEnvelope({ + _tag: "@maple/http/errors/InvestigationDailyQuotaError", + type: "rate_limit_error", + code: "investigation_daily_quota", + title: "Today's investigation allowance is used up", + message: "Daily limit of 90 model passes reached.", + retryable: true, + recovery: "retry", }) + + expect(displayError(input)).toBe(input.error) }) - it("formats WarehouseQuotaExceededError with execution time setting", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseQuotaExceededError", + it("reads the same body directly from a tagged domain error", () => { + const error = new WarehouseQuotaExceededError({ message: "Code: 159. TIMEOUT_EXCEEDED", - pipe: "listLogs", + pipeName: "listLogs", setting: "max_execution_time", }) - expect(result.title).toBe("Query was too expensive") - expect(result.description).toContain("30s execution limit") - }) - it("formats WarehouseQuotaExceededError with memory setting", () => { - const result = formatBackendError({ + expect(displayError(error)).toBe(error.error) + expect(displayError(error)).toMatchObject({ _tag: "@maple/http/errors/WarehouseQuotaExceededError", - message: "memory limit", - pipe: "listTraces", - setting: "max_memory_usage", - }) - expect(result.title).toBe("Query was too expensive") - expect(result.description).toContain("memory") - }) - - it("formats QueryEngineTimeoutError", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/QueryEngineTimeoutError", - message: "took too long", - }) - expect(result.title).toBe("Query timed out") - expect(result.description).toContain("30 seconds") - }) - - it("keeps the engine's message as the title and details as the description", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/QueryEngineValidationError", - message: "List query time range too large", - details: ["List queries support a maximum range of 7 days", "Narrow the time range"], + code: "warehouse_quota_exceeded", + title: "Query was too expensive", + message: "Query exceeded the 30s execution limit. Narrow the time range or add filters.", + recovery: "fix_request", }) - // The specific headline used to be discarded in favour of a generic - // "Invalid query parameters" whenever details were present. - expect(result.title).toBe("List query time range too large") - expect(result.description).toBe( - "List queries support a maximum range of 7 days; Narrow the time range", - ) }) - it("falls back to the message as the description when there are no details", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/QueryEngineValidationError", - message: "Invalid time range", - details: [], - }) - expect(result.title).toBe("Invalid time range") - expect(result.description).toBe("Invalid time range") - }) - - it("redacts QueryEngineExecutionError causeMessage from display copy", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/QueryEngineExecutionError", + it("lets the tagged error redact its internal details", () => { + const error = new QueryEngineExecutionError({ message: "errorsByType query failed", causeMessage: "Code: 226. DB::Exception: Syntax error", }) - expect(result.title).toBe("Query failed") - expect(result.description).not.toContain("errorsByType") - expect(result.description).not.toContain("Syntax error") - }) - - it("formats WarehouseQueryError without leaking the internal pipe label", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseQueryError", - message: "DB::Exception: syntax error", - pipe: "spanHierarchy", - }) - expect(result.title).toBe("Database query failed") - expect(result.description).toBe("Database query failed") - expect(result.description).not.toContain("DB::Exception") - expect(result.description).not.toContain("spanHierarchy") - }) - - it("formats WarehouseUpstreamError as transient", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseUpstreamError", - message: "Request failed with status 503", - pipe: "listLogs", - upstreamStatus: 503, - }) - expect(result.title).toBe("Database is temporarily unavailable") - expect(result.description).toContain("503") - }) - - it("formats WarehouseAuthError as a credentials issue", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseAuthError", - message: "Request failed with status 401", - pipe: "listLogs", - upstreamStatus: 401, - }) - expect(result.title).toBe("Database rejected our credentials") - expect(result.description).toContain("invalid or expired") - }) - - it("formats WarehouseConfigError as a configuration issue", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseConfigError", - message: "Database default does not exist", - pipe: "sqlQuery", - clickhouseType: "UNKNOWN_DATABASE", - }) - expect(result.title).toBe("Database is not configured correctly") - expect(result.description).toBe("Database is not configured correctly.") - expect(result.description).not.toContain("default") - }) - - it("formats WarehouseClientError as a decode issue", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseClientError", - message: "Unexpected token '<'", - pipe: "sqlQuery", - }) - expect(result.title).toBe("Database response could not be decoded") - expect(result.description).toBe("Database response could not be decoded.") - expect(result.description).not.toContain("Unexpected token") - }) - - it("formats WarehouseSchemaDriftError with a schema-apply hint", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseSchemaDriftError", - message: "Unknown identifier 'SampleRate'", - pipe: "service_overview", - }) - expect(result.title).toBe("Database schema is out of date") - expect(result.description).toContain("schema apply") - }) - - it("formats decode-kind WarehouseSchemaDriftError without the schema-apply hint", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseSchemaDriftError", - message: "Compiled query row 0 did not match its declared output schema", - kind: "decode", - pipe: "serviceOverview", - }) - expect(result.description).not.toContain("schema apply") - expect(result.description).toContain("Maple bug") - }) - - it("formats WarehouseMalformedQueryError as a Maple bug, not a database problem", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseMalformedQueryError", - message: "NO_COMMON_TYPE: There is no supertype for types UInt64, Float64", - pipe: "traces_timeseries", - }) - expect(result.title).toBe("This chart hit a bug in Maple") - expect(result.description).toContain("our fault") - expect(result.description).not.toContain("schema apply") - }) - - it("gives every warehouse tag a specific title", () => { - for (const tag of WAREHOUSE_ERROR_TAGS) { - const result = formatBackendError({ _tag: tag, message: "boom" }) - expect(result.title, tag).not.toBe("Something went wrong") - } - }) - - it("formats WarehouseValidationError as an invalid query", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseValidationError", - message: "SQL query must contain OrgId filter", - pipe: "sqlQuery", - }) - expect(result.title).toBe("Invalid query") - expect(result.description).toContain("OrgId") - }) - - it("rewrites WarehouseQueryError when message leaks a 5xx status", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseQueryError", - message: "Request failed with status 521: error code: 521", - pipe: "sqlQuery", - }) - expect(result.title).toBe("Database is temporarily unavailable") - expect(result.description).toContain("521") - }) - it("does not leak the (sqlQuery) pipe suffix", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseQueryError", - message: "DB::Exception: out of memory", - pipe: "sqlQuery", + expect(displayError(error)).toMatchObject({ + title: "Query failed", + message: "The aggregation query could not be completed.", }) - expect(result.description).not.toContain("sqlQuery") - expect(result.description).toBe("Database query failed") - expect(result.description).not.toContain("DB::Exception") }) - it("strips raw nginx HTML and converts leaked 503 to a friendly message", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/WarehouseQueryError", - message: - "Request failed with status 503: 503 Service Temporarily Unavailable

503 Service Temporarily Unavailable


nginx
", - pipe: "sqlQuery", + it("finds a declared error in an Effect cause", () => { + const input = errorEnvelope({ + _tag: "@maple/http/errors/DashboardNotFoundError", + type: "not_found_error", + code: "dashboard_not_found", + title: "Dashboard not found", + message: "No such dashboard.", + recovery: "none", }) - expect(result.description).not.toContain("") - expect(result.description).not.toContain("") - expect(result.title).toBe("Database is temporarily unavailable") - expect(result.description).toContain("503") - }) - it("formats UnauthorizedError", () => { - const result = formatBackendError({ - _tag: "@maple/http/errors/UnauthorizedError", - }) - expect(result.title).toBe("Sign in required") - expect(result.recovery).toEqual({ kind: "reauth" }) + expect(displayError(Cause.fail(input))).toBe(input.error) }) - it("tags transport HttpClientError as a network error", () => { + it("expresses typed transport failures in the public contract", () => { const error = new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ - request: HttpClientRequest.get("https://api.maple.dev/v1/services"), + request: HttpClientRequest.get("https://api.maple.dev/v2/services"), }), }) - const result = formatBackendError(error) - expect(result.title).toBe("Cannot reach Maple API") - expect(result.kind).toBe("network") - expect(result.recovery).toEqual({ kind: "retry", automatic: true }) + const displayed = displayError(error) + + expect(displayed).toEqual({ + _tag: NetworkErrorTag, + type: "api_error", + code: "network_unreachable", + title: "Cannot reach Maple API", + message: "Check your connection. Data will resume once the API is reachable.", + retryable: true, + recovery: "retry", + }) + expect(isAutomaticRetryError(displayed)).toBe(true) }) - it("treats client abort timeouts as manual retry, not connectivity polling", () => { + it("keeps typed transport timeouts manual", () => { const error = new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ - request: HttpClientRequest.get("https://api.maple.dev/v1/services"), + request: HttpClientRequest.get("https://api.maple.dev/v2/services"), cause: new DOMException("timed out", "TimeoutError"), }), }) - const result = formatBackendError(error) - expect(result.category).toBe("timeout") - expect(result.kind).toBeUndefined() - expect(result.recovery).toEqual({ kind: "retry", automatic: false }) - }) - - it("tags fetch-failure Error messages as network errors", () => { - const result = formatBackendError(new Error("Failed to fetch")) - expect(result.title).toBe("Cannot reach Maple API") - expect(result.kind).toBe("network") - }) + const displayed = displayError(error) - it("does not tag non-network errors", () => { - expect(formatBackendError(new Error("boom")).kind).toBeUndefined() - }) - - it("falls back for plain Error", () => { - const result = formatBackendError(new Error("boom")) - expect(result.title).toBe("Something went wrong") - expect(result.description).not.toContain("boom") - expect(result.recognized).toBe(false) + expect(displayed).toMatchObject({ + _tag: "@maple/web/errors/TimeoutError", + message: "The API did not respond in time. Try again when you're ready.", + recovery: "retry", + }) + expect(isAutomaticRetryError(displayed)).toBe(false) }) - it("falls back for unknown shapes", () => { - expect(formatBackendError("string error").description).not.toContain("string error") - expect(formatBackendError(null).title).toBe("Something went wrong") - expect(formatBackendError(undefined).title).toBe("Something went wrong") + it("does not interpret raw tags or human-readable messages", () => { + for (const error of [ + { _tag: "@maple/http/errors/DashboardNotFoundError", message: "No such dashboard." }, + new Error("Failed to fetch"), + new Error("request timed out"), + ]) { + expect(displayError(error)._tag).toBe(UnexpectedErrorTag) + } }) - it("reads message from object-shaped errors without _tag", () => { - const result = formatBackendError({ message: "raw message" }) - expect(result.title).toBe("Something went wrong") - expect(result.description).not.toContain("raw message") - }) + it("keeps unknown text out of the public fallback", () => { + const displayed = displayError("postgres://secret@internal:5432 failed") - it("retains raw technical text only in diagnostics", () => { - const result = normalizeAppError({ - _tag: "@maple/http/errors/DatabaseError", - message: "postgres://secret@internal:5432 failed", - }) - expect(result.description).not.toContain("postgres") - expect(result.diagnostics.technicalMessage).toContain("postgres://secret") + expect(isUnexpectedError(displayed)).toBe(true) + expect(displayed.message).not.toContain("postgres") }) - it("finds a structured API failure nested under a generic cause", () => { - const result = formatBackendError({ - message: "request wrapper failed", - cause: { - error: { - type: "not_found_error", - code: "dashboard_not_found", - message: "No such dashboard.", - }, - }, - }) - expect(result).toMatchObject({ - title: "Not found", - category: "not-found", - code: "dashboard_not_found", + it("represents stale chunks in the same public contract", () => { + expect( + displayError(new Error("Failed to fetch dynamically imported module: /assets/settings.js")), + ).toEqual({ + _tag: "@maple/web/errors/StaleChunkError", + type: "api_error", + code: "stale_chunk", + title: "Maple was updated", + message: "Reload to use the latest version.", + retryable: false, + recovery: "refresh", }) }) }) diff --git a/apps/web/src/lib/error-messages.ts b/apps/web/src/lib/error-messages.ts index b8d742da7..dd335e0ac 100644 --- a/apps/web/src/lib/error-messages.ts +++ b/apps/web/src/lib/error-messages.ts @@ -1,629 +1,135 @@ -import { Cause, Exit, Option } from "effect" +import { Cause, Exit, Option, Schema } from "effect" import { HttpClientError } from "effect/unstable/http" import { - cleanErrorMessage, - isWarehouseErrorTag, - presentWarehouseError, - presentWarehouseErrorPublic, - warehouseErrorMeta, - type WarehouseErrorLike, -} from "@maple/domain" -import { formatRelativeFrom } from "@maple/ui/lib/time-format" + PublicHttpErrorBodySchema, + type AnyPublicHttpErrorBody, + type HttpErrorRecovery, +} from "@maple/domain/http" import { isChunkLoadError } from "./chunk-reload" -export type ErrorCategory = - | "validation" - | "authentication" - | "permission" - | "not-found" - | "conflict" - | "rate-limit" - | "network" - | "timeout" - | "upstream" - | "server" - | "client" +export const NetworkErrorTag = "@maple/web/errors/NetworkError" as const +export const UnexpectedErrorTag = "@maple/web/errors/UnexpectedError" as const -export type ErrorRecovery = - | { readonly kind: "none" } - | { readonly kind: "retry"; readonly automatic: boolean } - | { readonly kind: "reauth" } - | { readonly kind: "fix-input"; readonly param?: string } - | { readonly kind: "reload" } - -export interface ErrorDiagnostics { - /** Raw telemetry-only value; never render. */ - readonly value: unknown - readonly tag?: string - readonly technicalMessage?: string -} - -export interface NormalizedAppError { - readonly category: ErrorCategory - readonly tag?: string - readonly code?: string - readonly status?: number - readonly param?: string - readonly docUrl?: string - readonly retryAfterSeconds?: number - readonly retryAt?: string - readonly recovery: ErrorRecovery - readonly title: string - readonly description: string - readonly recognized: boolean - readonly diagnostics: ErrorDiagnostics -} - -export interface FormattedError { - readonly title: string - readonly description: string - readonly category: ErrorCategory - readonly tag?: string - readonly code?: string - readonly status?: number - readonly param?: string - readonly docUrl?: string - readonly retryAfterSeconds?: number - readonly retryAt?: string - readonly recovery: ErrorRecovery - readonly recognized: boolean - /** Compatibility signal for callers not yet using `recovery`. */ - readonly kind?: "network" -} - -const hasTag = (value: unknown): value is { _tag: string; [key: string]: unknown } => - typeof value === "object" && - value !== null && - "_tag" in value && - typeof (value as { _tag: unknown })._tag === "string" - -const rawStringField = (value: unknown, key: string): string | undefined => { - if (typeof value === "object" && value !== null && key in value) { - const field = (value as Record<string, unknown>)[key] - if (typeof field === "string") return field - } - return undefined -} - -const stringField = (value: unknown, key: string): string | undefined => { - const field = rawStringField(value, key) - return field === undefined ? undefined : cleanErrorMessage(field) -} - -const stringArrayField = (value: unknown, key: string): ReadonlyArray<string> | undefined => { - if (typeof value === "object" && value !== null && key in value) { - const field = (value as Record<string, unknown>)[key] - if (Array.isArray(field)) return field.filter((item): item is string => typeof item === "string") - } - return undefined -} - -const unwrap = (error: unknown): unknown => { - if (Cause.isCause(error)) { - return Option.getOrElse(Cause.findErrorOption(error), () => error) - } - if (Exit.isExit(error)) { - return Option.getOrElse(Exit.findErrorOption(error), () => error) - } - return error -} - -export interface V2ErrorInfo { - readonly tag?: string - readonly type: string +export interface ClientErrorDefinition { + readonly _tag: `@maple/web/errors/${string}` readonly code: string + readonly title: string readonly message: string - readonly title?: string - readonly retryable?: boolean - readonly recovery?: string - readonly retryAfterSeconds?: number - readonly retryAt?: string - readonly param?: string - readonly docUrl?: string -} - -const booleanField = (value: unknown, key: string): boolean | undefined => { - if (typeof value !== "object" || value === null || !(key in value)) return undefined - const field = (value as Record<string, unknown>)[key] - return typeof field === "boolean" ? field : undefined + readonly retryable: boolean + readonly recovery: HttpErrorRecovery } -const numberField = (value: unknown, key: string): number | undefined => { - if (typeof value !== "object" || value === null || !(key in value)) return undefined - const field = (value as Record<string, unknown>)[key] - return typeof field === "number" && Number.isFinite(field) ? field : undefined -} -export const v2ErrorInfo = (input: unknown): V2ErrorInfo | null => { - const error = unwrap(input) - if (typeof error !== "object" || error === null || !("error" in error)) return null - const body = (error as { error: unknown }).error - const type = stringField(body, "type") - const code = stringField(body, "code") - const message = stringField(body, "message") - if (type === undefined || code === undefined || message === undefined) return null - const tag = rawStringField(body, "_tag") - const title = stringField(body, "title") - const retryable = booleanField(body, "retryable") - const recovery = stringField(body, "recovery") - const retryAfterSeconds = numberField(body, "retry_after_seconds") - const retryAt = stringField(body, "retry_at") - const param = stringField(body, "param") - const docUrl = stringField(body, "doc_url") - return { - type, - code, - message, - ...(tag === undefined ? {} : { tag }), - ...(title === undefined ? {} : { title }), - ...(retryable === undefined ? {} : { retryable }), - ...(recovery === undefined ? {} : { recovery }), - ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), - ...(retryAt === undefined ? {} : { retryAt }), - ...(param === undefined ? {} : { param }), - ...(docUrl === undefined ? {} : { docUrl }), - } -} - -// A preceding "at " is swallowed because the replacement supplies its own -// preposition: "Resets at 2026-08-10T00:00:00.000Z" becomes "Resets in 7h". -const ISO_INSTANT = /(?:\bat )?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)/g - -export const humanizeInstants = (message: string, nowMs: number = Date.now()): string => - message.replace(ISO_INSTANT, (match, iso: string) => { - const epochMs = Date.parse(iso) - return Number.isFinite(epochMs) ? formatRelativeFrom(epochMs, nowMs) : match - }) - -const V2_TYPE_META: Record< - string, - { readonly title: string; readonly category: ErrorCategory; readonly status: number } -> = { - invalid_request_error: { title: "Invalid request", category: "validation", status: 400 }, - authentication_error: { title: "Sign in required", category: "authentication", status: 401 }, - permission_error: { title: "Permission required", category: "permission", status: 403 }, - not_found_error: { title: "Not found", category: "not-found", status: 404 }, - conflict_error: { title: "Could not save changes", category: "conflict", status: 409 }, - rate_limit_error: { title: "Too many requests", category: "rate-limit", status: 429 }, - api_error: { title: "Maple is temporarily unavailable", category: "server", status: 500 }, -} +export const makeClientErrorBody = (definition: ClientErrorDefinition): AnyPublicHttpErrorBody => ({ + type: "api_error", + ...definition, +}) -const V2_CODE_TITLES: Record<string, string> = { - investigation_daily_quota: "Investigation limit reached", - range_too_large: "Requested range is too large", - dashboard_concurrent_update: "Dashboard changed elsewhere", - service_unavailable: "Maple is temporarily unavailable", - upstream_error: "Connected service is unavailable", -} +const NetworkError = makeClientErrorBody({ + _tag: NetworkErrorTag, + code: "network_unreachable", + title: "Cannot reach Maple API", + message: "Check your connection. Data will resume once the API is reachable.", + retryable: true, + recovery: "retry", +}) -const recoveryFor = (category: ErrorCategory, param?: string): ErrorRecovery => { - switch (category) { - case "validation": - return { kind: "fix-input", ...(param === undefined ? {} : { param }) } - case "authentication": - return { kind: "reauth" } - case "conflict": - case "rate-limit": - case "upstream": - case "server": - case "timeout": - return { kind: "retry", automatic: false } - case "network": - return { kind: "retry", automatic: true } - default: - return { kind: "none" } - } -} +const TimeoutError = makeClientErrorBody({ + _tag: "@maple/web/errors/TimeoutError", + code: "request_timeout", + title: "Request timed out", + message: "The API did not respond in time. Try again when you're ready.", + retryable: true, + recovery: "retry", +}) -interface NormalizedFields { - readonly category: ErrorCategory - readonly title: string - readonly description: string - readonly code?: string - readonly status?: number - readonly param?: string - readonly docUrl?: string - readonly retryAfterSeconds?: number - readonly retryAt?: string - readonly recovery?: ErrorRecovery - readonly recognized?: boolean - readonly tag?: string - readonly technicalMessage?: string -} +const InvalidUrlError = makeClientErrorBody({ + _tag: "@maple/web/errors/InvalidUrlError", + code: "invalid_request_url", + title: "This request could not be sent", + message: "Reload Maple. If the problem continues, contact support.", + retryable: false, + recovery: "refresh", +}) -const normalized = (value: unknown, fields: NormalizedFields): NormalizedAppError => ({ - category: fields.category, - title: fields.title, - description: humanizeInstants(fields.description), - recovery: fields.recovery ?? recoveryFor(fields.category, fields.param), - recognized: fields.recognized ?? true, - ...(fields.tag === undefined ? {} : { tag: fields.tag }), - ...(fields.code === undefined ? {} : { code: fields.code }), - ...(fields.status === undefined ? {} : { status: fields.status }), - ...(fields.param === undefined ? {} : { param: fields.param }), - ...(fields.docUrl === undefined ? {} : { docUrl: fields.docUrl }), - ...(fields.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: fields.retryAfterSeconds }), - ...(fields.retryAt === undefined ? {} : { retryAt: fields.retryAt }), - diagnostics: { - value, - ...(fields.tag === undefined ? {} : { tag: fields.tag }), - ...(fields.technicalMessage === undefined ? {} : { technicalMessage: fields.technicalMessage }), - }, +const HttpRequestError = makeClientErrorBody({ + _tag: "@maple/web/errors/HttpRequestError", + code: "http_request_failed", + title: "The request failed", + message: "Maple could not complete this request.", + retryable: false, + recovery: "none", }) -const recoveryFromV2 = (v2: V2ErrorInfo): ErrorRecovery | undefined => { - switch (v2.recovery) { - case "fix_request": - return { kind: "fix-input", ...(v2.param === undefined ? {} : { param: v2.param }) } - case "reauthenticate": - return { kind: "reauth" } - case "retry": - return { kind: "retry", automatic: false } - case "refresh": - return { kind: "reload" } - case "none": - case "request_access": - case "reconnect": - case "contact_support": - return { kind: "none" } - default: - return v2.retryable === undefined - ? undefined - : v2.retryable - ? { kind: "retry", automatic: false } - : { kind: "none" } - } -} +const StaleChunkError = makeClientErrorBody({ + _tag: "@maple/web/errors/StaleChunkError", + code: "stale_chunk", + title: "Maple was updated", + message: "Reload to use the latest version.", + retryable: false, + recovery: "refresh", +}) -const normalizeV2 = (value: unknown, v2: V2ErrorInfo): NormalizedAppError => { - const meta = V2_TYPE_META[v2.type] ?? { - title: "Something went wrong", - category: "server" as const, - status: 500, - } - const category = - v2.type === "api_error" && (v2.code.includes("upstream") || v2.code.endsWith("_unavailable")) - ? "upstream" - : meta.category - return normalized(value, { - category, - title: v2.title ?? V2_CODE_TITLES[v2.code] ?? meta.title, - description: v2.message, - code: v2.code, - status: meta.status, - recovery: recoveryFromV2(v2), - ...(v2.tag === undefined ? {} : { tag: v2.tag }), - ...(v2.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: v2.retryAfterSeconds }), - ...(v2.retryAt === undefined ? {} : { retryAt: v2.retryAt }), - ...(v2.param === undefined ? {} : { param: v2.param }), - ...(v2.docUrl === undefined ? {} : { docUrl: v2.docUrl }), - }) -} +const UnexpectedError = makeClientErrorBody({ + _tag: UnexpectedErrorTag, + code: "unexpected_error", + title: "Something went wrong", + message: "An unexpected error occurred. Try again, or reload if the problem continues.", + retryable: false, + recovery: "refresh", +}) -const warehouseCategory = (tag: WarehouseErrorLike["_tag"]): ErrorCategory => { - switch (tag) { - case "@maple/http/errors/WarehouseValidationError": - case "@maple/http/errors/WarehouseConfigError": - return "validation" - case "@maple/http/errors/WarehouseQuotaExceededError": - return "rate-limit" - case "@maple/http/errors/WarehouseAuthError": - return "authentication" - case "@maple/http/errors/WarehouseUpstreamError": - case "@maple/http/errors/WarehouseQueryError": - case "@maple/http/errors/WarehouseClientError": - return "upstream" - default: - return "server" - } -} +const isPublicErrorBody = Schema.is(PublicHttpErrorBodySchema) -const normalizeWarehouse = ( - value: { _tag: string; [key: string]: unknown }, - tag: WarehouseErrorLike["_tag"], -): NormalizedAppError => { - const like: WarehouseErrorLike = { - _tag: tag, - ...(typeof value.message === "string" ? { message: value.message } : {}), - ...(typeof value.setting === "string" ? { setting: value.setting } : {}), - ...(typeof value.upstreamStatus === "number" ? { upstreamStatus: value.upstreamStatus } : {}), - ...(typeof value.kind === "string" ? { kind: value.kind } : {}), - } - const withDetails = presentWarehouseError(like) - const withoutDetails = presentWarehouseErrorPublic(like) - // Validation copy is authored by Maple and directly helps the user. A generic - // query error may also be reclassified from an embedded status into safe, - // Maple-authored outage copy. Everything else drops driver/SQL/decoder text. - const presentation = - tag === "@maple/http/errors/WarehouseValidationError" || - withDetails.title !== warehouseErrorMeta[tag].title - ? withDetails - : withoutDetails - const category = - withDetails.title === warehouseErrorMeta["@maple/http/errors/WarehouseUpstreamError"].title - ? "upstream" - : warehouseCategory(tag) - return normalized(value, { - category, - code: warehouseErrorMeta[tag].code, - title: presentation.title, - description: presentation.description, - tag, - technicalMessage: rawStringField(value, "message"), - }) +const unwrap = (error: unknown): unknown => { + if (Cause.isCause(error)) return Option.getOrElse(Cause.findErrorOption(error), () => error) + if (Exit.isExit(error)) return Option.getOrElse(Exit.findErrorOption(error), () => error) + return error } -const isTimeoutCause = (cause: unknown): boolean => - (typeof DOMException !== "undefined" && - cause instanceof DOMException && - (cause.name === "TimeoutError" || cause.name === "AbortError")) || - (typeof cause === "object" && - cause !== null && - "name" in cause && - ((cause as { name?: unknown }).name === "TimeoutError" || - (cause as { name?: unknown }).name === "AbortError")) - -const normalizeHttpClientError = (value: HttpClientError.HttpClientError): NormalizedAppError => { - const status = value.response?.status - if (status === 401) { - return normalized(value, { - category: "authentication", - status, - title: "Sign in required", - description: "Your session may have expired. Sign in again to continue.", - technicalMessage: value.message, - }) - } - if (status === 403) { - return normalized(value, { - category: "permission", - status, - title: "Permission required", - description: "You do not have permission to perform this action.", - technicalMessage: value.message, - }) - } - if (status === 429) { - return normalized(value, { - category: "rate-limit", - status, - title: "Too many requests", - description: "Wait a moment, then try again.", - technicalMessage: value.message, - }) - } - if (status === 504) { - return normalized(value, { - category: "timeout", - status, - title: "Request timed out", - description: "The request took too long. Narrow the time range or add filters, then try again.", - technicalMessage: value.message, - }) - } - if (value.reason._tag === "TransportError") { - if (isTimeoutCause(value.reason.cause)) { - return normalized(value, { - category: "timeout", - title: "Request timed out", - description: "The API did not respond in time. Try again when you're ready.", - technicalMessage: value.message, - }) - } - return normalized(value, { - category: "network", - title: "Cannot reach Maple API", - description: "Check your connection. Data will resume once the API is reachable.", - technicalMessage: value.message, - }) - } - if (value.reason._tag === "InvalidUrlError") { - return normalized(value, { - category: "client", - title: "This request could not be sent", - description: "Reload Maple. If the problem continues, contact support.", - recovery: { kind: "reload" }, - technicalMessage: value.message, - }) - } - if (status !== undefined && status >= 500) { - return normalized(value, { - category: "server", - status, - title: "Maple is temporarily unavailable", - description: "The API could not complete the request. Try again in a moment.", - technicalMessage: value.message, - }) - } - return normalized(value, { - category: "client", - ...(status === undefined ? {} : { status }), - title: "The request failed", - description: "Maple could not complete this request.", - technicalMessage: value.message, - }) -} +const nestedCause = (value: unknown): unknown => + typeof value === "object" && value !== null && "cause" in value + ? (value as { readonly cause: unknown }).cause + : undefined -const normalizeTaggedError = (value: { _tag: string; [key: string]: unknown }): NormalizedAppError | null => { - const tag = value._tag - const technicalMessage = rawStringField(value, "message") - const safeMessage = stringField(value, "message") - if (tag === "@maple/http/errors/QueryEngineTimeoutError") { - return normalized(value, { - category: "timeout", - title: "Query timed out", - description: "The query took longer than 30 seconds. Narrow the time range or add filters.", - tag, - technicalMessage, - }) - } - if (tag === "@maple/http/errors/QueryEngineValidationError") { - const message = safeMessage ?? "Invalid query parameters" - const details = stringArrayField(value, "details") ?? [] - return normalized(value, { - category: "validation", - title: message, - description: details.length > 0 ? details.join("; ") : message, - tag, - technicalMessage, - }) - } - if (tag === "@maple/http/errors/QueryEngineExecutionError") { - return normalized(value, { - category: "server", - title: "Query failed", - description: "Maple could not run this query. Try again or adjust the query if it keeps failing.", - tag, - technicalMessage: rawStringField(value, "causeMessage") ?? technicalMessage, - }) - } - if (tag.endsWith("/UnauthorizedError") || tag.endsWith('AuthenticationError')) { - return normalized(value, { - category: "authentication", - title: "Sign in required", - description: "Your session may have expired. Sign in again to continue.", - tag, - technicalMessage, - }) - } - if (/PermissionError$|ForbiddenError$/.test(tag)) { - return normalized(value, { - category: "permission", - title: "Permission required", - description: safeMessage ?? "You do not have permission to perform this action.", - tag, - technicalMessage, - }) - } - if (/ValidationError$|InvalidRequestError$|InvalidInputError$/.test(tag)) { - return normalized(value, { - category: "validation", - title: "Check the entered values", - description: safeMessage ?? "One or more values are invalid.", - tag, - technicalMessage, - }) - } - if (tag.endsWith('NotFoundError')) { - return normalized(value, { - category: "not-found", - title: "Not found", - description: safeMessage ?? "That item no longer exists or you cannot access it.", - tag, - technicalMessage, - }) - } - if (/ConflictError$|ConcurrentUpdateError$|InUseError$/.test(tag)) { - return normalized(value, { - category: "conflict", - title: "Could not save changes", - description: - safeMessage ?? "The item changed while you were editing it. Review it and try again.", - tag, - technicalMessage, - }) - } - if (/RateLimitError$|QuotaExceededError$/.test(tag)) { - return normalized(value, { - category: "rate-limit", - title: "Limit reached", - description: safeMessage ?? "Wait a moment, then try again.", - tag, - technicalMessage, - }) - } - return null +/** Read the public body shared by decoded HTTP responses and live tagged errors. */ +export const publicError = (input: unknown): AnyPublicHttpErrorBody | null => { + const value = unwrap(input) + if (isPublicErrorBody(value)) return value + if (typeof value !== "object" || value === null || !("error" in value)) return null + const body = (value as { readonly error: unknown }).error + return isPublicErrorBody(body) ? body : null } -const nestedCause = (value: unknown): unknown => { - if (typeof value !== "object" || value === null || !("cause" in value)) return undefined - return (value as { cause?: unknown }).cause -} +const isTimeoutException = (value: unknown): boolean => + typeof DOMException !== "undefined" && value instanceof DOMException && value.name === "TimeoutError" -const normalizeInternal = (input: unknown, depth: number): NormalizedAppError => { +const displayErrorInternal = (input: unknown, depth: number): AnyPublicHttpErrorBody => { const value = unwrap(input) - const v2 = v2ErrorInfo(value) - if (v2 !== null) return normalizeV2(value, v2) + const declared = publicError(value) + if (declared !== null) return declared - if (hasTag(value) && isWarehouseErrorTag(value._tag)) return normalizeWarehouse(value, value._tag) - if (hasTag(value)) { - const tagged = normalizeTaggedError(value) - if (tagged !== null) return tagged + if (HttpClientError.isHttpClientError(value)) { + if (value.reason._tag === "TransportError") { + return isTimeoutException(value.reason.cause) ? TimeoutError : NetworkError + } + return value.reason._tag === "InvalidUrlError" ? InvalidUrlError : HttpRequestError } - if (HttpClientError.isHttpClientError(value)) return normalizeHttpClientError(value) - - if (isChunkLoadError(value)) { - return normalized(value, { - category: "client", - title: "Maple was updated", - description: "Reload to use the latest version.", - recovery: { kind: "reload" }, - technicalMessage: value instanceof Error ? value.message : undefined, - }) - } + if (isChunkLoadError(value)) return StaleChunkError const cause = nestedCause(value) if (depth < 4 && cause !== undefined && cause !== value) { - const nested = normalizeInternal(cause, depth + 1) - if (nested.recognized) { - return { ...nested, diagnostics: { ...nested.diagnostics, value } } - } + const nested = displayErrorInternal(cause, depth + 1) + if (nested._tag !== UnexpectedErrorTag) return nested } - if (value instanceof Error) { - if (/transport error|failed to fetch|load failed|networkerror/i.test(value.message)) { - return normalized(value, { - category: "network", - title: "Cannot reach Maple API", - description: "Check your connection. Data will resume once the API is reachable.", - technicalMessage: value.message, - }) - } - if (value.name === "TimeoutError" || /timed? out|timeout/i.test(value.message)) { - return normalized(value, { - category: "timeout", - title: "Request timed out", - description: "The request did not finish in time. Try again when you're ready.", - technicalMessage: value.message, - }) - } - return normalized(value, { - category: "client", - title: "Something went wrong", - description: "An unexpected error occurred. Try again, or reload if the problem continues.", - recovery: { kind: "reload" }, - recognized: false, - technicalMessage: value.message, - }) - } - - return normalized(value, { - category: "client", - title: "Something went wrong", - description: "An unexpected error occurred. Try again, or reload if the problem continues.", - recovery: { kind: "reload" }, - recognized: false, - technicalMessage: rawStringField(value, "message") ?? (typeof value === "string" ? value : undefined), - }) + return UnexpectedError } -export const normalizeAppError = (input: unknown): NormalizedAppError => normalizeInternal(input, 0) +/** Resolve any application failure to the single safe public error contract. */ +export const displayError = (input: unknown): AnyPublicHttpErrorBody => displayErrorInternal(input, 0) -export const presentAppError = (error: NormalizedAppError): FormattedError => ({ - title: error.title, - description: error.description, - category: error.category, - recovery: error.recovery, - recognized: error.recognized, - ...(error.tag === undefined ? {} : { tag: error.tag }), - ...(error.code === undefined ? {} : { code: error.code }), - ...(error.status === undefined ? {} : { status: error.status }), - ...(error.param === undefined ? {} : { param: error.param }), - ...(error.docUrl === undefined ? {} : { docUrl: error.docUrl }), - ...(error.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: error.retryAfterSeconds }), - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - ...(error.recovery.kind === "retry" && error.recovery.automatic ? { kind: "network" as const } : {}), -}) +export const isAutomaticRetryError = (error: AnyPublicHttpErrorBody): boolean => + error._tag === NetworkErrorTag -export const formatBackendError = (input: unknown): FormattedError => - presentAppError(normalizeAppError(input)) +export const isUnexpectedError = (error: AnyPublicHttpErrorBody): boolean => error._tag === UnexpectedErrorTag diff --git a/apps/web/src/lib/error-toast.ts b/apps/web/src/lib/error-toast.ts index ecaa89840..b4577f6c2 100644 --- a/apps/web/src/lib/error-toast.ts +++ b/apps/web/src/lib/error-toast.ts @@ -1,5 +1,5 @@ import { toastManager } from "@maple/ui/components/ui/toast" -import { formatBackendError } from "./error-messages" +import { displayError, isUnexpectedError } from "./error-messages" interface ShowErrorToastOptions { readonly title?: string @@ -9,17 +9,19 @@ interface ShowErrorToastOptions { /** Keeps raw Cause/Exit/error messages in telemetry rather than UI copy. */ export const showErrorToast = (error: unknown, options: ShowErrorToastOptions = {}): void => { - const presentation = formatBackendError(error) + const presentation = displayError(error) toastManager.add({ title: options.title ?? - (presentation.recognized ? presentation.title : (options.fallbackTitle ?? presentation.title)), - description: presentation.description, + (isUnexpectedError(presentation) + ? (options.fallbackTitle ?? presentation.title) + : presentation.title), + description: presentation.message, type: options.type ?? "error", }) } export const errorMessage = (error: unknown, fallback: string): string => { - const presentation = formatBackendError(error) - return presentation.recognized ? presentation.description : fallback + const presentation = displayError(error) + return isUnexpectedError(presentation) ? fallback : presentation.message } diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index 75b5cd528..7202f94dd 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { Exit, Schema } from "effect" import { Fragment, useCallback, useMemo, useRef, useState } from "react" @@ -489,7 +489,7 @@ function RuleDetailContent() { <EmptyTitle>Failed to load alert rule</EmptyTitle> <EmptyDescription> {Result.builder(rulesResult) - .onError((error) => formatBackendError(error).description) + .onError((error) => displayError(error).message) .orElse(() => undefined) ?? "Try refreshing or check API logs."} </EmptyDescription> </EmptyHeader> @@ -885,7 +885,7 @@ function RuleDetailContent() { </EmptyMedia> <EmptyTitle>Failed to load checks</EmptyTitle> <EmptyDescription> - {formatBackendError(error).description} + {displayError(error).message} </EmptyDescription> </EmptyHeader> <Button @@ -934,9 +934,7 @@ function RuleDetailContent() { <CircleWarningIcon size={18} /> </EmptyMedia> <EmptyTitle>Failed to load incidents</EmptyTitle> - <EmptyDescription> - {formatBackendError(error).description} - </EmptyDescription> + <EmptyDescription>{displayError(error).message}</EmptyDescription> </EmptyHeader> <Button variant="outline" diff --git a/apps/web/src/routes/anomalies/$incidentId.tsx b/apps/web/src/routes/anomalies/$incidentId.tsx index 8961f9670..903927db3 100644 --- a/apps/web/src/routes/anomalies/$incidentId.tsx +++ b/apps/web/src/routes/anomalies/$incidentId.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { Exit, Schema } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" @@ -222,8 +222,8 @@ function AnomalyDetailBody({ await navigate({ to: "/investigations/$id", params: { id: result.value.id } }) return } - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } finally { setBusy(false) } diff --git a/apps/web/src/routes/dashboards/templates.tsx b/apps/web/src/routes/dashboards/templates.tsx index f6ab5de23..c8c0d1225 100644 --- a/apps/web/src/routes/dashboards/templates.tsx +++ b/apps/web/src/routes/dashboards/templates.tsx @@ -11,7 +11,7 @@ import { LIST_LIMIT_MAX } from "@maple/domain/http/v2" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { useDashboardMutationSync } from "@/hooks/use-dashboard-store" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { TemplateList, type ReadinessFilter } from "@/components/dashboard-builder/templates/template-list" import { TemplateDetailPanel } from "@/components/dashboard-builder/templates/template-detail-panel" @@ -124,8 +124,8 @@ function TemplatesPage() { setCreating(false) if (Exit.isFailure(result)) { - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) return } diff --git a/apps/web/src/routes/errors/issues/$issueId.tsx b/apps/web/src/routes/errors/issues/$issueId.tsx index 9900a1c13..fdc5de1d3 100644 --- a/apps/web/src/routes/errors/issues/$issueId.tsx +++ b/apps/web/src/routes/errors/issues/$issueId.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import { Exit, Schema } from "effect" import { useMemo, useState } from "react" import { toastManager } from "@maple/ui/components/ui/toast" @@ -327,8 +327,8 @@ function IssueDetailPage() { params: { id: result.value.id }, }) } else { - const { title, description } = formatBackendError(result) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) } } diff --git a/apps/web/src/routes/investigations/index.tsx b/apps/web/src/routes/investigations/index.tsx index a4587b623..33dda987b 100644 --- a/apps/web/src/routes/investigations/index.tsx +++ b/apps/web/src/routes/investigations/index.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Exit, Schema } from "effect" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { formatBackendError } from "@/lib/error-messages" +import { displayError } from "@/lib/error-messages" import type { V2Investigation } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" @@ -149,8 +149,8 @@ function InvestigationsHub() { if (Exit.isSuccess(created)) { void navigate({ to: "/investigations/$id", params: { id: created.value.id } }) } else { - const { title, description } = formatBackendError(created) - toastManager.add({ title, description, type: "error" }) + const { title, message } = displayError(created) + toastManager.add({ title, description: message, type: "error" }) } } diff --git a/apps/web/src/routes/recommendations/$recommendationKey.tsx b/apps/web/src/routes/recommendations/$recommendationKey.tsx index 6aa1bbe4f..2499ca0ba 100644 --- a/apps/web/src/routes/recommendations/$recommendationKey.tsx +++ b/apps/web/src/routes/recommendations/$recommendationKey.tsx @@ -8,6 +8,7 @@ import type { V2Recommendation } from "@maple/domain/http/v2" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" +import { displayError } from "@/lib/error-messages" import { ingestAttributeMappingsListAtom, recommendationIssuesListAtom, @@ -189,7 +190,7 @@ function RecommendationDetailPage() { return Result.builder(listResult) .onInitial(() => <LoadingShell />) - .onError((error) => <ErrorShell message={error.message} />) + .onError((error) => <ErrorShell message={displayError(error).message} />) .onSuccess(() => { if (!issue) return <InactiveShell /> return ( diff --git a/docs/api-v2.md b/docs/api-v2.md index b3b86e65f..b18d2e96e 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -1,6 +1,6 @@ # Maple v2 Public API -The Maple v2 API is the public, documented, stability-committed HTTP surface for everything the dashboard can do. It follows Stripe's API design philosophy — resource-oriented URLs, prefixed object IDs, uniform list/error envelopes, scoped keys — modernized where Stripe's v1 mechanics are legacy (JSON PATCH updates instead of form-encoded POST, ISO-8601 timestamps instead of epoch seconds). +The Maple v2 API is the public, documented, stability-committed HTTP surface for customer-stable Maple resources and workflows. It follows Stripe's API design philosophy — resource-oriented URLs, prefixed object IDs, uniform list/error envelopes, scoped keys — modernized where Stripe's v1 mechanics are legacy (JSON PATCH updates instead of form-encoded POST, ISO-8601 timestamps instead of epoch seconds). The **executable contract is the spec**: `MapleApiV2` in `packages/domain/src/http/v2/` (an Effect `HttpApi`). OpenAPI is derived from it automatically and served as an interactive reference at **`/v2/docs`**. This document is the design-guideline layer every v2 contract file must conform to, plus the roadmap for the full surface. @@ -13,7 +13,7 @@ The **executable contract is the spec**: `MapleApiV2` in `packages/domain/src/ht Dashboard-only operations — billing checkout/portal, onboarding state, demo seeding, AI chat apply, digest subscription, AI-triage settings, raw warehouse queries, and the error-agent claim/heartbeat/release loop — belong in the internal RPC tier. They use the same tenant resolution and org scoping but are **not** HTTP API groups and never appear in the public OpenAPI. Everything else is public API, and the dashboard consumes the same `/v2` endpoints customers do. -The v1 API (`/api/...`) stays mounted while the dashboard migrates group-by-group; each v1 group is deleted once nothing consumes it. **The RPC tier is Phase 3 and not built yet** — `packages/domain/src/internal-rpc.ts` holds service-to-service schemas, not `RpcGroup`s — so until it exists the only two real homes for a new operation are v2 or the legacy v1 group it would extend. New surface goes to v2; v1 only grows where an existing v1 group already owns the resource. +The v1 API (`/api/...`) stays mounted while the dashboard migrates group-by-group; each v1 group is deleted once nothing consumes it. The audited group-by-group destination and removal gate live in [`http-api-migration.md`](http-api-migration.md). **The RPC tier is Phase 3 and not built yet** — `packages/domain/src/internal-rpc.ts` holds service-to-service schemas, not `RpcGroup`s — so until it exists the only two real homes for a new operation are v2 or the legacy v1 group it would extend. New surface goes to v2; v1 only grows where an existing v1 group already owns the resource. ### Integration endpoints: which tier @@ -99,16 +99,18 @@ Every error response body uses this envelope: } ``` -- `type` is closed: `invalid_request_error` (400), `authentication_error` (401), `permission_error` (403), `not_found_error` (404), `conflict_error` (409), `rate_limit_error` (429), `api_error` (5xx). -- `_tag` is the stable semantic identity of the failure. Maple clients branch on it directly; registered domain adapters keep the same tag from the Effect error channel through the HTTP boundary. Errors created at the v2 boundary receive a namespaced tag derived from their stable code or endpoint. -- `code` is a stable machine-readable string (`api_key_not_found`, `alert_destination_in_use`, `api_key_lookup_unavailable`, `insufficient_scope`, `parameter_invalid`, …). Resource and dependency failures identify the affected resource and operation. Codes are append-only. +- `type` is closed: `invalid_request_error` (400), `authentication_error` (401), `permission_error` (403), `not_found_error` (404), `conflict_error` (409), `rate_limit_error` (429), `api_error` (500/502/503/504). +- `_tag` is required and is the stable semantic identity of the failure. Maple clients branch on it directly. Each operation's OpenAPI response is an `anyOf` of the literal tags that operation can actually return; `_tag: string` and generic status-family schemas are not valid endpoint contracts. Adding a new safe, documented tag is preferable to collapsing distinct failures into a generic unavailable/not-found error. Errors created at the v2 boundary use an explicit `defineV2Error` definition whose constructor and literal-tag schema cannot drift apart. +- `code` is a compact presentation category (`api_key_not_found`, `alert_destination_in_use`, `integration_upstream_error`, `parameter_invalid`, …). Several semantic tags may share a code, and a code may change when errors are regrouped. Clients that need exact branching use `_tag`. - `title` and `message` are safe, human-readable presentation copy. `retryable` says whether the same logical request can plausibly succeed later without correcting its input; automatic mutation replay still requires an idempotency key. `recovery` is one of `none`, `fix_request`, `reauthenticate`, `request_access`, `reconnect`, `refresh`, `retry`, or `contact_support`. - `retry_after_seconds` carries a relative delay; `retry_at` carries a known absolute reset time. When either is present the response also emits the standard `Retry-After` header. - `param` names the offending parameter when applicable; `doc_url` may link to reference docs. On a request-decode failure it carries the full JSON path of the bad value (`widgets[3].display.chart_presentation.fill_nulls`), and for a path inside a `widgets[]` array the `message` also names the enclosing widget's `id`. - Stack traces, driver messages, raw provider responses, and diagnostic causes never appear on the wire. `_tag` is an intentionally public semantic tag, not a leaked runtime class name. -- Expected internal failures use operation-specific tagged errors. Unexpected defects are logged with the group and operation, then returned as a sanitized `api_error` / `internal_error`; dependency messages are never copied to public 5xx responses. +- Expected failures remain distinct tagged errors through the service and route. Unexpected defects are logged with the group and operation, then returned as a sanitized `api_error` / `internal_error`; dependency messages are never copied to public 5xx responses. -Implementation: `packages/domain/src/http/v2/errors.ts`; request-decode failures are rewritten into the envelope with a structured `param` by `V2SchemaErrors`, and `V2UnexpectedErrors` provides the defect boundary (`apps/api/src/routes/v2/error-envelope.ts`). +Implementation: `packages/domain/src/http/error-policy.ts`, `packages/domain/src/http/v2/public-error.ts`, and `packages/domain/src/http/v2/errors.ts`. Request-decode failures are rewritten into the envelope with a structured `param` by `V2SchemaErrors`, while response-encoding schema failures are logged and sanitized as 500 because they are server contract bugs, not bad requests. `V2UnexpectedErrors` provides the defect boundary (`apps/api/src/routes/v2/error-envelope.ts`). Both transport middleware are attached once by `MapleApiV2`, so a new group cannot accidentally omit either boundary. + +Each expected domain error class is created with `HttpTaggedError` and owns its tag, status, stable code, safe copy policy, retry behavior, and recovery action. The error instance exposes its own safe `error` body from that policy, and `publicError(ErrorClass)` derives the endpoint's exact wire schema from the same definition. Handlers fail with the original tagged error; there is no route-level serializer, `mapError`, `catchTags`, or second per-domain table at the HTTP boundary. `exposure: "redacted"` requires separate Maple-authored copy at compile time, while the original internal failure remains available for logs and tracing. Boundary-only failures use `defineV2Error` and are emitted through that definition's `make` constructor. ### Authentication and scopes @@ -128,7 +130,7 @@ Implementation: `packages/domain/src/http/v2/auth.ts` + `apps/api/src/services/A ### Versioning - The `/v2` path prefix is the major version. Breaking changes require `/v3`. -- Within v2, changes are additive (new endpoints, new optional fields, new enum values documented as open sets, new error codes). +- Within v2, resource shapes evolve additively. Error `_tag` values are the compatibility identity; `code`, title, message, recovery hints, and the broad HTTP category are presentation and may be corrected without minting a new API version. - A `Maple-Version: YYYY-MM-DD` header is reserved for future in-v2 evolution; until multiple versions exist, it is accepted and ignored. ### Idempotency (Phase 4 — reserved) @@ -208,8 +210,9 @@ The dashboard can reconcile optimistic writes against ElectricSQL synced shapes ## Adding a v2 resource (checklist) -1. Contract in `packages/domain/src/http/v2/<resource>.ts`: snake_case wire schemas with an `object` literal and validated `Timestamp` fields; public IDs via `PublicId(prefix, InternalId)` (register the prefix in `public-id.ts`); lists use `ListQuery` + `ListOf`; errors from `v2/errors.ts` only; group `.prefix("/v2/<resource>")` + `.middleware(AuthorizationV2)` + `.middleware(V2SchemaErrors)`. +1. Contract in `packages/domain/src/http/v2/<resource>.ts`: snake_case wire schemas with an `object` literal and validated `Timestamp` fields; public IDs via `PublicId(prefix, InternalId)` (register the prefix in `public-id.ts`); lists use `ListQuery` + `ListOf`; every endpoint lists its exact error schemas with `publicError(ErrorClass)` and/or explicit boundary definitions (shared exhaustive sets such as `V2WarehouseErrors` are fine); group `.prefix("/v2/<resource>")` + `.middleware(AuthorizationV2)`. Request-validation, authorization, and unexpected-error middleware contribute their own exact tags API-wide; do not attach them per group. 2. Add the group to `MapleApiV2` in `v2/api.ts` and export from `v2/index.ts`. -3. Handlers in `apps/api/src/routes/v2/<resource>.http.ts`: thin adapters over the existing service — map camelCase/epoch-ms service responses to the wire model, map service tagged errors to envelope errors. Register the layer in `ApiV2Routes` (`apps/api/src/app.ts`). -4. Tests: wire-shape encode (snake_case, public ID, envelope), error mapping, and a PGlite service test if the service changed. -5. Confirm the resource renders correctly at `/v2/docs`. +3. Define expected failures with `HttpTaggedError` in the domain contract. Put their public status, code, safe-copy policy, retry behavior, and recovery action on the class. +4. Handlers in `apps/api/src/routes/v2/<resource>.http.ts`: thin adapters over the existing service — map camelCase/epoch-ms service responses to the wire model and let expected tagged errors pass through unchanged. Register the layer in `ApiV2Routes` (`apps/api/src/runtime/http-graph.ts`). +5. Tests: wire-shape encode (snake_case, public ID, envelope), public error serialization/redaction, and a PGlite service test if the service changed. +6. Confirm the resource renders correctly at `/v2/docs`. diff --git a/docs/http-api-migration.md b/docs/http-api-migration.md new file mode 100644 index 000000000..4b792fba5 --- /dev/null +++ b/docs/http-api-migration.md @@ -0,0 +1,89 @@ +# HTTP API migration and v1 retirement + +Status: implementation plan, audited 2026-08-13. + +This document decides where the remaining `/api/...` surface belongs and defines the gate for deleting it. The governing rule is consumer intent, not transport convenience: + +- `/v2` is the stable public resource API for customers, agents, IaC, and the dashboard. +- `/rpc` is the private dashboard transport for product workflows that can change with the UI. +- Raw `HttpRouter` routes remain version-neutral when an external protocol requires redirects, signatures, streaming, or a provider-owned response shape. +- No new endpoint is added to v1 unless it is required to complete a safe migration of an existing v1 group. + +## What can leave v1 first + +These groups already have a v2 replacement used by the repository. Mark the v1 operations deprecated now, stop feature work on them, and remove each group after the retirement gate below passes. + +| v1 group or provider | v2 replacement | action | +| ---------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiKeys` | `/v2/api_keys` | Retire the whole v1 group. | +| `ingestKeys` | `/v2/ingest_keys` | Retire the whole v1 group. | +| `ingestAttributeMappings` | `/v2/attribute_mappings` | Retire the whole v1 group. | +| `recommendationIssues` | `/v2/instrumentation/recommendations` | Retire the whole v1 group. | +| `scrapeTargets` | `/v2/scrape_targets` | Retire the whole v1 group. | +| `investigations` | `/v2/investigations` | Retire the whole v1 group. | +| PlanetScale operations in `integrations` | `/v2/integrations/planetscale` | Already deprecated. Split provider operations out of the monolithic v1 group so they can be deleted independently. Keep callback and webhook router paths. | + +`dashboards`, `anomalies`, and `sessionReplays` are close, but repository callers still use part of their v1 surface. Migrate those callers before starting the external-traffic clock: + +| v1 group | lift to v2 | do not lift | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `dashboards` | Any remaining dashboard CRUD/import/history operation whose v2 equivalent already exists. | Nothing dashboard-specific belongs in RPC while it manipulates the public dashboard resource. | +| `anomalies` | Move the dashboard's remaining resolve/link-issue/settings mutations to the implemented v2 operations. | None of the current resource operations. | +| `sessionReplays` | Use the implemented v2 search, retrieve, events, transcript, manifest, and trace lookup operations. Promote a facet only if customers or agents need it as a stable capability. | Dashboard-only facet exploration and trace-summary helpers start in RPC. | + +## Complete the public v2 resources + +These v1 groups mix public resource operations with private orchestration. Split them before migration; copying the group wholesale would freeze internal implementation details into the public API. + +| v1 group | promote to v2 | move to internal RPC | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `errors` | Issue events, comments, state transitions, assignee, and severity under `/v2/error_issues/{id}/...`. | Agent registration, claim, heartbeat, release, escalation-policy evaluation, and other worker coordination. | +| `organizations` + `orgClickHouseSettings` | Organization update/delete and customer-managed ClickHouse configuration as organization subresources, with explicit admin scopes. | Setup wizards or UI-only probes that merely coordinate several public operations. | +| `integrations` | Promote a provider only when a public resource or supported external automation needs it. Slack and PlanetScale already meet that bar. | Cloudflare, GitHub, and Hazel dashboard control surfaces remain private until public demand exists. Split providers into independent contracts so one provider does not block retirement of another. | +| `queryEngine`, `warehouse`, `observability` | Keep the existing stable telemetry resources: traces, logs, metrics, services, and service map. Add a specific public resource endpoint only after its request and response shape is stable. | Raw SQL, generic query documents, arbitrary warehouse execution, attribute/facet discovery used only by dashboard builders, infrastructure drill-down helpers, and provider-specific chart queries. | + +There must never be a generic `/v2/query`, `/v2/sql`, or public query-builder execution endpoint. Those contracts expose Maple's storage and dashboard implementation rather than a durable product resource. + +## Remove from the public-API plan + +The following v1 groups are dashboard workflows or protocol surfaces. Do not port them to v2. + +| v1 group | destination | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `billing` | Internal RPC for checkout, portal, attach/preview, and billing controls. Keep provider webhooks as raw signed receivers. | +| `onboarding` | Internal RPC. | +| `demo` | Internal RPC. | +| `chat` | Internal RPC for mutations; keep a raw streaming route if the transport requires it. | +| `digest` | Internal RPC until there is a separately designed public notification-subscription resource. | +| `aiTriage` | Internal RPC. | +| `auth` and `authPublic` | Keep standards-driven CLI/MCP/OAuth/JWT exchange routes version-neutral; they are authentication protocols, not v2 resources. | + +The following raw routes are intentional end-state routes, not v1 debt: + +- OAuth callbacks that return redirects or RFC-defined OAuth errors. +- Webhook receivers whose signatures, retry status, and body are defined by the provider. +- Internal scraper or worker routes protected by service credentials. +- Streaming endpoints that cannot use the regular JSON request/response contract. + +They still use typed internal failures and sanitized logging, but they keep their protocol-specific wire response instead of the v1 or v2 JSON envelope. + +## Retirement gate + +A v1 operation is deleted only when all five conditions pass: + +1. Repository search shows no production caller, including web, CLI, MCP, workers, examples, and tests that model a real client. +2. The v1 OpenAPI operation is marked deprecated and points to its v2 or RPC replacement. Public callers receive `Deprecation`, `Sunset`, and migration-link headers for the announced window. +3. Per-operation access telemetry shows zero legitimate calls for 30 consecutive days in every deployed stage. Provider callbacks, health probes, and scanners are classified separately. +4. Published SDK, Terraform/IaC, docs, and customer examples no longer generate the v1 call. +5. Contract, handler, route-graph registration, error types used only by that operation, and tests are deleted in the same change. + +If external traffic prevents removal, keep the compatibility adapter thin over the same service as v2. Do not add features or fork business logic in v1. + +## Execution order + +1. **Boundary consistency (this change):** apply API-wide v1 request-validation and defect middleware; apply the same v2 middleware once at `MapleApiV2`; distinguish request-decode failures from server-side response drift; define one exhaustive public policy map per domain error union and preserve every typed error's semantic tag through the v2 envelope. +2. **Deprecate complete duplicates:** `apiKeys`, `ingestKeys`, `ingestAttributeMappings`, `recommendationIssues`, `scrapeTargets`, `investigations`, and PlanetScale v1 operations. Add operation-level traffic counters before starting the 30-day clock. +3. **Finish near-complete resources:** move the remaining dashboard callers for dashboards, anomalies, and session replays to v2. +4. **Build the internal RPC tier:** move billing, onboarding, demo, chat apply, digest, AI triage, generic query/warehouse helpers, and error-agent coordination. Preserve the billing-specific authentication retry behavior when its client moves. +5. **Split mixed v1 groups:** separate `errors`, provider integrations, and query/warehouse operations so public promotions and private RPC moves can be retired independently. +6. **Delete by evidence:** remove each empty v1 group as soon as its retirement gate passes; do not wait for every v1 group to be ready. diff --git a/packages/domain/src/anticipated-errors.ts b/packages/domain/src/anticipated-errors.ts index 7b280e1b1..4c7be0dc3 100644 --- a/packages/domain/src/anticipated-errors.ts +++ b/packages/domain/src/anticipated-errors.ts @@ -48,10 +48,11 @@ const readHttpStatus = (value: unknown): number | undefined => { * arrived as an Error span whose entire description was the word "Payload". */ const EXTERNAL_ANTICIPATED_IDENTIFIERS = ["HttpApiSchemaError"] as const +const exportedValues = (namespace: object): ReadonlyArray<unknown> => Object.values(namespace) const deriveAnticipatedIdentifiers = (): ReadonlySet<string> => { const identifiers = new Set<string>(EXTERNAL_ANTICIPATED_IDENTIFIERS) - for (const value of [...Object.values(Http), ...Object.values(HttpV2)]) { + for (const value of [...exportedValues(Http), ...exportedValues(HttpV2)]) { if (typeof value !== "function") continue const identifier = readIdentifier(value) if (identifier === undefined) continue diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 5e0796b15..2692cc8a9 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -14,6 +14,7 @@ import { UserId, } from "../primitives" import { QueryBuilderQueryDraftSchema } from "./query-engine" +import { HttpTaggedError } from "./error-policy" export const AlertDestinationType = Schema.Literals([ "slack-bot", @@ -643,55 +644,125 @@ export class AlertDestinationTestResponse extends Schema.Class<AlertDestinationT message: Schema.String, }) {} -export class AlertForbiddenError extends Schema.TaggedError<AlertForbiddenError>()( +export class AlertForbiddenError extends HttpTaggedError<AlertForbiddenError>()( "@maple/http/errors/AlertForbiddenError", { message: Schema.String, roles: Schema.optionalKey(Schema.Array(RoleName)), }, - { httpApiStatus: 403 }, + { + status: 403, + code: "alert_forbidden", + title: "Permission required", + message: "You do not have permission to perform this alert operation.", + retry: "never", + recovery: "request_access", + exposure: "redacted", + }, ) {} -export class AlertValidationError extends Schema.TaggedError<AlertValidationError>()( +export class AlertValidationError extends HttpTaggedError<AlertValidationError>()( "@maple/http/errors/AlertValidationError", { message: Schema.String, details: Schema.Array(Schema.String), cause: Schema.optionalKey(Schema.Defect()), }, - { httpApiStatus: 400 }, + { + status: 400, + code: "alert_invalid", + title: "Invalid alert request", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class AlertPersistenceError extends Schema.TaggedError<AlertPersistenceError>()( +export class AlertPersistenceError extends HttpTaggedError<AlertPersistenceError>()( "@maple/http/errors/AlertPersistenceError", { message: Schema.String, cause: Schema.optionalKey(Schema.String), }, - { httpApiStatus: 503 }, + { + status: 503, + code: "alerts_unavailable", + title: "Alerts are temporarily unavailable", + message: "Alerts are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class AlertNotFoundError extends Schema.TaggedError<AlertNotFoundError>()( +const alertResource = (resourceType: string): { code: string; title: string; message: string } => { + switch (resourceType) { + case "destination": + return { + code: "alert_destination_not_found", + title: "Alert destination not found", + message: "No such alert destination.", + } + case "rule": + case "alert_rule": + return { + code: "alert_rule_not_found", + title: "Alert rule not found", + message: "No such alert rule.", + } + case "alert_incident": + return { + code: "alert_incident_not_found", + title: "Alert incident not found", + message: "No such alert incident.", + } + default: + return { + code: "alert_resource_not_found", + title: "Alert resource not found", + message: "No such alert resource.", + } + } +} + +export class AlertNotFoundError extends HttpTaggedError<AlertNotFoundError>()( "@maple/http/errors/AlertNotFoundError", { message: Schema.String, resourceType: Schema.String, resourceId: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: (error) => alertResource(error.resourceType).code, + title: (error) => alertResource(error.resourceType).title, + message: (error) => alertResource(error.resourceType).message, + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class AlertDeliveryError extends Schema.TaggedError<AlertDeliveryError>()( +export class AlertDeliveryError extends HttpTaggedError<AlertDeliveryError>()( "@maple/http/errors/AlertDeliveryError", { message: Schema.String, destinationType: Schema.optionalKey(AlertDestinationType), cause: Schema.optionalKey(Schema.Defect()), }, - { httpApiStatus: 502 }, + { + status: 502, + code: "alert_delivery_failed", + title: "Alert provider request failed", + message: "The alert provider request failed.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class AlertDestinationInUseError extends Schema.TaggedError<AlertDestinationInUseError>()( +export class AlertDestinationInUseError extends HttpTaggedError<AlertDestinationInUseError>()( "@maple/http/errors/AlertDestinationInUseError", { message: Schema.String, @@ -699,7 +770,15 @@ export class AlertDestinationInUseError extends Schema.TaggedError<AlertDestinat ruleIds: Schema.Array(AlertRuleId), ruleNames: Schema.Array(Schema.String), }, - { httpApiStatus: 409 }, + { + status: 409, + code: "alert_destination_in_use", + title: "Alert destination is in use", + message: "The alert destination is currently used by one or more alert rules.", + retry: "never", + recovery: "fix_request", + exposure: "redacted", + }, ) {} export const AlertIncidentTransition = Schema.Literals(["none", "opened", "continued", "resolved"]).annotate({ diff --git a/packages/domain/src/http/anomalies.ts b/packages/domain/src/http/anomalies.ts index 849c84fbb..7a8455643 100644 --- a/packages/domain/src/http/anomalies.ts +++ b/packages/domain/src/http/anomalies.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { AnomalyIncidentId, ErrorIssueId, IsoDateTimeString, UserId } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" // Literals @@ -159,39 +160,72 @@ export class AnomalyDetectorSettingsUpdateRequest extends Schema.Class<AnomalyDe // Errors -export class AnomalyPersistenceError extends Schema.TaggedError<AnomalyPersistenceError>()( +export class AnomalyPersistenceError extends HttpTaggedError<AnomalyPersistenceError>()( "@maple/http/anomalies/AnomalyPersistenceError", { message: Schema.String, cause: Schema.optionalKey(Schema.String), }, - { httpApiStatus: 503 }, + { + status: 503, + code: "anomalies_unavailable", + title: "Anomalies are temporarily unavailable", + message: "Anomalies are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class AnomalyForbiddenError extends Schema.TaggedError<AnomalyForbiddenError>()( +export class AnomalyForbiddenError extends HttpTaggedError<AnomalyForbiddenError>()( "@maple/http/anomalies/AnomalyForbiddenError", { message: Schema.String, }, - { httpApiStatus: 403 }, + { + status: 403, + code: "anomaly_settings_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} -export class AnomalyIncidentNotFoundError extends Schema.TaggedError<AnomalyIncidentNotFoundError>()( +export class AnomalyIncidentNotFoundError extends HttpTaggedError<AnomalyIncidentNotFoundError>()( "@maple/http/anomalies/AnomalyIncidentNotFoundError", { message: Schema.String, incidentId: AnomalyIncidentId, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "anomaly_incident_not_found", + title: "Anomaly incident not found", + message: "No such anomaly incident.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class AnomalyLinkedIssueNotFoundError extends Schema.TaggedError<AnomalyLinkedIssueNotFoundError>()( +export class AnomalyLinkedIssueNotFoundError extends HttpTaggedError<AnomalyLinkedIssueNotFoundError>()( "@maple/http/anomalies/AnomalyLinkedIssueNotFoundError", { message: Schema.String, issueId: ErrorIssueId, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "error_issue_not_found", + title: "Error issue not found", + message: "No such error issue.", + param: "issue_id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} // Query schemas diff --git a/packages/domain/src/http/api-keys.ts b/packages/domain/src/http/api-keys.ts index d7de100a9..ef5fa8550 100644 --- a/packages/domain/src/http/api-keys.ts +++ b/packages/domain/src/http/api-keys.ts @@ -1,5 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" +import { HttpTaggedError } from "./error-policy" import { ApiKeyId, PostgresTransactionId, UserId } from "../primitives" import { Authorization } from "./current-tenant" @@ -56,38 +57,70 @@ export class CreateApiKeyRequest extends Schema.Class<CreateApiKeyRequest>("Crea scopes: Schema.optionalKey(Schema.Array(Schema.String)), }) {} -export class ApiKeyPersistenceError extends Schema.TaggedError<ApiKeyPersistenceError>()( +export class ApiKeyPersistenceError extends HttpTaggedError<ApiKeyPersistenceError>()( "@maple/http/errors/ApiKeyPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "api_keys_unavailable", + title: "API keys are temporarily unavailable", + message: "API keys are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class ApiKeyLookupPersistenceError extends Schema.TaggedError<ApiKeyLookupPersistenceError>()( +export class ApiKeyLookupPersistenceError extends HttpTaggedError<ApiKeyLookupPersistenceError>()( "@maple/http/errors/ApiKeyLookupPersistenceError", { message: Schema.String, cause: Schema.Defect(), }, - { httpApiStatus: 503 }, + { + status: 503, + code: "api_key_lookup_unavailable", + title: "Service temporarily unavailable", + message: "A service required for this operation is temporarily unavailable; retry with backoff.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class ApiKeyForbiddenError extends Schema.TaggedError<ApiKeyForbiddenError>()( +export class ApiKeyForbiddenError extends HttpTaggedError<ApiKeyForbiddenError>()( "@maple/http/errors/ApiKeyForbiddenError", { message: Schema.String, }, - { httpApiStatus: 403 }, + { + status: 403, + code: "api_key_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} -export class ApiKeyNotFoundError extends Schema.TaggedError<ApiKeyNotFoundError>()( +export class ApiKeyNotFoundError extends HttpTaggedError<ApiKeyNotFoundError>()( "@maple/http/errors/ApiKeyNotFoundError", { keyId: ApiKeyId, message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "api_key_not_found", + title: "API key not found", + message: "No such API key.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} export class ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") diff --git a/packages/domain/src/http/api.ts b/packages/domain/src/http/api.ts index 9e5ad4df6..5210c4ede 100644 --- a/packages/domain/src/http/api.ts +++ b/packages/domain/src/http/api.ts @@ -22,6 +22,7 @@ import { RecommendationIssuesApiGroup } from "./recommendation-issues" import { ScrapeTargetsApiGroup } from "./scrape-targets" import { SessionReplaysApiGroup } from "./session-replay" import { WarehouseApiGroup } from "./warehouse" +import { V1SchemaErrors, V1UnexpectedErrors } from "./v1-boundary" export class MapleApi extends HttpApi.make("MapleApi") .add(AuthPublicApiGroup) .add(AuthApiGroup) @@ -48,6 +49,8 @@ export class MapleApi extends HttpApi.make("MapleApi") .add(ScrapeTargetsApiGroup) .add(SessionReplaysApiGroup) .add(WarehouseApiGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) .annotateMerge( OpenApi.annotations({ title: "Maple API", diff --git a/packages/domain/src/http/dashboards.ts b/packages/domain/src/http/dashboards.ts index 2854a8265..572ca630a 100644 --- a/packages/domain/src/http/dashboards.ts +++ b/packages/domain/src/http/dashboards.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { DashboardDocument, PortableDashboardDocument } from "@maple/widgets/dashboard" import { @@ -12,6 +12,7 @@ import { UserId, } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" // The dashboard *document* schema lives in `../dashboard`, which is versioned. // This module keeps the HTTP surface: request/response envelopes, tagged errors @@ -158,64 +159,89 @@ const DashboardVersionsListQuery = Schema.Struct({ before: Schema.optional(Schema.NumberFromString.check(Schema.isInt())), }) -export class DashboardVersionNotFoundError extends Schema.TaggedError<DashboardVersionNotFoundError>()( +export class DashboardVersionNotFoundError extends HttpTaggedError<DashboardVersionNotFoundError>()( "@maple/http/errors/DashboardVersionNotFoundError", { dashboardId: DashboardId, versionId: DashboardVersionId, message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "dashboard_version_not_found", + title: "Dashboard version not found", + message: "No such dashboard version.", + param: "version_id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class DashboardPersistenceError extends Schema.TaggedError<DashboardPersistenceError>()( +export class DashboardPersistenceError extends HttpTaggedError<DashboardPersistenceError>()( "@maple/http/errors/DashboardPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "dashboards_unavailable", + title: "Dashboards are temporarily unavailable", + message: "Dashboards are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class DashboardNotFoundError extends Schema.TaggedError<DashboardNotFoundError>()( +export class DashboardNotFoundError extends HttpTaggedError<DashboardNotFoundError>()( "@maple/http/errors/DashboardNotFoundError", { dashboardId: DashboardId, message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "dashboard_not_found", + title: "Dashboard not found", + message: "No such dashboard.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class DashboardValidationError extends Schema.TaggedError<DashboardValidationError>()( +export class DashboardValidationError extends HttpTaggedError<DashboardValidationError>()( "@maple/http/errors/DashboardValidationError", { message: Schema.String, details: Schema.Array(Schema.String), }, - { httpApiStatus: 400 }, -) {} - -/** - * Rewrites request-decode failures on the dashboards group into a - * `DashboardValidationError` carrying the JSON path, the enclosing widget id - * and the expected-vs-received message for every offending field. - * - * Without it the runtime answers a schema failure with a bare empty 400, so the - * only way to find one bad key in a 14-widget document is to bisect it. The - * error class is already in every endpoint's error list, so attaching this - * widens no client contract. - */ -export class DashboardSchemaErrors extends HttpApiMiddleware.Service<DashboardSchemaErrors>()( - "DashboardSchemaErrors", - { error: DashboardValidationError }, + { + status: 400, + code: "dashboard_invalid", + title: "Invalid dashboard", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class DashboardConcurrencyError extends Schema.TaggedError<DashboardConcurrencyError>()( +export class DashboardConcurrencyError extends HttpTaggedError<DashboardConcurrencyError>()( "@maple/http/errors/DashboardConcurrencyError", { dashboardId: DashboardId, message: Schema.String, }, - { httpApiStatus: 409 }, + { + status: 409, + code: "dashboard_concurrent_update", + title: "Dashboard changed while saving", + retry: "never", + recovery: "refresh", + exposure: "public_message", + }, ) {} // Templates @@ -326,13 +352,22 @@ export class DashboardTemplateInstantiateRequest extends Schema.Class<DashboardT name: Schema.optionalKey(Schema.String), }) {} -export class DashboardTemplateNotFoundError extends Schema.TaggedError<DashboardTemplateNotFoundError>()( +export class DashboardTemplateNotFoundError extends HttpTaggedError<DashboardTemplateNotFoundError>()( "@maple/http/errors/DashboardTemplateNotFoundError", { templateId: DashboardTemplateId, message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "dashboard_template_not_found", + title: "Dashboard template not found", + message: "No such dashboard template.", + param: "template_id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} export class DashboardsApiGroup extends HttpApiGroup.make("dashboards") @@ -427,5 +462,4 @@ export class DashboardsApiGroup extends HttpApiGroup.make("dashboards") }), ) .prefix("/api/dashboards") - .middleware(Authorization) - .middleware(DashboardSchemaErrors) {} + .middleware(Authorization) {} diff --git a/packages/domain/src/http/error-policy.test.ts b/packages/domain/src/http/error-policy.test.ts index 59a981e57..f7e62a60a 100644 --- a/packages/domain/src/http/error-policy.test.ts +++ b/packages/domain/src/http/error-policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest" +import { OpenApi } from "effect/unstable/httpapi" import * as Http from "./index" const prop = (value: unknown, key: string): unknown => @@ -6,16 +7,25 @@ const prop = (value: unknown, key: string): unknown => ? (value as Record<string, unknown>)[key] : undefined -const taggedErrorIdentity = (value: unknown): { readonly tag: string; readonly status: number } | null => { +interface TaggedErrorIdentity { + readonly name: string + readonly tag: string + readonly status: number + readonly hasMessage: boolean +} + +const taggedErrorIdentity = (name: string, value: unknown): TaggedErrorIdentity | null => { const tag = prop(prop(prop(prop(value, "fields"), "_tag"), "schema"), "literal") const status = prop(prop(prop(value, "ast"), "annotations"), "httpApiStatus") - return typeof tag === "string" && typeof status === "number" ? { tag, status } : null + const hasMessage = + prop(value, "fields") !== undefined && prop(prop(value, "fields"), "message") !== undefined + return typeof tag === "string" && typeof status === "number" ? { name, tag, status, hasMessage } : null } describe("HTTP error tag contract", () => { it("gives every exported tagged HTTP error one globally unique semantic identity", () => { - const identities = Object.values(Http) - .map(taggedErrorIdentity) + const identities = Object.entries(Http) + .map(([name, value]) => taggedErrorIdentity(name, value)) .filter((identity): identity is NonNullable<typeof identity> => identity !== null) const tags = identities.map((identity) => identity.tag) @@ -27,4 +37,55 @@ describe("HTTP error tag contract", () => { expect(status, tag).toBeLessThan(600) } }) + + it("gives every tagged HTTP error a useful message and stable status semantics", () => { + const identities = Object.entries(Http) + .map(([name, value]) => taggedErrorIdentity(name, value)) + .filter((identity): identity is NonNullable<typeof identity> => identity !== null) + + for (const identity of identities) { + expect(identity.hasMessage, `${identity.name} has a message field`).toBe(true) + + if (identity.name.endsWith("ValidationError")) { + expect(identity.status, identity.name).toBe(400) + } + if (identity.name.endsWith("ForbiddenError")) { + expect(identity.status, identity.name).toBe(403) + } + if (identity.name.endsWith("NotFoundError")) { + expect(identity.status, identity.name).toBe(404) + } + if (identity.name.endsWith("PersistenceError")) { + expect(identity.status, identity.name).toBe(503) + } + if (/RateLimitError$|QuotaError$|QuotaExceededError$/.test(identity.name)) { + expect(identity.status, identity.name).toBe(429) + } + if (/ConflictError$|ConcurrencyError$|InUseError$/.test(identity.name)) { + expect(identity.status, identity.name).toBe(409) + } + } + }) + + it("declares the shared request-validation and unexpected-error boundary on every v1 operation", () => { + const spec = OpenApi.fromApi(Http.MapleApi) + const operations = Object.entries(spec.paths ?? {}).flatMap(([path, item]) => + Object.entries(item ?? {}) + .filter(([method]) => ["get", "post", "put", "patch", "delete"].includes(method)) + .map(([method, operation]) => ({ + method, + path, + operation: operation as { readonly responses?: Record<string, unknown> }, + })), + ) + + for (const { method, path, operation } of operations) { + for (const status of ["400", "500"]) { + expect( + operation.responses?.[status], + `${method.toUpperCase()} ${path} declares ${status}`, + ).toBeDefined() + } + } + }) }) diff --git a/packages/domain/src/http/error-policy.ts b/packages/domain/src/http/error-policy.ts index bf100878e..9d493fb9f 100644 --- a/packages/domain/src/http/error-policy.ts +++ b/packages/domain/src/http/error-policy.ts @@ -13,45 +13,256 @@ export const HttpErrorRecovery = Schema.Literals([ ]) export type HttpErrorRecovery = Schema.Schema.Type<typeof HttpErrorRecovery> +/** Closed public category shared by HTTP errors and every Maple client. */ +export const PublicHttpErrorType = Schema.Literals([ + "invalid_request_error", + "authentication_error", + "permission_error", + "not_found_error", + "conflict_error", + "rate_limit_error", + "api_error", +]) +export type PublicHttpErrorType = Schema.Schema.Type<typeof PublicHttpErrorType> + export type HttpErrorRetry = "never" | "backoff" | "after" -export type HttpErrorOrigin = "client" | "maple" | "dependency" -export type HttpErrorExposure = "public_message" | "redacted" +export type PublicHttpErrorStatus = 400 | 401 | 403 | 404 | 409 | 413 | 429 | 500 | 502 | 503 | 504 -/** - * Static semantics owned by one domain error tag. - * - * HTTP status remains on the error schema annotation. Keeping it out of this - * object prevents two sources of truth from drifting apart. - */ -export interface HttpErrorPolicy { +export type PublicHttpErrorTypeForStatus<Status extends PublicHttpErrorStatus> = Status extends 400 | 413 + ? "invalid_request_error" + : Status extends 401 + ? "authentication_error" + : Status extends 403 + ? "permission_error" + : Status extends 404 + ? "not_found_error" + : Status extends 409 + ? "conflict_error" + : Status extends 429 + ? "rate_limit_error" + : "api_error" + +/** The public representation every self-describing HTTP error exposes itself. */ +export interface PublicHttpErrorBody<Tag extends string, Status extends PublicHttpErrorStatus> { + readonly _tag: Tag + readonly type: PublicHttpErrorTypeForStatus<Status> + readonly code: string readonly title: string - readonly retry: HttpErrorRetry + readonly message: string + readonly retryable: boolean readonly recovery: HttpErrorRecovery - readonly origin: HttpErrorOrigin - readonly exposure: HttpErrorExposure -} - -/** One exhaustive policy table per tagged-error union. */ -export const defineHttpErrorPolicies = - <Tag extends string>() => - <const Policies extends Record<Tag, HttpErrorPolicy>>(policies: Policies): Policies => - policies - -export const isHttpErrorRetryable = (policy: HttpErrorPolicy): boolean => policy.retry !== "never" - -/** Metadata copied by the generic v2 envelope constructors. */ -export const httpErrorMetadata = ( - tag: string, - policy: HttpErrorPolicy, - dynamic: { - readonly retryAfterSeconds?: number - readonly retryAt?: string - } = {}, -) => ({ - tag, - title: policy.title, - retryable: isHttpErrorRetryable(policy), - recovery: policy.recovery, - ...(dynamic.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: dynamic.retryAfterSeconds }), - ...(dynamic.retryAt === undefined ? {} : { retryAt: dynamic.retryAt }), + readonly retry_after_seconds?: number + readonly retry_at?: string + readonly param?: string + readonly doc_url?: string +} + +/** Runtime contract for a public error body when its exact tag/status are not known statically. */ +export const PublicHttpErrorBodySchema = Schema.Struct({ + _tag: Schema.String.check(Schema.isPattern(/^@maple\//)), + type: PublicHttpErrorType, + code: Schema.String, + title: Schema.String, + message: Schema.String, + retryable: Schema.Boolean, + recovery: HttpErrorRecovery, + retry_after_seconds: Schema.optionalKey(Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))), + retry_at: Schema.optionalKey( + Schema.String.check( + Schema.makeFilter((value: string) => Number.isFinite(Date.parse(value)), { + description: "Expected an ISO date-time string", + }), + ), + ), + param: Schema.optionalKey(Schema.String), + doc_url: Schema.optionalKey(Schema.String), }) +export type AnyPublicHttpErrorBody = Schema.Schema.Type<typeof PublicHttpErrorBodySchema> + +type ErrorValue<Error, Value> = Value | ((error: Error) => Value) + +interface PublicHttpErrorPolicyBase<Error, Status extends PublicHttpErrorStatus> { + readonly status: Status + readonly code: ErrorValue<Error, string> + readonly title: ErrorValue<Error, string> + readonly retry: HttpErrorRetry + readonly recovery: HttpErrorRecovery + readonly param?: ErrorValue<Error, string | undefined> + readonly retryAfterSeconds?: ErrorValue<Error, number | undefined> + readonly retryAt?: ErrorValue<Error, string | undefined> +} + +/** Public HTTP presentation owned by the tagged error class itself. */ +export type PublicHttpErrorPolicy<Error, Status extends PublicHttpErrorStatus> = + | (PublicHttpErrorPolicyBase<Error, Status> & { + readonly exposure: "public_message" + }) + | (PublicHttpErrorPolicyBase<Error, Status> & { + readonly exposure: "redacted" + readonly message: ErrorValue<Error, string> + }) + +/** Type-level and runtime link from an error to its class-owned HTTP definition. */ +export const PublicHttpErrorPolicyTypeId: unique symbol = Symbol.for("@maple/http/PublicHttpErrorPolicy") + +export interface PublicHttpErrorDefinition<Tag extends string, Status extends PublicHttpErrorStatus> { + readonly tag: Tag + readonly status: Status +} + +export interface WithPublicHttpErrorPolicy<Tag extends string, Status extends PublicHttpErrorStatus> { + readonly [PublicHttpErrorPolicyTypeId]: PublicHttpErrorDefinition<Tag, Status> + /** Public HTTP body read directly by the endpoint error schema during encoding. */ + readonly error: PublicHttpErrorBody<Tag, Status> +} + +export interface PublicTaggedError { + readonly _tag: string + readonly message: string +} + +export type SelfDescribingHttpError = PublicTaggedError & + WithPublicHttpErrorPolicy<string, PublicHttpErrorStatus> + +export type PublicHttpErrorStatusOf<Error extends WithPublicHttpErrorPolicy<string, PublicHttpErrorStatus>> = + Error[typeof PublicHttpErrorPolicyTypeId]["status"] + +export interface PublicHttpErrorClassDefinition< + Error extends PublicTaggedError, + Tag extends string, + Status extends PublicHttpErrorStatus, +> { + readonly tag: Tag + readonly policy: PublicHttpErrorPolicy<Error, Status> +} + +export interface SelfDescribingHttpErrorClass<Error extends SelfDescribingHttpError> extends Function { + readonly [PublicHttpErrorPolicyTypeId]: PublicHttpErrorClassDefinition< + Error, + Error["_tag"], + PublicHttpErrorStatusOf<Error> + > +} + +/** + * Define a Schema tagged error whose HTTP status and safe public presentation + * live on the error class. The brand keeps the policy available to TypeScript; + * the static symbol makes the same policy available to the v2 serializer. + */ +export const HttpTaggedError = + <Self>() => + < + const Tag extends string, + const Fields extends Schema.Struct.Fields, + const Policy extends PublicHttpErrorPolicy< + Schema.Struct.Type<Fields> & PublicTaggedError, + PublicHttpErrorStatus + >, + >( + tag: Tag, + fields: Fields, + policy: Policy, + ) => { + const ErrorClass = Schema.TaggedError<Self, WithPublicHttpErrorPolicy<Tag, Policy["status"]>>()( + tag, + fields, + { httpApiStatus: policy.status }, + ) + const SelfDescribingErrorClass = class extends (ErrorClass as unknown as new ( + ...args: Array<unknown> + ) => SelfDescribingHttpError) { + override readonly error: PublicHttpErrorBody<string, PublicHttpErrorStatus> = + publicHttpErrorBody(this) + } as unknown as typeof ErrorClass + + return Object.assign(SelfDescribingErrorClass, { + [PublicHttpErrorPolicyTypeId]: { tag, policy }, + } as const) + } + +export const publicHttpErrorPolicy = <Error extends SelfDescribingHttpError>( + error: Error, +): PublicHttpErrorPolicy<Error, PublicHttpErrorStatusOf<Error>> => + publicHttpErrorDefinitionFor<Error>(error.constructor).policy + +/** Read the tag and public policy owned by an error class. */ +export function publicHttpErrorDefinitionFor<Error extends SelfDescribingHttpError>( + errorClass: Function, +): PublicHttpErrorClassDefinition<Error, Error["_tag"], PublicHttpErrorStatusOf<Error>> +export function publicHttpErrorDefinitionFor<Error extends PublicTaggedError>( + errorClass: Function, +): PublicHttpErrorClassDefinition<Error, Error["_tag"], PublicHttpErrorStatus> +export function publicHttpErrorDefinitionFor<Error extends PublicTaggedError>( + errorClass: Function, +): PublicHttpErrorClassDefinition<Error, Error["_tag"], PublicHttpErrorStatus> { + const definition = ( + errorClass as { + readonly [PublicHttpErrorPolicyTypeId]?: PublicHttpErrorClassDefinition< + Error, + Error["_tag"], + PublicHttpErrorStatus + > + } + )[PublicHttpErrorPolicyTypeId] + if (definition === undefined) throw new Error(`No public HTTP policy registered for ${errorClass.name}`) + return definition +} + +/** Read the public policy owned by an error class, including for wire-shaped presenters. */ +export const publicHttpErrorPolicyFor = <Error extends PublicTaggedError>( + errorClass: Function, +): PublicHttpErrorPolicy<Error, PublicHttpErrorStatus> => + publicHttpErrorDefinitionFor<Error>(errorClass).policy + +const resolve = <Error, Value>(value: Value | ((error: Error) => Value), error: Error): Value => + typeof value === "function" ? (value as (error: Error) => Value)(error) : value + +export const publicHttpErrorTypeForStatus = <const Status extends PublicHttpErrorStatus>( + status: Status, +): PublicHttpErrorTypeForStatus<Status> => { + switch (status) { + case 400: + case 413: + return "invalid_request_error" as PublicHttpErrorTypeForStatus<Status> + case 401: + return "authentication_error" as PublicHttpErrorTypeForStatus<Status> + case 403: + return "permission_error" as PublicHttpErrorTypeForStatus<Status> + case 404: + return "not_found_error" as PublicHttpErrorTypeForStatus<Status> + case 409: + return "conflict_error" as PublicHttpErrorTypeForStatus<Status> + case 429: + return "rate_limit_error" as PublicHttpErrorTypeForStatus<Status> + default: + return "api_error" as PublicHttpErrorTypeForStatus<Status> + } +} + +/** + * Materialize the safe public body owned by a tagged error. `HttpTaggedError` + * installs it on the instance, so handlers can fail with the original tagged + * error and let the endpoint schema serialize it directly. + */ +export const publicHttpErrorBody = <Error extends SelfDescribingHttpError>( + error: Error, +): PublicHttpErrorBody<Error["_tag"], PublicHttpErrorStatusOf<Error>> => { + const policy = publicHttpErrorPolicy(error) + const retryAfterSeconds = + policy.retryAfterSeconds === undefined ? undefined : resolve(policy.retryAfterSeconds, error) + const retryAt = policy.retryAt === undefined ? undefined : resolve(policy.retryAt, error) + const param = policy.param === undefined ? undefined : resolve(policy.param, error) + + return { + _tag: error._tag, + type: publicHttpErrorTypeForStatus(policy.status), + code: resolve(policy.code, error), + title: resolve(policy.title, error), + message: policy.exposure === "public_message" ? error.message : resolve(policy.message, error), + retryable: policy.retry !== "never", + recovery: policy.recovery, + ...(retryAfterSeconds === undefined ? {} : { retry_after_seconds: retryAfterSeconds }), + ...(retryAt === undefined ? {} : { retry_at: retryAt }), + ...(param === undefined ? {} : { param }), + } +} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index aa329cc64..b5cf5085a 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -16,6 +16,7 @@ import { } from "../primitives" import { Authorization } from "./current-tenant" import { AlertSeverity } from "./alerts" +import { HttpTaggedError } from "./error-policy" // Workflow state machine literals @@ -473,13 +474,21 @@ const IssueEventsQuery = Schema.Struct({ // Errors -export class ErrorPersistenceError extends Schema.TaggedError<ErrorPersistenceError>()( +export class ErrorPersistenceError extends HttpTaggedError<ErrorPersistenceError>()( "@maple/http/errors/ErrorPersistenceError", { message: Schema.String, cause: Schema.optionalKey(Schema.String), }, - { httpApiStatus: 503 }, + { + status: 503, + code: "error_issues_unavailable", + title: "Error issues are temporarily unavailable", + message: "Error issues are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} export const EscalationSkipReason = Schema.Literals([ @@ -559,14 +568,26 @@ export class ErrorForbiddenError extends Schema.TaggedError<ErrorForbiddenError> { httpApiStatus: 403 }, ) {} -export class ErrorIssueNotFoundError extends Schema.TaggedError<ErrorIssueNotFoundError>()( +export class ErrorIssueNotFoundError extends HttpTaggedError<ErrorIssueNotFoundError>()( "@maple/http/errors/ErrorIssueNotFoundError", { message: Schema.String, resourceType: Schema.Literals(["issue", "incident"]), resourceId: Schema.Union([ErrorIssueId, ErrorIncidentId]), }, - { httpApiStatus: 404 }, + { + status: 404, + code: (error) => + error.resourceType === "issue" ? "error_issue_not_found" : "error_incident_not_found", + title: (error) => + error.resourceType === "issue" ? "Error issue not found" : "Error incident not found", + message: (error) => + error.resourceType === "issue" ? "No such error issue." : "No such error incident.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) { static forIssue(id: ErrorIssueId) { return new ErrorIssueNotFoundError({ diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 445982378..68e0aec52 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -1,7 +1,6 @@ export * from "./api" export * from "./ai-triage" export * from "./investigations" -export * from "./investigation-error-meta" export * from "./anomalies" export * from "./api-keys" export * from "./alerts" @@ -29,5 +28,6 @@ export * from "./scraper-internal" export * from "./session-replay" export * from "./slack-internal" export * from "./vcs" +export * from "./v1-boundary" export * from "./warehouse" export * from "./widget-types" diff --git a/packages/domain/src/http/ingest-attribute-mappings.ts b/packages/domain/src/http/ingest-attribute-mappings.ts index 1d308b0eb..ca008ff5e 100644 --- a/packages/domain/src/http/ingest-attribute-mappings.ts +++ b/packages/domain/src/http/ingest-attribute-mappings.ts @@ -7,6 +7,7 @@ import { IsoDateTimeString, } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" export class IngestAttributeMapping extends Schema.Class<IngestAttributeMapping>("IngestAttributeMapping")({ id: IngestAttributeMappingId, @@ -54,29 +55,53 @@ export class IngestAttributeMappingDeleteResponse extends Schema.Class<IngestAtt id: IngestAttributeMappingId, }) {} -export class IngestAttributeMappingPersistenceError extends Schema.TaggedError<IngestAttributeMappingPersistenceError>()( +export class IngestAttributeMappingPersistenceError extends HttpTaggedError<IngestAttributeMappingPersistenceError>()( "@maple/http/errors/IngestAttributeMappingPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "attribute_mappings_unavailable", + title: "Attribute mappings are temporarily unavailable", + message: "Attribute mappings are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class IngestAttributeMappingNotFoundError extends Schema.TaggedError<IngestAttributeMappingNotFoundError>()( +export class IngestAttributeMappingNotFoundError extends HttpTaggedError<IngestAttributeMappingNotFoundError>()( "@maple/http/errors/IngestAttributeMappingNotFoundError", { mappingId: IngestAttributeMappingId, message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "attribute_mapping_not_found", + title: "Attribute mapping not found", + message: "No such attribute mapping.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class IngestAttributeMappingValidationError extends Schema.TaggedError<IngestAttributeMappingValidationError>()( +export class IngestAttributeMappingValidationError extends HttpTaggedError<IngestAttributeMappingValidationError>()( "@maple/http/errors/IngestAttributeMappingValidationError", { message: Schema.String, }, - { httpApiStatus: 400 }, + { + status: 400, + code: "attribute_mapping_invalid", + title: "Invalid attribute mapping", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} export class IngestAttributeMappingsApiGroup extends HttpApiGroup.make("ingestAttributeMappings") diff --git a/packages/domain/src/http/ingest-keys.ts b/packages/domain/src/http/ingest-keys.ts index 92473d9e5..bba66acd4 100644 --- a/packages/domain/src/http/ingest-keys.ts +++ b/packages/domain/src/http/ingest-keys.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { IsoDateTimeString } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" export class IngestKeysResponse extends Schema.Class<IngestKeysResponse>("IngestKeysResponse")({ publicKey: Schema.String, @@ -10,28 +11,51 @@ export class IngestKeysResponse extends Schema.Class<IngestKeysResponse>("Ingest privateRotatedAt: IsoDateTimeString, }) {} -export class IngestKeyPersistenceError extends Schema.TaggedError<IngestKeyPersistenceError>()( +export class IngestKeyPersistenceError extends HttpTaggedError<IngestKeyPersistenceError>()( "@maple/http/errors/IngestKeyPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "ingest_keys_unavailable", + title: "Ingest keys are temporarily unavailable", + message: "Ingest keys are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class IngestKeyEncryptionError extends Schema.TaggedError<IngestKeyEncryptionError>()( +export class IngestKeyEncryptionError extends HttpTaggedError<IngestKeyEncryptionError>()( "@maple/http/errors/IngestKeyEncryptionError", { message: Schema.String, }, - { httpApiStatus: 500 }, + { + status: 500, + code: "ingest_key_encryption_failed", + title: "Ingest key could not be secured", + message: "Maple could not securely process the ingest key.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} -export class IngestKeyForbiddenError extends Schema.TaggedError<IngestKeyForbiddenError>()( +export class IngestKeyForbiddenError extends HttpTaggedError<IngestKeyForbiddenError>()( "@maple/http/errors/IngestKeyForbiddenError", { message: Schema.String, }, - { httpApiStatus: 403 }, + { + status: 403, + code: "ingest_key_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} export class IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index cf4d722f7..4742c94dd 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ExternalUserId, ScrapeTargetId, UserId } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" import { GitCommitSha, VcsAccountType, @@ -694,56 +695,127 @@ export class VcsCommitDetailsResponse extends Schema.Class<VcsCommitDetailsRespo /** Upper bound on SHAs per bulk commit lookup — one page of a list view. */ export const VCS_COMMIT_DETAILS_MAX_SHAS = 50 -export class IntegrationsForbiddenError extends Schema.TaggedError<IntegrationsForbiddenError>()( +export class IntegrationsForbiddenError extends HttpTaggedError<IntegrationsForbiddenError>()( "@maple/http/errors/IntegrationsForbiddenError", { message: Schema.String, }, - { httpApiStatus: 403 }, + { + status: 403, + code: "integration_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} -export class IntegrationsValidationError extends Schema.TaggedError<IntegrationsValidationError>()( +export class IntegrationsValidationError extends HttpTaggedError<IntegrationsValidationError>()( "@maple/http/errors/IntegrationsValidationError", { message: Schema.String, }, - { httpApiStatus: 400 }, + { + status: 400, + code: "integration_request_invalid", + title: "Invalid integration request", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class IntegrationsNotConnectedError extends Schema.TaggedError<IntegrationsNotConnectedError>()( +/** Maple cannot start an integration because its server-side configuration is incomplete. */ +export class IntegrationsConfigurationError extends HttpTaggedError<IntegrationsConfigurationError>()( + "@maple/http/errors/IntegrationsConfigurationError", + { + message: Schema.String, + }, + { + status: 503, + code: "integration_not_configured", + title: "Integration is not configured", + message: "This integration is not configured in Maple. Contact support.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +export class IntegrationsNotConnectedError extends HttpTaggedError<IntegrationsNotConnectedError>()( "@maple/http/errors/IntegrationsNotConnectedError", { message: Schema.String, }, - { httpApiStatus: 409 }, + { + status: 409, + code: "integration_not_connected", + title: "Integration not connected", + retry: "never", + recovery: "reconnect", + exposure: "public_message", + }, ) {} -export class IntegrationsRevokedError extends Schema.TaggedError<IntegrationsRevokedError>()( +export class IntegrationsRevokedError extends HttpTaggedError<IntegrationsRevokedError>()( "@maple/http/errors/IntegrationsRevokedError", { message: Schema.String, }, - { httpApiStatus: 401 }, + { + status: 401, + code: "integration_authorization_revoked", + title: "Integration authorization revoked", + message: "The integration authorization was revoked. Reconnect and try again.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, ) {} -export class IntegrationsUpstreamError extends Schema.TaggedError<IntegrationsUpstreamError>()( +export class IntegrationsUpstreamError extends HttpTaggedError<IntegrationsUpstreamError>()( "@maple/http/errors/IntegrationsUpstreamError", { message: Schema.String, status: Schema.optionalKey(Schema.Number), cause: Schema.optionalKey(Schema.Defect()), }, - { httpApiStatus: 502 }, + { + status: 502, + code: "integration_upstream_error", + title: "Integration provider is unavailable", + message: "The integration provider could not complete the request.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class IntegrationsPersistenceError extends Schema.TaggedError<IntegrationsPersistenceError>()( +export class IntegrationsPersistenceError extends HttpTaggedError<IntegrationsPersistenceError>()( "@maple/http/errors/IntegrationsPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "integration_persistence_unavailable", + title: "Integrations are temporarily unavailable", + message: "Integrations are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} +export type IntegrationHttpError = + | IntegrationsForbiddenError + | IntegrationsConfigurationError + | IntegrationsNotConnectedError + | IntegrationsRevokedError + | IntegrationsValidationError + | IntegrationsUpstreamError + | IntegrationsPersistenceError + /** * Every `/api/integrations/planetscale/*` operation below is superseded by the * `/v2/integrations/planetscale` group (`http/v2/integrations-planetscale.ts`), @@ -870,12 +942,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("planetscaleStart", "/planetscale/start", { payload: PlanetScaleStartConnectRequest, success: PlanetScaleStartConnectResponse, - error: [ - IntegrationsForbiddenError, - IntegrationsValidationError, - IntegrationsUpstreamError, - IntegrationsPersistenceError, - ], + error: [IntegrationsForbiddenError, IntegrationsConfigurationError, IntegrationsPersistenceError], }).annotateMerge(PLANETSCALE_V1_DEPRECATED), ) .add( @@ -885,6 +952,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") success: PlanetScaleOrganizationsResponse, error: [ IntegrationsForbiddenError, + IntegrationsConfigurationError, IntegrationsValidationError, IntegrationsNotConnectedError, IntegrationsRevokedError, @@ -902,6 +970,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") success: PlanetScaleIntegrationStatus, error: [ IntegrationsForbiddenError, + IntegrationsConfigurationError, IntegrationsValidationError, IntegrationsNotConnectedError, IntegrationsRevokedError, @@ -918,6 +987,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") success: PlanetScaleIntegrationStatus, error: [ IntegrationsForbiddenError, + IntegrationsConfigurationError, IntegrationsNotConnectedError, IntegrationsValidationError, IntegrationsUpstreamError, @@ -950,6 +1020,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") payload: PlanetScaleQueryInsightsRequest, success: PlanetScaleQueryInsightsResponse, error: [ + IntegrationsConfigurationError, IntegrationsNotConnectedError, IntegrationsValidationError, IntegrationsRevokedError, diff --git a/packages/domain/src/http/investigation-error-meta.test.ts b/packages/domain/src/http/investigation-error-meta.test.ts deleted file mode 100644 index 5fcc4aca3..000000000 --- a/packages/domain/src/http/investigation-error-meta.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { investigationErrorPolicy } from "./investigation-error-meta" -import { investigationHttpErrors } from "./investigations" - -const tagOf = (errorClass: (typeof investigationHttpErrors)[number]): string => { - const fields = errorClass.fields as Record<string, unknown> - const tagField = fields._tag as { readonly schema?: { readonly literal?: unknown } } | undefined - const tag = tagField?.schema?.literal - if (typeof tag !== "string") throw new Error("investigation error class has no literal _tag") - return tag -} - -describe("investigation error policy", () => { - it("covers every semantic error tag exactly once", () => { - const tags = investigationHttpErrors.map(tagOf) - expect(new Set(tags).size).toBe(tags.length) - expect(Object.keys(investigationErrorPolicy).sort()).toEqual([...tags].sort()) - }) - - it("separates terminal configuration from transient start failures", () => { - expect( - investigationErrorPolicy["@maple/http/investigations/InvestigationAutomationDisabledError"].retry, - ).toBe("never") - expect( - investigationErrorPolicy["@maple/http/investigations/InvestigationAgentUnavailableError"].retry, - ).toBe("backoff") - expect(investigationErrorPolicy["@maple/http/investigations/InvestigationQuotaError"].retry).toBe( - "after", - ) - }) -}) diff --git a/packages/domain/src/http/investigation-error-meta.ts b/packages/domain/src/http/investigation-error-meta.ts deleted file mode 100644 index e697ec7f8..000000000 --- a/packages/domain/src/http/investigation-error-meta.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { InvestigationHttpError } from "./investigations" -import { defineHttpErrorPolicies } from "./error-policy" - -export type InvestigationErrorTag = InvestigationHttpError["_tag"] - -/** One recovery/presentation policy per semantic investigation failure. */ -export const investigationErrorPolicy = defineHttpErrorPolicies<InvestigationErrorTag>()({ - "@maple/http/investigations/InvestigationPersistenceError": { - title: "Investigations are temporarily unavailable", - retry: "backoff", - recovery: "retry", - origin: "dependency", - exposure: "redacted", - }, - "@maple/http/investigations/InvestigationValidationError": { - title: "Invalid investigation", - retry: "never", - recovery: "fix_request", - origin: "client", - exposure: "public_message", - }, - "@maple/http/investigations/InvestigationNotFoundError": { - title: "Investigation not found", - retry: "never", - recovery: "none", - origin: "client", - exposure: "public_message", - }, - "@maple/http/investigations/InvestigationQuotaError": { - title: "Investigation limit reached", - retry: "after", - recovery: "retry", - origin: "client", - exposure: "public_message", - }, - "@maple/http/investigations/InvestigationAutomationDisabledError": { - title: "Automatic investigations are disabled", - retry: "never", - recovery: "none", - origin: "client", - exposure: "public_message", - }, - "@maple/http/investigations/InvestigationAgentUnavailableError": { - title: "Investigation agent is temporarily unavailable", - retry: "backoff", - recovery: "retry", - origin: "dependency", - exposure: "public_message", - }, - "@maple/http/investigations/InvestigationStartFailedError": { - title: "Investigation could not be started", - retry: "backoff", - recovery: "retry", - origin: "dependency", - exposure: "public_message", - }, - "@maple/http/investigations/InvestigationRejectedError": { - title: "Investigation agent rejected the request", - retry: "never", - recovery: "reconnect", - origin: "dependency", - exposure: "redacted", - }, -}) - -export const isInvestigationErrorTag = (tag: string): tag is InvestigationErrorTag => - Object.hasOwn(investigationErrorPolicy, tag) diff --git a/packages/domain/src/http/investigations.ts b/packages/domain/src/http/investigations.ts index 694e53009..643024824 100644 --- a/packages/domain/src/http/investigations.ts +++ b/packages/domain/src/http/investigations.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { ErrorIssueId, InvestigationId, IsoDateTimeString, UserId } from "../primitives" import { AiTriageEvidence, AiTriageIncidentKind, AiTriageResult } from "./ai-triage" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" import { IssueSeverity } from "./errors" // Literals @@ -492,32 +493,56 @@ export class SubmitDiagnosisRequest extends Schema.Class<SubmitDiagnosisRequest> // Errors -export class InvestigationPersistenceError extends Schema.TaggedError<InvestigationPersistenceError>()( +export class InvestigationPersistenceError extends HttpTaggedError<InvestigationPersistenceError>()( "@maple/http/investigations/InvestigationPersistenceError", { message: Schema.String, cause: Schema.optionalKey(Schema.String), }, - { httpApiStatus: 503 }, + { + status: 503, + code: "investigations_unavailable", + title: "Investigations are temporarily unavailable", + message: "Investigations are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class InvestigationValidationError extends Schema.TaggedError<InvestigationValidationError>()( +export class InvestigationValidationError extends HttpTaggedError<InvestigationValidationError>()( "@maple/http/investigations/InvestigationValidationError", { message: Schema.String, }, - { httpApiStatus: 400 }, + { + status: 400, + code: "investigation_invalid", + title: "Invalid investigation", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class InvestigationNotFoundError extends Schema.TaggedError<InvestigationNotFoundError>()( +export class InvestigationNotFoundError extends HttpTaggedError<InvestigationNotFoundError>()( "@maple/http/investigations/InvestigationNotFoundError", { message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "investigation_not_found", + title: "Investigation not found", + message: "No such investigation.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class InvestigationQuotaError extends Schema.TaggedError<InvestigationQuotaError>()( +export class InvestigationQuotaError extends HttpTaggedError<InvestigationQuotaError>()( "@maple/http/investigations/InvestigationQuotaError", { message: Schema.String, @@ -530,43 +555,106 @@ export class InvestigationQuotaError extends Schema.TaggedError<InvestigationQuo limit: Schema.Number, retryableAt: IsoDateTimeString, }, - { httpApiStatus: 429 }, + { + status: 429, + code: "investigation_daily_quota", + title: "Investigation limit reached", + message: (error) => + error.dimension === "runs" + ? `Daily limit of ${error.limit} investigations reached. Resets at ${error.retryableAt}.` + : `Daily limit of ${error.limit} model passes reached. Resets at ${error.retryableAt}.`, + retry: "after", + retryAt: (error) => error.retryableAt, + recovery: "retry", + exposure: "redacted", + }, ) {} /** Automatic starts are disabled by organization policy. Retrying unchanged cannot help. */ -export class InvestigationAutomationDisabledError extends Schema.TaggedError<InvestigationAutomationDisabledError>()( +export class InvestigationAutomationDisabledError extends HttpTaggedError<InvestigationAutomationDisabledError>()( "@maple/http/investigations/InvestigationAutomationDisabledError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "investigation_automation_disabled", + title: "Automatic investigations are disabled", + retry: "never", + recovery: "none", + exposure: "public_message", + }, ) {} /** The investigation agent/workflow binding cannot currently be reached. */ -export class InvestigationAgentUnavailableError extends Schema.TaggedError<InvestigationAgentUnavailableError>()( +export class InvestigationAgentUnavailableError extends HttpTaggedError<InvestigationAgentUnavailableError>()( "@maple/http/investigations/InvestigationAgentUnavailableError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "investigation_agent_unavailable", + title: "Investigation agent is temporarily unavailable", + retry: "backoff", + recovery: "retry", + exposure: "public_message", + }, ) {} /** A configured agent was reached, but the investigation turn could not be started. */ -export class InvestigationStartFailedError extends Schema.TaggedError<InvestigationStartFailedError>()( +export class InvestigationStartFailedError extends HttpTaggedError<InvestigationStartFailedError>()( "@maple/http/investigations/InvestigationStartFailedError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "investigation_start_failed", + title: "Investigation could not be started", + retry: "backoff", + recovery: "retry", + exposure: "public_message", + }, ) {} -export class InvestigationRejectedError extends Schema.TaggedError<InvestigationRejectedError>()( +export class InvestigationRejectedError extends HttpTaggedError<InvestigationRejectedError>()( "@maple/http/investigations/InvestigationRejectedError", { message: Schema.String, status: Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 400, maximum: 499 })), }, - { httpApiStatus: 502 }, + { + status: 502, + code: "investigation_start_rejected", + title: "Investigation agent rejected the request", + message: (error) => `The investigation agent rejected the start request with HTTP ${error.status}.`, + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, +) {} + +/** Stored investigation data no longer decodes into its current public schema. */ +export class InvestigationDataCorruptionError extends HttpTaggedError<InvestigationDataCorruptionError>()( + "@maple/http/investigations/InvestigationDataCorruptionError", + { + message: Schema.String, + investigationId: InvestigationId, + field: Schema.String, + value: Schema.String, + incidentKind: Schema.optionalKey(Schema.String), + incidentId: Schema.optionalKey(Schema.String), + }, + { + status: 500, + code: "investigation_data_corrupt", + title: "Stored investigation data is invalid", + message: "Maple could not decode the stored investigation.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} export type InvestigationHttpError = @@ -578,6 +666,7 @@ export type InvestigationHttpError = | InvestigationAgentUnavailableError | InvestigationStartFailedError | InvestigationRejectedError + | InvestigationDataCorruptionError export const investigationHttpErrors = [ InvestigationPersistenceError, @@ -588,6 +677,7 @@ export const investigationHttpErrors = [ InvestigationAgentUnavailableError, InvestigationStartFailedError, InvestigationRejectedError, + InvestigationDataCorruptionError, ] as const // Query schemas diff --git a/packages/domain/src/http/organizations.ts b/packages/domain/src/http/organizations.ts index adf7be9b4..1d4279419 100644 --- a/packages/domain/src/http/organizations.ts +++ b/packages/domain/src/http/organizations.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" export class DeleteOrganizationResponse extends Schema.Class<DeleteOrganizationResponse>( "DeleteOrganizationResponse", @@ -8,28 +9,51 @@ export class DeleteOrganizationResponse extends Schema.Class<DeleteOrganizationR deleted: Schema.Literal(true), }) {} -export class OrganizationForbiddenError extends Schema.TaggedError<OrganizationForbiddenError>()( +export class OrganizationForbiddenError extends HttpTaggedError<OrganizationForbiddenError>()( "@maple/http/errors/OrganizationForbiddenError", { message: Schema.String, }, - { httpApiStatus: 403 }, + { + status: 403, + code: "organization_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} -export class OrganizationPersistenceError extends Schema.TaggedError<OrganizationPersistenceError>()( +export class OrganizationPersistenceError extends HttpTaggedError<OrganizationPersistenceError>()( "@maple/http/errors/OrganizationPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "organization_persistence_unavailable", + title: "Organization storage is temporarily unavailable", + message: "Organization storage is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class OrganizationProviderError extends Schema.TaggedError<OrganizationProviderError>()( +export class OrganizationProviderError extends HttpTaggedError<OrganizationProviderError>()( "@maple/http/errors/OrganizationProviderError", { message: Schema.String, }, - { httpApiStatus: 502 }, + { + status: 502, + code: "organization_provider_unavailable", + title: "Organization provider unavailable", + message: "The organization provider is temporarily unavailable.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} export class OrganizationsApiGroup extends HttpApiGroup.make("organizations") diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 3cddf299a..f298683b8 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -20,6 +20,7 @@ import { TinybirdDateTime, } from "../query-engine" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" import { warehouseHttpErrors } from "./warehouse" // Dedicated endpoint schemas @@ -1666,31 +1667,74 @@ export class RawSqlValidationError extends Schema.TaggedError<RawSqlValidationEr { httpApiStatus: 400 }, ) {} -export class QueryEngineValidationError extends Schema.TaggedError<QueryEngineValidationError>()( +export class QueryEngineValidationError extends HttpTaggedError<QueryEngineValidationError>()( "@maple/http/errors/QueryEngineValidationError", { message: Schema.String, details: Schema.Array(Schema.String), }, - { httpApiStatus: 400 }, + { + status: 400, + code: "query_engine_invalid", + title: "Invalid query", + param: "aggregation", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class QueryEngineExecutionError extends Schema.TaggedError<QueryEngineExecutionError>()( +export class QueryEngineExecutionError extends HttpTaggedError<QueryEngineExecutionError>()( "@maple/http/errors/QueryEngineExecutionError", { message: Schema.String, causeMessage: Schema.optional(Schema.String), pipeName: Schema.optional(Schema.String), }, - { httpApiStatus: 502 }, + { + status: 502, + code: "query_engine_failed", + title: "Query failed", + message: "The aggregation query could not be completed.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} -export class QueryEngineTimeoutError extends Schema.TaggedError<QueryEngineTimeoutError>()( +export class QueryEngineTimeoutError extends HttpTaggedError<QueryEngineTimeoutError>()( "@maple/http/errors/QueryEngineTimeoutError", { message: Schema.String, }, - { httpApiStatus: 504 }, + { + status: 504, + code: "query_engine_timeout", + title: "Query timed out", + message: "The aggregation query timed out. Retry with a narrower time range.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, +) {} + +/** The query engine returned a result variant that cannot satisfy the requested operation. */ +export class QueryEngineResultMismatchError extends HttpTaggedError<QueryEngineResultMismatchError>()( + "@maple/http/errors/QueryEngineResultMismatchError", + { + message: Schema.String, + expectedKind: Schema.String, + actualKind: Schema.String, + }, + { + status: 500, + code: "query_engine_result_mismatch", + title: "Maple returned an invalid query result", + message: "Maple returned an invalid result for this query.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} // Shared arrays — passing the same reference to every endpoint avoids diff --git a/packages/domain/src/http/recommendation-issues.ts b/packages/domain/src/http/recommendation-issues.ts index c7249048d..0856a30d4 100644 --- a/packages/domain/src/http/recommendation-issues.ts +++ b/packages/domain/src/http/recommendation-issues.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { IsoDateTimeString, RecommendationIssueId } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" export const RecommendationIssueKind = Schema.Literals(["rename", "double-emission", "naming"]) export type RecommendationIssueKind = typeof RecommendationIssueKind.Type @@ -30,16 +31,33 @@ export class RecommendationIssuesListResponse extends Schema.Class<Recommendatio issues: Schema.Array(RecommendationIssue), }) {} -export class RecommendationIssuePersistenceError extends Schema.TaggedError<RecommendationIssuePersistenceError>()( +export class RecommendationIssuePersistenceError extends HttpTaggedError<RecommendationIssuePersistenceError>()( "@maple/http/errors/RecommendationIssuePersistenceError", { message: Schema.String }, - { httpApiStatus: 503 }, + { + status: 503, + code: "recommendations_unavailable", + title: "Recommendations are temporarily unavailable", + message: "Recommendations are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class RecommendationIssueNotFoundError extends Schema.TaggedError<RecommendationIssueNotFoundError>()( +export class RecommendationIssueNotFoundError extends HttpTaggedError<RecommendationIssueNotFoundError>()( "@maple/http/errors/RecommendationIssueNotFoundError", { id: RecommendationIssueId, message: Schema.String }, - { httpApiStatus: 404 }, + { + status: 404, + code: "recommendation_not_found", + title: "Recommendation not found", + message: "No such recommendation.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} export class RecommendationIssuesApiGroup extends HttpApiGroup.make("recommendationIssues") diff --git a/packages/domain/src/http/scrape-targets.ts b/packages/domain/src/http/scrape-targets.ts index a03223dc9..fb864a492 100644 --- a/packages/domain/src/http/scrape-targets.ts +++ b/packages/domain/src/http/scrape-targets.ts @@ -8,6 +8,7 @@ import { ScrapeTargetType, } from "../primitives" import { Authorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" export class ScrapeTargetResponse extends Schema.Class<ScrapeTargetResponse>("ScrapeTargetResponse")({ id: ScrapeTargetId, @@ -130,37 +131,69 @@ export const ListScrapeTargetChecksQuery = Schema.Struct({ ), }) -export class ScrapeTargetPersistenceError extends Schema.TaggedError<ScrapeTargetPersistenceError>()( +export class ScrapeTargetPersistenceError extends HttpTaggedError<ScrapeTargetPersistenceError>()( "@maple/http/errors/ScrapeTargetPersistenceError", { message: Schema.String, }, - { httpApiStatus: 503 }, + { + status: 503, + code: "scrape_targets_unavailable", + title: "Scrape targets are temporarily unavailable", + message: "Scrape targets are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class ScrapeTargetNotFoundError extends Schema.TaggedError<ScrapeTargetNotFoundError>()( +export class ScrapeTargetNotFoundError extends HttpTaggedError<ScrapeTargetNotFoundError>()( "@maple/http/errors/ScrapeTargetNotFoundError", { targetId: ScrapeTargetId, message: Schema.String, }, - { httpApiStatus: 404 }, + { + status: 404, + code: "scrape_target_not_found", + title: "Scrape target not found", + message: "No such scrape target.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, ) {} -export class ScrapeTargetValidationError extends Schema.TaggedError<ScrapeTargetValidationError>()( +export class ScrapeTargetValidationError extends HttpTaggedError<ScrapeTargetValidationError>()( "@maple/http/errors/ScrapeTargetValidationError", { message: Schema.String, }, - { httpApiStatus: 400 }, + { + status: 400, + code: "scrape_target_invalid", + title: "Invalid scrape target", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class ScrapeTargetEncryptionError extends Schema.TaggedError<ScrapeTargetEncryptionError>()( +export class ScrapeTargetEncryptionError extends HttpTaggedError<ScrapeTargetEncryptionError>()( "@maple/http/errors/ScrapeTargetEncryptionError", { message: Schema.String, }, - { httpApiStatus: 500 }, + { + status: 500, + code: "scrape_target_encryption_failed", + title: "Scrape target credentials could not be saved", + message: "Maple could not securely save the scrape target credentials.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} /** @@ -172,13 +205,21 @@ export class ScrapeTargetEncryptionError extends Schema.TaggedError<ScrapeTarget * provider failure, `config` is a credential/OAuth-app misconfiguration * (bad service token, missing scope). */ -export class ScrapeTargetAuthError extends Schema.TaggedError<ScrapeTargetAuthError>()( +export class ScrapeTargetAuthError extends HttpTaggedError<ScrapeTargetAuthError>()( "@maple/http/errors/ScrapeTargetAuthError", { message: Schema.String, reason: Schema.Literals(["not_connected", "revoked", "upstream", "config"]), }, - { httpApiStatus: 502 }, + { + status: 502, + code: "scrape_target_auth_failed", + title: "Scrape target authentication failed", + message: "The scrape target rejected Maple's credentials.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, ) {} /** @@ -191,13 +232,21 @@ export class ScrapeTargetAuthError extends Schema.TaggedError<ScrapeTargetAuthEr * regex-sniffing the HTTP status back out of a persistence message. `status` * carries the upstream HTTP status when the failure reached one. */ -export class ScrapeTargetUpstreamError extends Schema.TaggedError<ScrapeTargetUpstreamError>()( +export class ScrapeTargetUpstreamError extends HttpTaggedError<ScrapeTargetUpstreamError>()( "@maple/http/errors/ScrapeTargetUpstreamError", { message: Schema.String, status: Schema.optionalKey(Schema.Number), }, - { httpApiStatus: 502 }, + { + status: 502, + code: "scrape_target_upstream_failed", + title: "Scrape target is unavailable", + message: "The scrape target could not complete the request.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} export class ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") diff --git a/packages/domain/src/http/v1-boundary.ts b/packages/domain/src/http/v1-boundary.ts new file mode 100644 index 000000000..8382b837a --- /dev/null +++ b/packages/domain/src/http/v1-boundary.ts @@ -0,0 +1,39 @@ +import { Schema } from "effect" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +/** + * Uniform request-decode failure for the legacy `/api` HttpApi. + * + * v1 keeps its established top-level tagged-error wire format, but malformed + * params, query strings, headers, and payloads must still return a useful JSON + * body instead of Effect's default empty 400 response. + */ +export class V1RequestValidationError extends Schema.TaggedError<V1RequestValidationError>()( + "@maple/http/v1/V1RequestValidationError", + { + message: Schema.String, + param: Schema.optionalKey(Schema.String), + details: Schema.Array(Schema.String), + }, + { httpApiStatus: 400 }, +) {} + +/** Sanitized response for an unexpected defect in a legacy HttpApi handler. */ +export class V1UnexpectedError extends Schema.TaggedError<V1UnexpectedError>()( + "@maple/http/v1/V1UnexpectedError", + { + message: Schema.String, + }, + { httpApiStatus: 500 }, +) {} + +/** Rewrites request/response schema failures for every v1 HttpApi endpoint. */ +export class V1SchemaErrors extends HttpApiMiddleware.Service<V1SchemaErrors>()("V1SchemaErrors", { + error: [V1RequestValidationError, V1UnexpectedError], +}) {} + +/** Converts unexpected handler defects into a logged, sanitized v1 response. */ +export class V1UnexpectedErrors extends HttpApiMiddleware.Service<V1UnexpectedErrors>()( + "V1UnexpectedErrors", + { error: V1UnexpectedError }, +) {} diff --git a/packages/domain/src/http/v2/alert-deliveries.ts b/packages/domain/src/http/v2/alert-deliveries.ts index 13a6f8c09..e8f938881 100644 --- a/packages/domain/src/http/v2/alert-deliveries.ts +++ b/packages/domain/src/http/v2/alert-deliveries.ts @@ -1,9 +1,10 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { AlertDeliveryStatus, AlertDestinationType, AlertEventType } from "../alerts" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AlertDeliveryStatus, AlertDestinationType, AlertEventType, AlertPersistenceError } from "../alerts" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2ServiceUnavailableError } from "./errors" +import { V2ParameterInvalid } from "./errors" +import { publicError } from "./public-error" import { AlertDeliveryEventPublicId, AlertDestinationPublicId, @@ -48,7 +49,7 @@ export class V2AlertDeliveriesApiGroup extends HttpApiGroup.make("alertDeliverie HttpApiEndpoint.get("list", "/", { query: ListQuery, success: AlertDeliveryList, - error: [V2InvalidRequestError, V2ServiceUnavailableError], + error: [V2ParameterInvalid.schema, publicError(AlertPersistenceError)], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertDeliveries", @@ -60,7 +61,6 @@ export class V2AlertDeliveriesApiGroup extends HttpApiGroup.make("alertDeliverie ) .prefix("/v2/alerts/deliveries") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Alert Deliveries", diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts index 4a2d77a2d..7412985ed 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -1,17 +1,20 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { HazelChannelId, HazelOrganizationId, PostgresTransactionId, UserId } from "../../primitives" -import { AlertDestinationType, MAX_EMAIL_RECIPIENTS } from "../alerts" -import { AuthorizationV2, V2SchemaErrors } from "./auth" -import { ListOf, ListQuery, Timestamp } from "./envelopes" import { - V2ConflictError, - V2InvalidRequestError, - V2NotFoundError, - V2PermissionError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "./errors" + AlertDeliveryError, + AlertDestinationInUseError, + AlertDestinationType, + AlertForbiddenError, + AlertNotFoundError, + AlertPersistenceError, + AlertValidationError, + MAX_EMAIL_RECIPIENTS, +} from "../alerts" +import { AuthorizationV2 } from "./auth" +import { ListOf, ListQuery, Timestamp } from "./envelopes" +import { V2ParameterInvalid } from "./errors" +import { publicError, publicErrors } from "./public-error" import { AlertDestinationPublicId } from "./resource-ids" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ @@ -338,7 +341,13 @@ export const V2AlertDestinationTestResult = Schema.Struct({ }) export type V2AlertDestinationTestResult = Schema.Schema.Type<typeof V2AlertDestinationTestResult> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError, V2UpstreamError] as const +const [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDelivery] = publicErrors( + AlertForbiddenError, + AlertValidationError, + AlertPersistenceError, + AlertNotFoundError, + AlertDeliveryError, +) const AlertDestinationList = ListOf(V2AlertDestination).annotate({ identifier: "AlertDestinationList", @@ -351,7 +360,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.get("list", "/", { query: ListQuery, success: AlertDestinationList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertDestinations", @@ -365,7 +374,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.post("create", "/", { payload: V2AlertDestinationCreateParams, success: V2AlertDestinationMutationResponse, - error: [...commonErrors, V2PermissionError], + error: [alertForbidden, alertValidation, alertPersistence, alertDelivery], }).annotateMerge( OpenApi.annotations({ identifier: "createAlertDestination", @@ -379,7 +388,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertDestinationPublicId }, success: V2AlertDestination, - error: [...commonErrors, V2NotFoundError], + error: [alertNotFound, alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertDestination", @@ -394,7 +403,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina params: { id: AlertDestinationPublicId }, payload: V2AlertDestinationUpdateParams, success: V2AlertDestinationMutationResponse, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [alertForbidden, alertValidation, alertPersistence, alertNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "updateAlertDestination", @@ -408,7 +417,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.delete("delete", "/:id", { params: { id: AlertDestinationPublicId }, success: V2AlertDestinationDeleteResponse, - error: [...commonErrors, V2PermissionError, V2NotFoundError, V2ConflictError], + error: [alertForbidden, alertPersistence, alertNotFound, publicError(AlertDestinationInUseError)], }).annotateMerge( OpenApi.annotations({ identifier: "deleteAlertDestination", @@ -422,7 +431,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.post("test", "/:id/test", { params: { id: AlertDestinationPublicId }, success: V2AlertDestinationTestResult, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDelivery], }).annotateMerge( OpenApi.annotations({ identifier: "testAlertDestination", @@ -434,7 +443,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina ) .prefix("/v2/alerts/destinations") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Alert Destinations", diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index e16499148..3b80d3042 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -1,5 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" +import { AlertNotFoundError, AlertPersistenceError } from "../alerts" import { AlertComparator, AlertEventType, @@ -7,9 +8,10 @@ import { AlertSeverity, AlertSignalType, } from "../alerts" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2NotFoundError, V2ServiceUnavailableError } from "./errors" +import { V2ParameterInvalid } from "./errors" +import { publicError } from "./public-error" import { AlertIncidentPublicId, AlertRulePublicId, ErrorIssuePublicId } from "./resource-ids" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ @@ -129,7 +131,7 @@ const IncidentsQuery = Schema.Struct({ description: "Pagination plus optional status / rule filters.", }) -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +const alertPersistence = publicError(AlertPersistenceError) const AlertIncidentList = ListOf(V2AlertIncident).annotate({ identifier: "AlertIncidentList", @@ -142,7 +144,7 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" HttpApiEndpoint.get("list", "/", { query: IncidentsQuery, success: AlertIncidentList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertIncidents", @@ -156,7 +158,7 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertIncidentPublicId }, success: V2AlertIncident, - error: [...commonErrors, V2NotFoundError], + error: [publicError(AlertNotFoundError), alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertIncident", @@ -168,7 +170,6 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" ) .prefix("/v2/alerts/incidents") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Alert Incidents", diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts index 0be939aa6..b6adf2190 100644 --- a/packages/domain/src/http/v2/alert-rules.ts +++ b/packages/domain/src/http/v2/alert-rules.ts @@ -8,20 +8,21 @@ import { AlertEvaluationStatus, AlertIncidentTransition, AlertNotificationTemplate, + AlertDeliveryError, + AlertForbiddenError, + AlertNotFoundError, + AlertPersistenceError, AlertSeverity, AlertSignalType, + AlertValidationError, AlertWindowMinutes, } from "../alerts" import { AlertDestinationPublicId } from "./alert-destinations" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { - V2InvalidRequestError, - V2NotFoundError, - V2PermissionError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "./errors" +import { V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" +import { V2WarehouseErrors } from "./query-errors" import { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" export { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" @@ -625,7 +626,13 @@ const ChecksQuery = Schema.Struct({ description: "Pagination plus optional group/time filters for a rule's check history.", }) -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError, V2UpstreamError] as const +const [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDelivery] = publicErrors( + AlertForbiddenError, + AlertValidationError, + AlertPersistenceError, + AlertNotFoundError, + AlertDeliveryError, +) const AlertRuleList = ListOf(V2AlertRule).annotate({ identifier: "AlertRuleList", @@ -691,7 +698,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.get("list", "/", { query: ListQuery, success: AlertRuleList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertRules", @@ -705,7 +712,13 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.post("create", "/", { payload: V2AlertRuleCreateParams, success: V2AlertRuleMutationResponse, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [ + V2ParameterInvalid.schema, + alertForbidden, + alertValidation, + alertPersistence, + alertNotFound, + ], }).annotateMerge( OpenApi.annotations({ identifier: "createAlertRule", @@ -719,7 +732,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertRulePublicId }, success: V2AlertRule, - error: [...commonErrors, V2NotFoundError], + error: [alertPersistence, alertNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertRule", @@ -734,7 +747,13 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, payload: V2AlertRuleUpdateParams, success: V2AlertRuleMutationResponse, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [ + V2ParameterInvalid.schema, + alertForbidden, + alertValidation, + alertPersistence, + alertNotFound, + ], }).annotateMerge( OpenApi.annotations({ identifier: "updateAlertRule", @@ -748,7 +767,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.delete("delete", "/:id", { params: { id: AlertRulePublicId }, success: V2AlertRuleDeleteResponse, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [alertForbidden, alertPersistence, alertNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "deleteAlertRule", @@ -762,7 +781,15 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.post("test", "/test", { payload: V2AlertRuleTestParams, success: V2AlertRuleTestResult, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [ + V2ParameterInvalid.schema, + alertForbidden, + alertValidation, + alertPersistence, + alertNotFound, + alertDelivery, + ...V2WarehouseErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "testAlertRule", @@ -776,7 +803,14 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.post("preview", "/preview", { payload: V2AlertRulePreviewParams, success: V2AlertRulePreviewResult, - error: [...commonErrors, V2NotFoundError], + error: [ + V2ParameterInvalid.schema, + alertForbidden, + alertValidation, + alertPersistence, + alertDelivery, + ...V2WarehouseErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "previewAlertRule", @@ -791,7 +825,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, query: ChecksQuery, success: AlertCheckList, - error: [...commonErrors, V2NotFoundError], + error: [V2ParameterInvalid.schema, alertPersistence, alertNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertRuleChecks", @@ -806,7 +840,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, query: AlertCheckSummaryQuery, success: AlertCheckSummary, - error: [...commonErrors, V2NotFoundError], + error: [alertValidation, alertPersistence, alertNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "summarizeAlertRuleChecks", @@ -818,7 +852,6 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") ) .prefix("/v2/alerts/rules") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Alert Rules", diff --git a/packages/domain/src/http/v2/anomalies.ts b/packages/domain/src/http/v2/anomalies.ts index 9a30b3d08..1b6d29beb 100644 --- a/packages/domain/src/http/v2/anomalies.ts +++ b/packages/domain/src/http/v2/anomalies.ts @@ -1,6 +1,13 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { UserId } from "../../primitives" +import { + AnomalyForbiddenError, + AnomalyIncidentNotFoundError, + AnomalyLinkedIssueNotFoundError, + AnomalyPersistenceError, +} from "../anomalies" +import { ErrorPersistenceError } from "../errors" import { AnomalyIncidentSeverity, AnomalyIncidentStatus, @@ -10,14 +17,10 @@ import { AnomalyTimeseriesUnit, AnomalyTriageStatus, } from "../anomalies" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { - V2InvalidRequestError, - V2NotFoundError, - V2PermissionError, - V2ServiceUnavailableError, -} from "./errors" +import { V2ParameterInvalid } from "./errors" +import { publicError, publicErrors } from "./public-error" import { AnomalyIncidentPublicId, ErrorIssuePublicId } from "./resource-ids" export { AnomalyIncidentPublicId } from "./resource-ids" @@ -282,7 +285,12 @@ export const V2AnomalyTimeseriesQuery = Schema.Struct({ }) export type V2AnomalyTimeseriesQuery = Schema.Schema.Type<typeof V2AnomalyTimeseriesQuery> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +const [anomalyPersistence, anomalyNotFound, anomalyLinkedIssueNotFound, anomalyForbidden] = publicErrors( + AnomalyPersistenceError, + AnomalyIncidentNotFoundError, + AnomalyLinkedIssueNotFoundError, + AnomalyForbiddenError, +) const AnomalyIncidentList = ListOf(V2AnomalyIncident).annotate({ identifier: "AnomalyIncidentList", @@ -295,7 +303,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") HttpApiEndpoint.get("listIncidents", "/incidents", { query: V2AnomalyIncidentsListQuery, success: AnomalyIncidentList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, anomalyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listAnomalyIncidents", @@ -309,7 +317,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") HttpApiEndpoint.get("getIncident", "/incidents/:id", { params: { id: AnomalyIncidentPublicId }, success: V2AnomalyIncident, - error: [...commonErrors, V2NotFoundError], + error: [anomalyPersistence, anomalyNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "getAnomalyIncident", @@ -324,7 +332,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") params: { id: AnomalyIncidentPublicId }, query: V2AnomalyTimeseriesQuery, success: V2AnomalyIncidentTimeseries, - error: [...commonErrors, V2NotFoundError], + error: [anomalyPersistence, anomalyNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "getAnomalyIncidentTimeseries", @@ -338,7 +346,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") HttpApiEndpoint.post("resolveIncident", "/incidents/:id/resolve", { params: { id: AnomalyIncidentPublicId }, success: V2AnomalyIncident, - error: [...commonErrors, V2NotFoundError], + error: [anomalyPersistence, anomalyNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "resolveAnomalyIncident", @@ -353,7 +361,12 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") params: { id: AnomalyIncidentPublicId }, payload: V2AnomalyLinkIssueParams, success: V2AnomalyIncident, - error: [...commonErrors, V2NotFoundError], + error: [ + publicError(ErrorPersistenceError), + anomalyPersistence, + anomalyNotFound, + anomalyLinkedIssueNotFound, + ], }).annotateMerge( OpenApi.annotations({ identifier: "setAnomalyIncidentIssue", @@ -366,7 +379,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") .add( HttpApiEndpoint.get("getSettings", "/settings", { success: V2AnomalySettings, - error: [...commonErrors], + error: anomalyPersistence, }).annotateMerge( OpenApi.annotations({ identifier: "getAnomalySettings", @@ -380,7 +393,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") HttpApiEndpoint.patch("updateSettings", "/settings", { payload: V2AnomalySettingsUpdateParams, success: V2AnomalySettings, - error: [...commonErrors, V2PermissionError], + error: [anomalyForbidden, anomalyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "updateAnomalySettings", @@ -392,7 +405,6 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") ) .prefix("/v2/anomalies") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Anomalies", diff --git a/packages/domain/src/http/v2/api-keys.ts b/packages/domain/src/http/v2/api-keys.ts index 6c1eb54e7..cd4cdb56a 100644 --- a/packages/domain/src/http/v2/api-keys.ts +++ b/packages/domain/src/http/v2/api-keys.ts @@ -1,16 +1,12 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ApiKeyId, PostgresTransactionId, UserId } from "../../primitives" -import { ApiKeyKind } from "../api-keys" -import { AuthorizationV2, V2SchemaErrors, V2Scope } from "./auth" +import { ApiKeyForbiddenError, ApiKeyKind, ApiKeyNotFoundError, ApiKeyPersistenceError } from "../api-keys" +import { AuthorizationV2, V2Scope } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { - V2InvalidRequestError, - V2NotFoundError, - V2PermissionError, - V2ServiceUnavailableError, -} from "./errors" +import { V2InsufficientPermissions, V2ParameterInvalid } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" +import { publicError } from "./public-error" /** * Author OpenAPI `examples` in wire (encoded) shape. Effect types the `examples` @@ -201,7 +197,9 @@ export const V2ApiKeyCreateParams = Schema.Struct({ }) export type V2ApiKeyCreateParams = Schema.Schema.Type<typeof V2ApiKeyCreateParams> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +const apiKeyForbidden = publicError(ApiKeyForbiddenError) +const apiKeyNotFound = publicError(ApiKeyNotFoundError) +const apiKeyPersistence = publicError(ApiKeyPersistenceError) /** List response: a named, cursor-paginated page of API keys. */ const ApiKeyList = ListOf(V2ApiKey).annotate({ @@ -215,7 +213,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.get("list", "/", { query: ListQuery, success: ApiKeyList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listApiKeys", @@ -229,7 +227,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.post("create", "/", { payload: V2ApiKeyCreateParams, success: V2ApiKeyWithSecret, - error: [...commonErrors, V2PermissionError], + error: [V2InsufficientPermissions.schema, apiKeyForbidden, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "createApiKey", @@ -243,7 +241,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: ApiKeyPublicId }, success: V2ApiKey, - error: [...commonErrors, V2NotFoundError], + error: [apiKeyNotFound, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getApiKey", @@ -257,7 +255,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.post("roll", "/:id/roll", { params: { id: ApiKeyPublicId }, success: V2ApiKeyWithSecret, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [V2InsufficientPermissions.schema, apiKeyForbidden, apiKeyNotFound, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "rollApiKey", @@ -271,7 +269,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.delete("revoke", "/:id", { params: { id: ApiKeyPublicId }, success: V2ApiKeyMutationResponse, - error: [...commonErrors, V2PermissionError, V2NotFoundError], + error: [V2InsufficientPermissions.schema, apiKeyForbidden, apiKeyNotFound, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "revokeApiKey", @@ -283,7 +281,6 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") ) .prefix("/v2/api_keys") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "API Keys", diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index c1f37c2a3..d10cb42fc 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -24,7 +24,7 @@ import { V2ServicesApiGroup, V2TracesApiGroup, } from "./telemetry" -import { V2UnexpectedErrors } from "./auth" +import { V2SchemaErrors, V2UnexpectedErrors } from "./auth" const HTTP_OPERATION_METHODS = ["get", "post", "put", "patch", "delete", "head"] as const @@ -100,6 +100,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2MetricsApiGroup) .add(V2ServicesApiGroup) .add(V2ServiceMapApiGroup) + .middleware(V2SchemaErrors) .middleware(V2UnexpectedErrors) .annotateMerge( OpenApi.annotations({ @@ -107,7 +108,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") version: "2.0.0", summary: "The public, stability-committed HTTP API for the Maple observability platform.", description: [ - "The Maple public API is a resource-oriented REST interface for everything the dashboard can do.", + "The Maple public API is a resource-oriented REST interface for customer-stable resources and workflows.", "It follows Stripe's design philosophy, modernized where useful:", "", "- **Resources** are plural nouns under `/v2` (`/v2/api_keys`). Related resources share a product namespace (`/v2/alerts/rules`, `/v2/alerts/destinations`). Non-CRUD verbs are sub-resource POSTs (`/v2/api_keys/{id}/roll`).", diff --git a/packages/domain/src/http/v2/attribute-mappings.ts b/packages/domain/src/http/v2/attribute-mappings.ts index b6ba8c9e2..c1273ca4d 100644 --- a/packages/domain/src/http/v2/attribute-mappings.ts +++ b/packages/domain/src/http/v2/attribute-mappings.ts @@ -1,13 +1,19 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" +import { + IngestAttributeMappingNotFoundError, + IngestAttributeMappingPersistenceError, + IngestAttributeMappingValidationError, +} from "../ingest-attribute-mappings" import { IngestAttributeMappingId, IngestMappingOperation, IngestMappingSourceContext, } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2NotFoundError, V2ServiceUnavailableError } from "./errors" +import { V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ @@ -151,7 +157,11 @@ export const V2AttributeMappingDeleteResponse = Schema.Struct({ }) export type V2AttributeMappingDeleteResponse = Schema.Schema.Type<typeof V2AttributeMappingDeleteResponse> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +const [mappingNotFound, mappingValidation, mappingPersistence] = publicErrors( + IngestAttributeMappingNotFoundError, + IngestAttributeMappingValidationError, + IngestAttributeMappingPersistenceError, +) const AttributeMappingList = ListOf(V2AttributeMapping).annotate({ identifier: "AttributeMappingList", @@ -164,7 +174,7 @@ export class V2AttributeMappingsApiGroup extends HttpApiGroup.make("attributeMap HttpApiEndpoint.get("list", "/", { query: ListQuery, success: AttributeMappingList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, mappingPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listAttributeMappings", @@ -178,7 +188,7 @@ export class V2AttributeMappingsApiGroup extends HttpApiGroup.make("attributeMap HttpApiEndpoint.post("create", "/", { payload: V2AttributeMappingCreateParams, success: V2AttributeMapping, - error: [...commonErrors], + error: [mappingValidation, mappingPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "createAttributeMapping", @@ -192,7 +202,7 @@ export class V2AttributeMappingsApiGroup extends HttpApiGroup.make("attributeMap HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AttributeMappingPublicId }, success: V2AttributeMapping, - error: [...commonErrors, V2NotFoundError], + error: [mappingNotFound, mappingPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getAttributeMapping", @@ -207,7 +217,7 @@ export class V2AttributeMappingsApiGroup extends HttpApiGroup.make("attributeMap params: { id: AttributeMappingPublicId }, payload: V2AttributeMappingUpdateParams, success: V2AttributeMapping, - error: [...commonErrors, V2NotFoundError], + error: [mappingNotFound, mappingValidation, mappingPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "updateAttributeMapping", @@ -221,7 +231,7 @@ export class V2AttributeMappingsApiGroup extends HttpApiGroup.make("attributeMap HttpApiEndpoint.delete("delete", "/:id", { params: { id: AttributeMappingPublicId }, success: V2AttributeMappingDeleteResponse, - error: [...commonErrors, V2NotFoundError], + error: [mappingNotFound, mappingPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "deleteAttributeMapping", @@ -233,7 +243,6 @@ export class V2AttributeMappingsApiGroup extends HttpApiGroup.make("attributeMap ) .prefix("/v2/attribute_mappings") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Attribute Mappings", diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index d6522d4e5..3cc629af6 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -1,21 +1,23 @@ import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" +import { ApiKeyLookupPersistenceError } from "../api-keys" import { Context } from "../current-tenant" import { - V2ApiError, - V2AuthenticationError, - V2InvalidRequestError, - V2PermissionError, - V2RateLimitError, - V2ServiceUnavailableError, + V2InsufficientScope, + V2InvalidCredentials, + V2InvalidRequest, + V2RateLimited, + V2ResponseSchemaFailure, + V2UnexpectedFailure, } from "./errors" +import { publicError } from "./public-error" /** * v2 bearer authorization. Same credential resolution as v1 (`maple_ak_…` API * key, else Clerk/self-hosted session token) but errors use the v2 envelope * and API keys are subject to scope enforcement (see docs/api-v2.md#scopes). * - * Note: the error option must stay a *list* of classes (not `Schema.Union`) so + * Note: the error option must stay a *list* of schemas (not `Schema.Union`) so * each error keeps its own `httpApiStatus` when responses are encoded. */ export class AuthorizationV2 extends HttpApiMiddleware.Service< @@ -24,7 +26,12 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< provides: Context } >()("AuthorizationV2", { - error: [V2AuthenticationError, V2PermissionError, V2RateLimitError, V2ServiceUnavailableError], + error: [ + V2InvalidCredentials.schema, + V2InsufficientScope.schema, + V2RateLimited.schema, + publicError(ApiKeyLookupPersistenceError), + ], security: { bearer: HttpApiSecurity.bearer.pipe( HttpApiSecurity.annotateMerge( @@ -41,16 +48,17 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< /** Converts unexpected route defects into the public v2 API-error envelope. */ export class V2UnexpectedErrors extends HttpApiMiddleware.Service<V2UnexpectedErrors>()( "V2UnexpectedErrors", - { error: V2ApiError }, + { error: V2UnexpectedFailure.schema }, ) {} /** * Rewrites request-decode failures (params/query/payload schema errors) into * the v2 `invalid_request_error` envelope. Implemented in apps/api via - * `HttpApiMiddleware.layerSchemaErrorTransform`; every v2 group must attach it. + * `HttpApiMiddleware.layerSchemaErrorTransform`; `MapleApiV2` attaches it once + * after composing every resource group. */ export class V2SchemaErrors extends HttpApiMiddleware.Service<V2SchemaErrors>()("V2SchemaErrors", { - error: V2InvalidRequestError, + error: [V2InvalidRequest.schema, V2ResponseSchemaFailure.schema], }) {} /** Scope string grammar: `<family>:read`, `<family>:write`, or `*`. */ diff --git a/packages/domain/src/http/v2/dashboards.ts b/packages/domain/src/http/v2/dashboards.ts index ee1c1a926..6f368c36c 100644 --- a/packages/domain/src/http/v2/dashboards.ts +++ b/packages/domain/src/http/v2/dashboards.ts @@ -11,17 +11,24 @@ import { } from "../../primitives" import { DashboardQueryVariableFacet, + DashboardConcurrencyError, + DashboardNotFoundError, + DashboardPersistenceError, DashboardRefreshIntervalSeconds, + DashboardTemplateNotFoundError, DashboardTemplatePreviewKind, + DashboardValidationError, DashboardVariableName, + DashboardVersionNotFoundError, DashboardVersionChangeKind, } from "../dashboards" import { SORT_DIRECTIONS, STAT_AGGREGATES } from "@maple/widgets/dashboard" import { HEATMAP_COLOR_SCALES, HEATMAP_SCALE_TYPES, WIDGET_VISUALIZATIONS } from "../widget-types" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2ConflictError, V2InvalidRequestError, V2NotFoundError, V2ServiceUnavailableError } from "./errors" +import { V2ParameterInvalid, V2ParameterMissing } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" +import { publicErrors } from "./public-error" export const DashboardPublicId = PublicId(PublicIdPrefixes.dashboard, DashboardId) export const DashboardVersionPublicId = PublicId(PublicIdPrefixes.dashboardVersion, DashboardVersionId) @@ -627,15 +634,30 @@ const DashboardList = ListOf(V2Dashboard).annotate({ identifier: "DashboardList" const DashboardVersionList = ListOf(V2DashboardVersion).annotate({ identifier: "DashboardVersionList" }) const DashboardTemplateList = ListOf(V2DashboardTemplate).annotate({ identifier: "DashboardTemplateList" }) -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const -const mutationErrors = [...commonErrors, V2ConflictError] as const +const [ + dashboardVersionNotFound, + dashboardPersistence, + dashboardNotFound, + dashboardValidation, + dashboardConcurrency, + dashboardTemplateNotFound, +] = publicErrors( + DashboardVersionNotFoundError, + DashboardPersistenceError, + DashboardNotFoundError, + DashboardValidationError, + DashboardConcurrencyError, + DashboardTemplateNotFoundError, +) + +const dashboardMutationErrors = [dashboardValidation, dashboardPersistence, dashboardConcurrency] as const export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") .add( HttpApiEndpoint.get("list", "/", { query: ListQuery, success: DashboardList, - error: commonErrors, + error: [V2ParameterInvalid.schema, dashboardPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listDashboards", @@ -648,7 +670,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.post("create", "/", { payload: V2DashboardCreateParams, success: V2DashboardMutation, - error: mutationErrors, + error: dashboardMutationErrors, }).annotateMerge( OpenApi.annotations({ identifier: "createDashboard", @@ -662,7 +684,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.post("importPerses", "/import/perses", { payload: V2DashboardPersesImportParams, success: V2DashboardPersesImportResponse, - error: mutationErrors, + error: dashboardMutationErrors, }).annotateMerge( OpenApi.annotations({ identifier: "importPersesDashboard", @@ -676,7 +698,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.get("listTemplates", "/templates", { query: ListQuery, success: DashboardTemplateList, - error: [V2InvalidRequestError], + error: V2ParameterInvalid.schema, }).annotateMerge( OpenApi.annotations({ identifier: "listDashboardTemplates", @@ -691,7 +713,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { template_id: DashboardTemplatePublicId }, payload: V2DashboardTemplatePreviewParams, success: V2DashboardTemplatePreview, - error: [V2InvalidRequestError, V2NotFoundError], + error: [V2ParameterInvalid.schema, dashboardTemplateNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "previewDashboardTemplate", @@ -706,7 +728,12 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { template_id: DashboardTemplatePublicId }, payload: V2DashboardTemplateInstantiateParams, success: V2DashboardMutation, - error: [...mutationErrors, V2NotFoundError], + error: [ + V2ParameterInvalid.schema, + V2ParameterMissing.schema, + dashboardTemplateNotFound, + ...dashboardMutationErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "instantiateDashboardTemplate", @@ -720,7 +747,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: DashboardPublicId }, success: V2Dashboard, - error: [...commonErrors, V2NotFoundError], + error: [dashboardPersistence, dashboardNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "getDashboard", @@ -734,7 +761,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { id: DashboardPublicId }, payload: V2DashboardUpdateParams, success: V2DashboardMutation, - error: [...mutationErrors, V2NotFoundError], + error: [dashboardNotFound, ...dashboardMutationErrors], }).annotateMerge( OpenApi.annotations({ identifier: "updateDashboard", @@ -747,7 +774,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.delete("delete", "/:id", { params: { id: DashboardPublicId }, success: V2DashboardDeleteResponse, - error: [...commonErrors, V2NotFoundError], + error: [dashboardPersistence, dashboardNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "deleteDashboard", @@ -762,7 +789,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { id: DashboardPublicId }, query: ListQuery, success: DashboardVersionList, - error: [...commonErrors, V2NotFoundError], + error: [V2ParameterInvalid.schema, dashboardPersistence, dashboardNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "listDashboardVersions", @@ -775,7 +802,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.get("retrieveVersion", "/:id/versions/:version_id", { params: { id: DashboardPublicId, version_id: DashboardVersionPublicId }, success: V2DashboardVersionDetail, - error: [...commonErrors, V2NotFoundError], + error: [dashboardPersistence, dashboardNotFound, dashboardVersionNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "getDashboardVersion", @@ -788,7 +815,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.post("restoreVersion", "/:id/versions/:version_id/restore", { params: { id: DashboardPublicId, version_id: DashboardVersionPublicId }, success: V2DashboardMutation, - error: [...mutationErrors, V2NotFoundError], + error: [dashboardNotFound, dashboardVersionNotFound, ...dashboardMutationErrors], }).annotateMerge( OpenApi.annotations({ identifier: "restoreDashboardVersion", @@ -799,7 +826,6 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") ) .prefix("/v2/dashboards") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Dashboards", diff --git a/packages/domain/src/http/v2/envelopes.ts b/packages/domain/src/http/v2/envelopes.ts index 8bee1ad14..0839fc301 100644 --- a/packages/domain/src/http/v2/envelopes.ts +++ b/packages/domain/src/http/v2/envelopes.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect" -import { invalidRequest } from "./errors" +import { V2ParameterInvalid } from "./errors" /** * Shared v2 wire-format primitives (see docs/api-v2.md). @@ -113,7 +113,7 @@ export const decodeOffsetCursorEffect = (cursor: string | undefined) => { if (cursor === undefined) return Effect.succeed(0) const offset = decodeOffsetCursor(cursor) return offset === null - ? Effect.fail(invalidRequest("parameter_invalid", "Invalid pagination cursor.", "cursor")) + ? Effect.fail(V2ParameterInvalid.make("Invalid pagination cursor.", { param: "cursor" })) : Effect.succeed(offset) } diff --git a/packages/domain/src/http/v2/error-issues.ts b/packages/domain/src/http/v2/error-issues.ts index 5f2622498..a9474d785 100644 --- a/packages/domain/src/http/v2/error-issues.ts +++ b/packages/domain/src/http/v2/error-issues.ts @@ -1,10 +1,19 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { ActorType, IssueKind, IssueSeverity, IssueSeveritySource, WorkflowState } from "../errors" +import { + ActorType, + ErrorIssueNotFoundError, + ErrorPersistenceError, + IssueKind, + IssueSeverity, + IssueSeveritySource, + WorkflowState, +} from "../errors" import { SpanId, TraceId, UserId } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2NotFoundError, V2ServiceUnavailableError } from "./errors" +import { V2CursorInvalid, V2CursorSortMismatch } from "./errors" +import { publicErrors } from "./public-error" import { ActorPublicId, ErrorIncidentPublicId, ErrorIssuePublicId } from "./resource-ids" export const V2ErrorIssueActor = Schema.Struct({ @@ -148,14 +157,15 @@ const ErrorIssueServiceCountList = ListOf(V2ErrorIssueServiceCount).annotate({ identifier: "ErrorIssueServiceCountList", title: "Error issue service count list", }) -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const + +const [errorIssueNotFound, errorPersistence] = publicErrors(ErrorIssueNotFoundError, ErrorPersistenceError) export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") .add( HttpApiEndpoint.get("list", "/", { query: V2ErrorIssueListQuery, success: ErrorIssueList, - error: [...commonErrors], + error: [V2CursorInvalid.schema, V2CursorSortMismatch.schema, errorPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listErrorIssues", @@ -169,7 +179,7 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") // Static path — must be registered before the `/:id` param route. HttpApiEndpoint.get("serviceCounts", "/service_counts", { success: ErrorIssueServiceCountList, - error: [...commonErrors], + error: errorPersistence, }).annotateMerge( OpenApi.annotations({ identifier: "listErrorIssueServiceCounts", @@ -184,7 +194,7 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") params: { id: ErrorIssuePublicId }, query: V2ErrorIssueDetailQuery, success: V2ErrorIssueDetail, - error: [...commonErrors, V2NotFoundError], + error: [errorIssueNotFound, errorPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getErrorIssue", @@ -196,7 +206,6 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") ) .prefix("/v2/error_issues") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Error Issues", diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts index 5dc300009..cc2186d03 100644 --- a/packages/domain/src/http/v2/errors.ts +++ b/packages/domain/src/http/v2/errors.ts @@ -1,5 +1,11 @@ import { Schema } from "effect" -import { HttpErrorRecovery } from "../error-policy" +import { + HttpErrorRecovery, + PublicHttpErrorType, + publicHttpErrorTypeForStatus, + type PublicHttpErrorStatus, + type PublicHttpErrorTypeForStatus, +} from "../error-policy" /** * v2 error envelope (see docs/api-v2.md): every error response body is @@ -8,27 +14,61 @@ import { HttpErrorRecovery } from "../error-policy" * * These remain `Schema.Error`s rather than tagged Effect failures: `_tag` is * deliberately nested inside the public envelope and identifies the semantic - * failure that reached the boundary. Domain adapters preserve their original - * tag; errors born at the v2 boundary derive one from their stable code. + * failure that reached the boundary. Domain errors expose their original tag + * directly; errors born at the v2 boundary derive one from their stable code. */ -export const V2ErrorType = Schema.Literals([ - "invalid_request_error", - "authentication_error", - "permission_error", - "not_found_error", - "conflict_error", - "rate_limit_error", - "api_error", -]) +export const V2ErrorType = PublicHttpErrorType export type V2ErrorType = Schema.Schema.Type<typeof V2ErrorType> export const V2ErrorRecovery = HttpErrorRecovery export type V2ErrorRecovery = Schema.Schema.Type<typeof V2ErrorRecovery> +export type V2ErrorForStatus<Status extends PublicHttpErrorStatus> = Status extends 400 + ? V2InvalidRequestError + : Status extends 401 + ? V2AuthenticationError + : Status extends 403 + ? V2PermissionError + : Status extends 404 + ? V2NotFoundError + : Status extends 409 + ? V2ConflictError + : Status extends 413 + ? V2PayloadTooLargeError + : Status extends 429 + ? V2RateLimitError + : Status extends 500 + ? V2ApiError + : Status extends 502 + ? V2UpstreamError + : Status extends 503 + ? V2ServiceUnavailableError + : V2GatewayTimeoutError + +export type V2ErrorTypeForStatus<Status extends PublicHttpErrorStatus> = PublicHttpErrorTypeForStatus<Status> + +export interface V2PublicError<Tag extends string, Type extends string> { + readonly error: { + readonly _tag: Tag + readonly type: Type + readonly code: string + readonly title: string + readonly message: string + readonly retryable: boolean + readonly recovery: V2ErrorRecovery + readonly retry_after_seconds?: number + readonly retry_at?: string + readonly param?: string + readonly doc_url?: string + } +} + +export const errorTypeForStatus = publicHttpErrorTypeForStatus + /** Presentation/recovery metadata shared by every v2 error constructor. */ export interface V2ErrorMetadata { - /** Stable semantic identity. Domain adapters pass the original Effect `_tag`. */ + /** Stable semantic identity for errors created at the v2 boundary. */ readonly tag?: string readonly title?: string readonly retryable?: boolean @@ -43,70 +83,67 @@ interface ErrorExample { readonly param?: string } -const errorBody = <const T extends V2ErrorType>(type: T, example: ErrorExample) => - Schema.Struct({ - _tag: Schema.optionalKey( - Schema.String.check(Schema.isPattern(/^@maple\//)).annotate({ - description: - "Stable semantic error tag. Maple clients should branch on this; public integrations may continue branching on `code`.", +const errorBodyFields = <const T extends V2ErrorType>(type: T, example: ErrorExample) => ({ + type: Schema.Literal(type).annotate({ + description: + "Error category — a closed enum (`invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `conflict_error`, `rate_limit_error`, `api_error`). Branch on `code` for specifics.", + }), + code: Schema.String.annotate({ + description: + "Compact presentation category. Multiple semantic tags may share a code; branch on `_tag` for the exact failure.", + examples: [example.code], + }), + message: Schema.String.annotate({ + description: + "Human-readable explanation of what went wrong. For humans, not for programmatic branching.", + examples: [example.message], + }), + title: Schema.String.annotate({ + description: "Short, human-readable heading suitable for an error state or toast.", + }), + retryable: Schema.Boolean.annotate({ + description: + "Whether the same logical request can plausibly succeed later without correcting its input. Automatic mutation replay still requires idempotency protection.", + }), + recovery: V2ErrorRecovery.annotate({ + description: "Recommended next action for a person or API client.", + }), + retry_after_seconds: Schema.optionalKey( + Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).annotate({ + description: "Minimum delay before retrying, mirrored in the Retry-After header.", + }), + ), + retry_at: Schema.optionalKey( + Schema.String.check( + Schema.makeFilter((value: string) => Number.isFinite(Date.parse(value)), { + description: "Expected an ISO date-time string", }), - ), - type: Schema.Literal(type).annotate({ + ).annotate({ description: - "Error category — a closed enum (`invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `conflict_error`, `rate_limit_error`, `api_error`). Branch on `code` for specifics.", + "Absolute ISO-8601 retry time when the backend knows a reset instant rather than a fixed delay.", }), - code: Schema.String.annotate({ - description: "Stable, machine-readable error code. Codes are append-only; branch on this.", - examples: [example.code], + ), + param: Schema.optionalKey( + Schema.String.annotate({ + description: "The request parameter that caused the error, when applicable.", + ...(example.param !== undefined ? { examples: [example.param] } : {}), }), - message: Schema.String.annotate({ + ), + doc_url: Schema.optionalKey( + Schema.String.annotate({ + description: "Link to reference documentation for this error, when available.", + examples: ["https://api.maple.dev/v2/docs#errors"], + }), + ), +}) + +const errorBody = <const T extends V2ErrorType>(type: T, example: ErrorExample) => + Schema.Struct({ + _tag: Schema.String.check(Schema.isPattern(/^@maple\//)).annotate({ description: - "Human-readable explanation of what went wrong. For humans, not for programmatic branching.", - examples: [example.message], + "Stable semantic error tag. Branch on this for the exact failure; new tags may be added without changing the envelope shape.", }), - title: Schema.optionalKey( - Schema.String.annotate({ - description: "Short, human-readable heading suitable for an error state or toast.", - }), - ), - retryable: Schema.optionalKey( - Schema.Boolean.annotate({ - description: - "Whether the same logical request can plausibly succeed later without correcting its input. Automatic mutation replay still requires idempotency protection.", - }), - ), - recovery: Schema.optionalKey( - V2ErrorRecovery.annotate({ - description: "Recommended next action for a person or API client.", - }), - ), - retry_after_seconds: Schema.optionalKey( - Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).annotate({ - description: "Minimum delay before retrying, mirrored in the Retry-After header.", - }), - ), - retry_at: Schema.optionalKey( - Schema.String.check( - Schema.makeFilter((value: string) => Number.isFinite(Date.parse(value)), { - description: "Expected an ISO date-time string", - }), - ).annotate({ - description: - "Absolute ISO-8601 retry time when the backend knows a reset instant rather than a fixed delay.", - }), - ), - param: Schema.optionalKey( - Schema.String.annotate({ - description: "The request parameter that caused the error, when applicable.", - ...(example.param !== undefined ? { examples: [example.param] } : {}), - }), - ), - doc_url: Schema.optionalKey( - Schema.String.annotate({ - description: "Link to reference documentation for this error, when available.", - examples: ["https://api.maple.dev/v2/docs#errors"], - }), - ), + ...errorBodyFields(type, example), }) const defaultTitle: Record<V2ErrorType, string> = { @@ -375,7 +412,29 @@ export class V2ServiceUnavailableError extends Schema.Error<V2ServiceUnavailable } } -// Constructors — keep handler adapters one-liners. +/** `api_error` flavor for an operation that exceeded its server-side deadline (504). */ +export class V2GatewayTimeoutError extends Schema.Error<V2GatewayTimeoutError>( + "@maple/http/v2/GatewayTimeoutError", +)( + Schema.Struct({ + error: errorBody("api_error", { + code: "request_timeout", + message: "The operation timed out. Retry with a narrower request.", + }), + }).annotate({ identifier: "GatewayTimeoutError" }), + { + httpApiStatus: 504, + identifier: "GatewayTimeoutError", + title: "Gateway timeout error", + description: "The operation exceeded its server-side deadline. HTTP 504.", + }, +) { + override get message(): string { + return this.error.message + } +} + +// Constructors for failures created at the v2 boundary. export const invalidRequest = ( code: string, @@ -415,12 +474,20 @@ export const permissionError = (code: string, message: string, metadata: V2Error /** `resource_missing` matches Stripe's code for a bad object ID. */ export const notFound = (message: string, param?: string, metadata: V2ErrorMetadata = {}) => + notFoundError("resource_missing", message, param, metadata) + +export const notFoundError = ( + code: string, + message: string, + param?: string, + metadata: V2ErrorMetadata = {}, +) => new V2NotFoundError({ error: { type: "not_found_error", - code: "resource_missing", + code, message, - ...errorMetadata("not_found_error", "resource_missing", {}, metadata), + ...errorMetadata("not_found_error", code, {}, metadata), ...(param !== undefined ? { param } : {}), }, }) @@ -433,15 +500,7 @@ export const resourceNotFound = ( metadata: V2ErrorMetadata = {}, ) => { const code = `${resource}_not_found` - return new V2NotFoundError({ - error: { - type: "not_found_error", - code, - message, - param, - ...errorMetadata("not_found_error", code, {}, metadata), - }, - }) + return notFoundError(code, message, param, metadata) } export const conflict = (code: string, message: string, metadata: V2ErrorMetadata = {}) => @@ -461,12 +520,20 @@ export const conflict = (code: string, message: string, metadata: V2ErrorMetadat * telling the user exactly what to do is the whole value. */ export const payloadTooLarge = (message: string, param?: string, metadata: V2ErrorMetadata = {}) => + payloadTooLargeError("range_too_large", message, param, metadata) + +export const payloadTooLargeError = ( + code: string, + message: string, + param?: string, + metadata: V2ErrorMetadata = {}, +) => new V2PayloadTooLargeError({ error: { type: "invalid_request_error", - code: "range_too_large", + code, message, - ...errorMetadata("invalid_request_error", "range_too_large", {}, metadata), + ...errorMetadata("invalid_request_error", code, {}, metadata), ...(param !== undefined ? { param } : {}), }, }) @@ -541,17 +608,19 @@ export const upstreamError = (code: string, message: string, metadata: V2ErrorMe }) export const apiError = (metadata: V2ErrorMetadata = {}) => + serverError("internal_error", "An unexpected error occurred on our end.", { + tag: "@maple/http/v2/UnexpectedApiError", + title: "Something went wrong", + ...metadata, + }) + +export const serverError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => new V2ApiError({ error: { type: "api_error", - code: "internal_error", - message: "An unexpected error occurred on our end.", - ...errorMetadata( - "api_error", - "internal_error", - { tag: "@maple/http/v2/UnexpectedApiError", title: "Something went wrong" }, - metadata, - ), + code, + message, + ...errorMetadata("api_error", code, {}, metadata), }, }) @@ -573,10 +642,332 @@ export const serviceError = (code: string, message: string, metadata: V2ErrorMet export const serviceUnavailable = (message: string, metadata: V2ErrorMetadata = {}) => serviceError("service_unavailable", message, metadata) -/** Sanitized dependency failure with a stable operation-specific public code. */ +export const gatewayTimeoutError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => + new V2GatewayTimeoutError({ + error: { + type: "api_error", + code, + message, + ...errorMetadata( + "api_error", + code, + { title: "Operation timed out", retryable: true, recovery: "retry" }, + metadata, + ), + }, + }) + +/** Sanitized dependency failure for boundary-created errors. */ export const dependencyUnavailable = (code: string, metadata: V2ErrorMetadata = {}) => serviceError( code, "A service required for this operation is temporarily unavailable; retry with backoff.", metadata, ) + +export interface V2ErrorDefinitionOptions< + Tag extends string, + Status extends PublicHttpErrorStatus, + Code extends string, +> { + readonly tag: Tag + readonly status: Status + readonly code: Code + readonly title: string + readonly message: string + readonly retryable: boolean + readonly recovery: V2ErrorRecovery + readonly identifier: string +} + +export interface V2ErrorMakeOptions { + readonly param?: string + readonly retryAfterSeconds?: number + readonly retryAt?: string +} + +export interface V2ErrorSchemaOptions<Tag extends string, Status extends PublicHttpErrorStatus> { + readonly tag: Tag + readonly status: Status + readonly identifier: string + readonly title: string + readonly description?: string + readonly codeExample?: string +} + +/** Build one exact OpenAPI branch for a single semantic error tag. */ +export const makeV2ErrorSchema = <const Tag extends string, const Status extends PublicHttpErrorStatus>( + options: V2ErrorSchemaOptions<Tag, Status>, +) => { + const type = errorTypeForStatus(options.status) + return Schema.Struct({ + error: Schema.Struct({ + _tag: Schema.Literal(options.tag).annotate({ + description: "Stable semantic error tag. Branch on this exact value.", + }), + type: Schema.Literal(type).annotate({ + description: "Broad error category shared by related semantic tags.", + }), + code: Schema.String.annotate({ + description: + "Compact presentation category. Branch on `_tag` when the exact failure matters.", + ...(options.codeExample === undefined ? {} : { examples: [options.codeExample] }), + }), + title: Schema.String.annotate({ + description: "Short, human-readable heading suitable for an error state or toast.", + }), + message: Schema.String.annotate({ + description: "Human-readable explanation for people, not programmatic branching.", + }), + retryable: Schema.Boolean.annotate({ + description: "Whether the same logical request can plausibly succeed later.", + }), + recovery: V2ErrorRecovery.annotate({ + description: "Recommended next action for an API client or person.", + }), + retry_after_seconds: Schema.optionalKey( + Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).annotate({ + description: "Minimum delay before retrying, mirrored in the Retry-After header.", + }), + ), + retry_at: Schema.optionalKey( + Schema.String.check( + Schema.makeFilter((value: string) => Number.isFinite(Date.parse(value)), { + description: "Expected an ISO date-time string", + }), + ).annotate({ description: "Absolute ISO-8601 time at which a retry may succeed." }), + ), + param: Schema.optionalKey( + Schema.String.annotate({ description: "Request parameter associated with the failure." }), + ), + doc_url: Schema.optionalKey( + Schema.String.annotate({ description: "Reference documentation for this failure." }), + ), + }), + }).annotate({ + httpApiStatus: options.status, + identifier: options.identifier, + title: options.title, + description: options.description ?? `The ${options.tag} failure. HTTP ${options.status}.`, + }) +} + +/** + * Define an error that is born at the v2 boundary rather than in a domain + * service. Its literal tag schema and constructor are inseparable, so a route + * cannot document one tag and emit another. + */ +export const defineV2Error = < + const Tag extends string, + const Status extends PublicHttpErrorStatus, + const Code extends string, +>( + definition: V2ErrorDefinitionOptions<Tag, Status, Code>, +) => { + const type = errorTypeForStatus(definition.status) + const schema = makeV2ErrorSchema({ + tag: definition.tag, + status: definition.status, + identifier: definition.identifier, + title: definition.title, + codeExample: definition.code, + }) + + const make = ( + message: string = definition.message, + options: V2ErrorMakeOptions = {}, + ): V2ErrorForStatus<Status> & V2PublicError<Tag, V2ErrorTypeForStatus<Status>> => { + const metadata = { + tag: definition.tag, + title: definition.title, + retryable: definition.retryable, + recovery: definition.recovery, + ...(options.retryAfterSeconds === undefined + ? {} + : { retryAfterSeconds: options.retryAfterSeconds }), + ...(options.retryAt === undefined ? {} : { retryAt: options.retryAt }), + } + let error: V2ErrorForStatus<PublicHttpErrorStatus> + switch (definition.status) { + case 400: + error = invalidRequest(definition.code, message, options.param, metadata) + break + case 401: + error = authenticationError(definition.code, message, metadata) + break + case 403: + error = permissionError(definition.code, message, metadata) + break + case 404: + error = notFoundError(definition.code, message, options.param, metadata) + break + case 409: + error = conflict(definition.code, message, metadata) + break + case 413: + error = payloadTooLargeError(definition.code, message, options.param, metadata) + break + case 429: + error = rateLimitError(definition.code, message, metadata) + break + case 500: + error = serverError(definition.code, message, metadata) + break + case 502: + error = upstreamError(definition.code, message, metadata) + break + case 503: + error = serviceError(definition.code, message, metadata) + break + case 504: + error = gatewayTimeoutError(definition.code, message, metadata) + break + } + return error as V2ErrorForStatus<Status> & V2PublicError<Tag, V2ErrorTypeForStatus<Status>> + } + + return { ...definition, type, schema, make } as const +} + +export const V2InvalidRequest = defineV2Error({ + tag: "@maple/http/v2/InvalidRequestError", + status: 400, + code: "parameter_invalid", + title: "Invalid request", + message: "The request did not match the endpoint schema.", + retryable: false, + recovery: "fix_request", + identifier: "InvalidRequestError", +}) + +export const V2InvalidCredentials = defineV2Error({ + tag: "@maple/http/v2/InvalidCredentialsError", + status: 401, + code: "invalid_credentials", + title: "Sign in required", + message: "Invalid or missing credentials.", + retryable: false, + recovery: "reauthenticate", + identifier: "InvalidCredentialsError", +}) + +export const V2InsufficientScope = defineV2Error({ + tag: "@maple/http/v2/InsufficientScopeError", + status: 403, + code: "insufficient_scope", + title: "Permission required", + message: "The API key does not have the scope required for this request.", + retryable: false, + recovery: "request_access", + identifier: "InsufficientScopeError", +}) + +export const V2InsufficientPermissions = defineV2Error({ + tag: "@maple/http/v2/InsufficientPermissionsError", + status: 403, + code: "insufficient_permissions", + title: "Permission required", + message: "Only organization administrators can perform this operation.", + retryable: false, + recovery: "request_access", + identifier: "InsufficientPermissionsError", +}) + +export const V2ParameterInvalid = defineV2Error({ + tag: "@maple/http/v2/ParameterInvalidError", + status: 400, + code: "parameter_invalid", + title: "Invalid request", + message: "A request parameter is invalid.", + retryable: false, + recovery: "fix_request", + identifier: "ParameterInvalidError", +}) + +export const V2ParameterMissing = defineV2Error({ + tag: "@maple/http/v2/ParameterMissingError", + status: 400, + code: "parameter_missing", + title: "Missing request parameter", + message: "A required request parameter is missing.", + retryable: false, + recovery: "fix_request", + identifier: "ParameterMissingError", +}) + +export const V2TimeRangeInvalid = defineV2Error({ + tag: "@maple/http/v2/TimeRangeInvalidError", + status: 400, + code: "invalid_time_range", + title: "Invalid time range", + message: "end_time must be after start_time.", + retryable: false, + recovery: "fix_request", + identifier: "TimeRangeInvalidError", +}) + +export const V2CursorInvalid = defineV2Error({ + tag: "@maple/http/v2/CursorInvalidError", + status: 400, + code: "cursor_invalid", + title: "Invalid pagination cursor", + message: "Invalid pagination cursor.", + retryable: false, + recovery: "fix_request", + identifier: "CursorInvalidError", +}) + +export const V2CursorSortMismatch = defineV2Error({ + tag: "@maple/http/v2/CursorSortMismatchError", + status: 400, + code: "cursor_sort_mismatch", + title: "Cursor does not match sort", + message: "Cursor does not match the selected sort.", + retryable: false, + recovery: "fix_request", + identifier: "CursorSortMismatchError", +}) + +export const V2CallbackHostUnavailable = defineV2Error({ + tag: "@maple/http/v2/CallbackHostUnavailableError", + status: 503, + code: "callback_host_unavailable", + title: "Integration setup unavailable", + message: "Integration setup is not available from this host.", + retryable: false, + recovery: "contact_support", + identifier: "CallbackHostUnavailableError", +}) + +export const V2RateLimited = defineV2Error({ + tag: "@maple/http/v2/RateLimitError", + status: 429, + code: "rate_limited", + title: "Too many requests", + message: "Too many requests. Retry after the interval in the Retry-After header.", + retryable: true, + recovery: "retry", + identifier: "RateLimitError", +}) + +export const V2ResponseSchemaFailure = defineV2Error({ + tag: "@maple/http/v2/ResponseSchemaError", + status: 500, + code: "internal_error", + title: "Something went wrong", + message: "An unexpected error occurred on our end.", + retryable: false, + recovery: "contact_support", + identifier: "ResponseSchemaError", +}) + +export const V2UnexpectedFailure = defineV2Error({ + tag: "@maple/http/v2/UnexpectedError", + status: 500, + code: "internal_error", + title: "Something went wrong", + message: "An unexpected error occurred on our end.", + retryable: false, + recovery: "contact_support", + identifier: "UnexpectedError", +}) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 526aadc0e..3a0cd0360 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -15,9 +15,10 @@ export * from "./ingest-keys" export * from "./integrations" export * from "./integrations-planetscale" export * from "./investigations" -export * from "./investigation-error" export * from "./organization" export * from "./public-id" +export * from "./public-error" +export * from "./query-errors" export * from "./recommendations" export * from "./resource-ids" export * from "./scrape-targets" diff --git a/packages/domain/src/http/v2/ingest-keys.ts b/packages/domain/src/http/v2/ingest-keys.ts index 8938638e7..97d903805 100644 --- a/packages/domain/src/http/v2/ingest-keys.ts +++ b/packages/domain/src/http/v2/ingest-keys.ts @@ -1,8 +1,10 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { IngestKeyEncryptionError, IngestKeyForbiddenError, IngestKeyPersistenceError } from "../ingest-keys" +import { AuthorizationV2 } from "./auth" import { Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2PermissionError, V2ServiceUnavailableError } from "./errors" +import { V2InsufficientPermissions } from "./errors" +import { publicErrors } from "./public-error" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = <A>(example: object): A => example as A @@ -48,13 +50,17 @@ export const V2IngestKeys = Schema.Struct({ }) export type V2IngestKeys = Schema.Schema.Type<typeof V2IngestKeys> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError, V2PermissionError] as const +const ingestKeyErrors = publicErrors( + IngestKeyForbiddenError, + IngestKeyPersistenceError, + IngestKeyEncryptionError, +) export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") .add( HttpApiEndpoint.get("retrieve", "/", { success: V2IngestKeys, - error: [...commonErrors], + error: [V2InsufficientPermissions.schema, ...ingestKeyErrors], }).annotateMerge( OpenApi.annotations({ identifier: "getIngestKeys", @@ -67,7 +73,7 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") .add( HttpApiEndpoint.post("rollPublic", "/public/roll", { success: V2IngestKeys, - error: [...commonErrors], + error: [V2InsufficientPermissions.schema, ...ingestKeyErrors], }).annotateMerge( OpenApi.annotations({ identifier: "rollPublicIngestKey", @@ -80,7 +86,7 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") .add( HttpApiEndpoint.post("rollPrivate", "/private/roll", { success: V2IngestKeys, - error: [...commonErrors], + error: [V2InsufficientPermissions.schema, ...ingestKeyErrors], }).annotateMerge( OpenApi.annotations({ identifier: "rollPrivateIngestKey", @@ -92,7 +98,6 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") ) .prefix("/v2/ingest_keys") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Ingest Keys", diff --git a/packages/domain/src/http/v2/integrations-planetscale.ts b/packages/domain/src/http/v2/integrations-planetscale.ts index 29eff98e4..8c022ea7e 100644 --- a/packages/domain/src/http/v2/integrations-planetscale.ts +++ b/packages/domain/src/http/v2/integrations-planetscale.ts @@ -1,15 +1,18 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { UserId } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" -import { Timestamp } from "./envelopes" import { - V2InvalidRequestError, - V2NotFoundError, - V2PermissionError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "./errors" + IntegrationsConfigurationError, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsValidationError, +} from "../integrations" +import { AuthorizationV2 } from "./auth" +import { Timestamp } from "./envelopes" +import { V2CallbackHostUnavailable, V2InsufficientPermissions, V2TimeRangeInvalid } from "./errors" +import { publicErrors } from "./public-error" import { ScrapeTargetPublicId } from "./scrape-targets" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ @@ -762,15 +765,36 @@ export const V2PlanetScaleEventList = Schema.Struct({ }) export type V2PlanetScaleEventList = Schema.Schema.Type<typeof V2PlanetScaleEventList> -// Errors are declared per endpoint rather than from a shared tuple, matching the -// Slack group: each handler maps a small, fixed set of service failures, and a -// wider list would publish responses the API can never return. 400/401/403/429/503 -// come from the middleware. +const [ + integrationConfiguration, + integrationNotConnected, + integrationRevoked, + integrationValidation, + integrationUpstream, + integrationPersistence, +] = publicErrors( + IntegrationsConfigurationError, + IntegrationsNotConnectedError, + IntegrationsRevokedError, + IntegrationsValidationError, + IntegrationsUpstreamError, + IntegrationsPersistenceError, +) + +const organizationErrors = [ + integrationConfiguration, + integrationNotConnected, + integrationRevoked, + integrationValidation, + integrationUpstream, + integrationPersistence, +] as const + export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planetscaleIntegration") .add( HttpApiEndpoint.get("status", "/", { success: V2PlanetScaleIntegration, - error: [V2ServiceUnavailableError], + error: [integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getPlanetScaleIntegration", @@ -786,7 +810,12 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet // No upstream error: nothing is sent to PlanetScale until the browser // follows the returned authorize URL. success: V2PlanetScaleConnectResponse, - error: [V2PermissionError, V2ServiceUnavailableError], + error: [ + V2InsufficientPermissions.schema, + V2CallbackHostUnavailable.schema, + integrationConfiguration, + integrationPersistence, + ], }).annotateMerge( OpenApi.annotations({ identifier: "connectPlanetScaleIntegration", @@ -799,13 +828,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet .add( HttpApiEndpoint.get("organizations", "/organizations", { success: V2PlanetScaleOrganizationList, - error: [ - V2PermissionError, - V2NotFoundError, - V2InvalidRequestError, - V2UpstreamError, - V2ServiceUnavailableError, - ], + error: [V2InsufficientPermissions.schema, ...organizationErrors], }).annotateMerge( OpenApi.annotations({ identifier: "listPlanetScaleOrganizations", @@ -819,13 +842,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet HttpApiEndpoint.post("selectOrganization", "/select_organization", { payload: V2PlanetScaleSelectOrganizationRequest, success: V2PlanetScaleIntegration, - error: [ - V2PermissionError, - V2NotFoundError, - V2InvalidRequestError, - V2UpstreamError, - V2ServiceUnavailableError, - ], + error: [V2InsufficientPermissions.schema, ...organizationErrors], }).annotateMerge( OpenApi.annotations({ identifier: "selectPlanetScaleOrganization", @@ -840,11 +857,11 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet payload: V2PlanetScaleMetricsTokenRequest, success: V2PlanetScaleIntegration, error: [ - V2PermissionError, - V2NotFoundError, - V2InvalidRequestError, - V2UpstreamError, - V2ServiceUnavailableError, + V2InsufficientPermissions.schema, + integrationNotConnected, + integrationValidation, + integrationUpstream, + integrationPersistence, ], }).annotateMerge( OpenApi.annotations({ @@ -858,7 +875,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet .add( HttpApiEndpoint.delete("disconnect", "/", { success: V2PlanetScaleDisconnectResponse, - error: [V2PermissionError, V2ServiceUnavailableError], + error: [V2InsufficientPermissions.schema, integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "disconnectPlanetScaleIntegration", @@ -871,7 +888,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet .add( HttpApiEndpoint.get("databases", "/databases", { success: V2PlanetScaleDatabaseList, - error: [V2ServiceUnavailableError], + error: [integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listPlanetScaleDatabases", @@ -884,7 +901,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet .add( HttpApiEndpoint.get("webhookConfig", "/webhook_config", { success: V2PlanetScaleWebhookConfig, - error: [V2PermissionError, V2ServiceUnavailableError], + error: [V2InsufficientPermissions.schema, integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getPlanetScaleWebhookConfig", @@ -898,7 +915,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet HttpApiEndpoint.post("queryInsights", "/query_insights", { payload: V2PlanetScaleQueryInsightsRequest, success: V2PlanetScaleQueryInsightList, - error: [V2NotFoundError, V2InvalidRequestError, V2UpstreamError, V2ServiceUnavailableError], + error: [V2TimeRangeInvalid.schema, ...organizationErrors], }).annotateMerge( OpenApi.annotations({ identifier: "queryPlanetScaleQueryInsights", @@ -912,7 +929,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet HttpApiEndpoint.post("events", "/events", { payload: V2PlanetScaleEventsRequest, success: V2PlanetScaleEventList, - error: [V2InvalidRequestError, V2ServiceUnavailableError], + error: [V2TimeRangeInvalid.schema, integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listPlanetScaleEvents", @@ -924,7 +941,6 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet ) .prefix("/v2/integrations/planetscale") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "PlanetScale Integration", diff --git a/packages/domain/src/http/v2/integrations.ts b/packages/domain/src/http/v2/integrations.ts index 924a22033..bcf46f0da 100644 --- a/packages/domain/src/http/v2/integrations.ts +++ b/packages/domain/src/http/v2/integrations.ts @@ -1,8 +1,15 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { + IntegrationsConfigurationError, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsUpstreamError, +} from "../integrations" +import { AuthorizationV2 } from "./auth" import { Timestamp } from "./envelopes" -import { V2NotFoundError, V2PermissionError, V2ServiceUnavailableError, V2UpstreamError } from "./errors" +import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" +import { publicErrors } from "./public-error" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = <A>(example: object): A => example as A @@ -163,15 +170,19 @@ export const V2SlackChannelList = Schema.Struct({ }) export type V2SlackChannelList = Schema.Schema.Type<typeof V2SlackChannelList> -// Declared per endpoint rather than from a shared `commonErrors` tuple: the -// handlers in apps/api/src/routes/v2/integrations.http.ts each map a small, -// fixed set of service failures, and a wider list would publish responses the -// API can never return. 400/401/403/429/503 come from the middleware. +const [integrationConfiguration, integrationNotConnected, integrationUpstream, integrationPersistence] = + publicErrors( + IntegrationsConfigurationError, + IntegrationsNotConnectedError, + IntegrationsUpstreamError, + IntegrationsPersistenceError, + ) + export class V2SlackIntegrationsApiGroup extends HttpApiGroup.make("slackIntegration") .add( HttpApiEndpoint.get("status", "/", { success: V2SlackIntegrationStatus, - error: [V2ServiceUnavailableError], + error: [integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getSlackIntegration", @@ -184,7 +195,12 @@ export class V2SlackIntegrationsApiGroup extends HttpApiGroup.make("slackIntegra .add( HttpApiEndpoint.post("install", "/install", { success: V2SlackInstallResponse, - error: [V2ServiceUnavailableError, V2PermissionError], + error: [ + V2InsufficientPermissions.schema, + V2CallbackHostUnavailable.schema, + integrationConfiguration, + integrationPersistence, + ], }).annotateMerge( OpenApi.annotations({ identifier: "installSlackIntegration", @@ -197,7 +213,7 @@ export class V2SlackIntegrationsApiGroup extends HttpApiGroup.make("slackIntegra .add( HttpApiEndpoint.delete("uninstall", "/", { success: V2SlackUninstallResponse, - error: [V2ServiceUnavailableError, V2PermissionError], + error: [V2InsufficientPermissions.schema, integrationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "uninstallSlackIntegration", @@ -210,7 +226,12 @@ export class V2SlackIntegrationsApiGroup extends HttpApiGroup.make("slackIntegra .add( HttpApiEndpoint.get("channels", "/channels", { success: V2SlackChannelList, - error: [V2ServiceUnavailableError, V2NotFoundError, V2UpstreamError, V2PermissionError], + error: [ + V2InsufficientPermissions.schema, + integrationNotConnected, + integrationUpstream, + integrationPersistence, + ], }).annotateMerge( OpenApi.annotations({ identifier: "listSlackChannels", @@ -222,7 +243,6 @@ export class V2SlackIntegrationsApiGroup extends HttpApiGroup.make("slackIntegra ) .prefix("/v2/integrations/slack") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Slack Integration", diff --git a/packages/domain/src/http/v2/investigation-error.test.ts b/packages/domain/src/http/v2/investigation-error.test.ts index 46e0ec39b..d95092dda 100644 --- a/packages/domain/src/http/v2/investigation-error.test.ts +++ b/packages/domain/src/http/v2/investigation-error.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "@effect/vitest" import { Schema } from "effect" -import { IsoDateTimeString } from "../../primitives" +import { InvestigationId, IsoDateTimeString } from "../../primitives" import { InvestigationAgentUnavailableError, InvestigationAutomationDisabledError, + InvestigationDataCorruptionError, InvestigationNotFoundError, InvestigationPersistenceError, InvestigationQuotaError, @@ -11,12 +12,11 @@ import { InvestigationStartFailedError, InvestigationValidationError, } from "../investigations" -import { investigationErrorToV2 } from "./investigation-error" -const map = investigationErrorToV2("restart") const retryableAt = Schema.decodeUnknownSync(IsoDateTimeString)("2026-08-12T00:00:00.000Z") +const investigationId = Schema.decodeUnknownSync(InvestigationId)("00000000-0000-0000-0000-000000000000") -describe("investigationErrorToV2", () => { +describe("investigation public errors", () => { it("preserves each unique domain tag in the public envelope", () => { const errors = [ new InvestigationPersistenceError({ message: "db failed" }), @@ -32,40 +32,44 @@ describe("investigationErrorToV2", () => { new InvestigationAgentUnavailableError({ message: "agent unavailable" }), new InvestigationStartFailedError({ message: "start failed" }), new InvestigationRejectedError({ message: "rejected", status: 401 }), + new InvestigationDataCorruptionError({ + message: "invalid stored trace id", + investigationId, + field: "report.trace_id", + value: "invalid", + }), ] as const for (const error of errors) { - expect(map(error).error._tag).toBe(error._tag) + expect(error.error._tag).toBe(error._tag) } }) it("derives retry and recovery from the semantic tag", () => { - const disabled = map(new InvestigationAutomationDisabledError({ message: "disabled" })) + const disabled = new InvestigationAutomationDisabledError({ message: "disabled" }) expect(disabled.error.retryable).toBe(false) expect(disabled.error.recovery).toBe("none") - const unavailable = map(new InvestigationAgentUnavailableError({ message: "agent unavailable" })) + const unavailable = new InvestigationAgentUnavailableError({ message: "agent unavailable" }) expect(unavailable.error.retryable).toBe(true) expect(unavailable.error.recovery).toBe("retry") - const rejected = map(new InvestigationRejectedError({ message: "rejected", status: 401 })) + const rejected = new InvestigationRejectedError({ message: "rejected", status: 401 }) expect(rejected.error.retryable).toBe(false) expect(rejected.error.recovery).toBe("reconnect") }) - it("retains operation codes and the absolute quota reset", () => { - const persistence = map(new InvestigationPersistenceError({ message: "private db url" })) - expect(persistence.error.code).toBe("investigation_restart_unavailable") + it("uses tag-owned codes and retains the absolute quota reset", () => { + const persistence = new InvestigationPersistenceError({ message: "private db url" }) + expect(persistence.error.code).toBe("investigations_unavailable") expect(persistence.error.message).not.toContain("private db url") - const quota = map( - new InvestigationQuotaError({ - message: "quota", - dimension: "passes", - limit: 90, - retryableAt, - }), - ) + const quota = new InvestigationQuotaError({ + message: "quota", + dimension: "passes", + limit: 90, + retryableAt, + }) expect(quota.error.retry_at).toBe("2026-08-12T00:00:00.000Z") }) }) diff --git a/packages/domain/src/http/v2/investigation-error.ts b/packages/domain/src/http/v2/investigation-error.ts deleted file mode 100644 index bd21840a0..000000000 --- a/packages/domain/src/http/v2/investigation-error.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Match } from "effect" -import { httpErrorMetadata } from "../error-policy" -import { investigationErrorPolicy } from "../investigation-error-meta" -import type { - InvestigationAgentUnavailableError, - InvestigationAutomationDisabledError, - InvestigationHttpError, - InvestigationNotFoundError, - InvestigationPersistenceError, - InvestigationQuotaError, - InvestigationRejectedError, - InvestigationStartFailedError, - InvestigationValidationError, -} from "../investigations" -import { - dependencyUnavailable, - invalidRequest, - investigationQuotaReached, - resourceNotFound, - serviceError, - upstreamError, - type V2InvalidRequestError, - type V2NotFoundError, - type V2RateLimitError, - type V2ServiceUnavailableError, - type V2UpstreamError, -} from "./errors" - -export type V2InvestigationDomainError = - | V2InvalidRequestError - | V2NotFoundError - | V2RateLimitError - | V2ServiceUnavailableError - | V2UpstreamError - -export type V2InvestigationErrorFor<Error extends InvestigationHttpError> = - Error extends InvestigationValidationError - ? V2InvalidRequestError - : Error extends InvestigationNotFoundError - ? V2NotFoundError - : Error extends InvestigationQuotaError - ? V2RateLimitError - : Error extends InvestigationRejectedError - ? V2UpstreamError - : Error extends - | InvestigationPersistenceError - | InvestigationAutomationDisabledError - | InvestigationAgentUnavailableError - | InvestigationStartFailedError - ? V2ServiceUnavailableError - : never - -/** - * The single investigation-domain → public-envelope boundary. - * - * Routes provide only the operation name used by existing stable public codes; - * the semantic tag, retry policy, title, recovery, and redaction rules all come - * from the domain error itself and its exhaustive policy table. - */ -export const investigationErrorToV2 = ( - operation: string, -): (<Error extends InvestigationHttpError>(error: Error) => V2InvestigationErrorFor<Error>) => { - const map = Match.type<InvestigationHttpError>().pipe( - Match.tagsExhaustive({ - "@maple/http/investigations/InvestigationPersistenceError": (error) => - dependencyUnavailable( - `investigation_${operation}_unavailable`, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - "@maple/http/investigations/InvestigationValidationError": (error) => - invalidRequest( - "parameter_invalid", - error.message, - undefined, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - "@maple/http/investigations/InvestigationNotFoundError": (error) => - resourceNotFound( - "investigation", - "No such investigation.", - "id", - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - "@maple/http/investigations/InvestigationQuotaError": (error) => - investigationQuotaReached( - { - dimension: error.dimension, - limit: error.limit, - retryableAt: error.retryableAt, - }, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag], { - retryAt: error.retryableAt, - }), - ), - "@maple/http/investigations/InvestigationAutomationDisabledError": (error) => - serviceError( - "investigation_automation_disabled", - error.message, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - "@maple/http/investigations/InvestigationAgentUnavailableError": (error) => - serviceError( - "investigation_agent_unavailable", - error.message, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - "@maple/http/investigations/InvestigationStartFailedError": (error) => - serviceError( - "investigation_start_failed", - error.message, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - "@maple/http/investigations/InvestigationRejectedError": (error) => - upstreamError( - "investigation_start_rejected", - `The investigation agent rejected the start request with HTTP ${error.status}.`, - httpErrorMetadata(error._tag, investigationErrorPolicy[error._tag]), - ), - }), - ) - return <Error extends InvestigationHttpError>(error: Error): V2InvestigationErrorFor<Error> => - map(error) as V2InvestigationErrorFor<Error> -} diff --git a/packages/domain/src/http/v2/investigations.ts b/packages/domain/src/http/v2/investigations.ts index d6336b05a..f203fbb08 100644 --- a/packages/domain/src/http/v2/investigations.ts +++ b/packages/domain/src/http/v2/investigations.ts @@ -4,24 +4,27 @@ import { AiTriageIncidentKind } from "../ai-triage" import { IssueSeverity } from "../errors" import { InvestigationConfidence, + InvestigationAgentUnavailableError, + InvestigationAutomationDisabledError, + InvestigationDataCorruptionError, InvestigationFanoutState, + InvestigationNotFoundError, + InvestigationPersistenceError, + InvestigationQuotaError, + InvestigationRejectedError, InvestigationSeededBy, + InvestigationStartFailedError, InvestigationStatus, LensId, LensRunStatus, LensVerdict, } from "../investigations" import { TraceId, UserId } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { - V2InvalidRequestError, - V2NotFoundError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "./errors" +import { V2ParameterInvalid } from "./errors" import { encodePublicId, PublicIdPrefixes } from "./public-id" +import { publicErrors } from "./public-error" import { AlertIncidentPublicId, AnomalyIncidentPublicId, @@ -494,8 +497,35 @@ export const V2InvestigationsListQuery = Schema.Struct({ }) export type V2InvestigationsListQuery = Schema.Schema.Type<typeof V2InvestigationsListQuery> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const -const startErrors = [...commonErrors, V2RateLimitError, V2UpstreamError] as const +const [ + investigationPersistence, + investigationNotFound, + investigationQuota, + investigationAutomationDisabled, + investigationAgentUnavailable, + investigationStartFailed, + investigationRejected, + investigationDataCorruption, +] = publicErrors( + InvestigationPersistenceError, + InvestigationNotFoundError, + InvestigationQuotaError, + InvestigationAutomationDisabledError, + InvestigationAgentUnavailableError, + InvestigationStartFailedError, + InvestigationRejectedError, + InvestigationDataCorruptionError, +) + +const investigationStartErrors = [ + investigationPersistence, + investigationQuota, + investigationAutomationDisabled, + investigationAgentUnavailable, + investigationStartFailed, + investigationRejected, + investigationDataCorruption, +] as const const InvestigationList = ListOf(V2Investigation).annotate({ identifier: "InvestigationList", @@ -508,7 +538,7 @@ export class V2InvestigationsApiGroup extends HttpApiGroup.make("investigations" HttpApiEndpoint.get("list", "/", { query: V2InvestigationsListQuery, success: InvestigationList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, investigationPersistence, investigationDataCorruption], }).annotateMerge( OpenApi.annotations({ identifier: "listInvestigations", @@ -522,7 +552,7 @@ export class V2InvestigationsApiGroup extends HttpApiGroup.make("investigations" HttpApiEndpoint.get("retrieve", "/:id", { params: { id: InvestigationPublicId }, success: V2Investigation, - error: [...commonErrors, V2NotFoundError], + error: [investigationPersistence, investigationNotFound, investigationDataCorruption], }).annotateMerge( OpenApi.annotations({ identifier: "getInvestigation", @@ -536,7 +566,7 @@ export class V2InvestigationsApiGroup extends HttpApiGroup.make("investigations" HttpApiEndpoint.post("create", "/", { payload: V2InvestigationCreateParams, success: V2Investigation, - error: [...startErrors], + error: investigationStartErrors, }).annotateMerge( OpenApi.annotations({ identifier: "createInvestigation", @@ -550,7 +580,7 @@ export class V2InvestigationsApiGroup extends HttpApiGroup.make("investigations" HttpApiEndpoint.post("restart", "/:id/restart", { params: { id: InvestigationPublicId }, success: V2Investigation, - error: [...startErrors, V2NotFoundError], + error: [investigationNotFound, ...investigationStartErrors], }).annotateMerge( OpenApi.annotations({ identifier: "restartInvestigation", @@ -565,7 +595,7 @@ export class V2InvestigationsApiGroup extends HttpApiGroup.make("investigations" params: { id: InvestigationPublicId }, payload: V2InvestigationStatusUpdateParams, success: V2Investigation, - error: [...commonErrors, V2NotFoundError], + error: [investigationPersistence, investigationNotFound, investigationDataCorruption], }).annotateMerge( OpenApi.annotations({ identifier: "updateInvestigationStatus", @@ -577,7 +607,6 @@ export class V2InvestigationsApiGroup extends HttpApiGroup.make("investigations" ) .prefix("/v2/investigations") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Investigations", diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index da55a215b..ec99ccb8c 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -35,6 +35,25 @@ const doc = spec as unknown as Record<string, any> const schemas = doc.components.schemas as Record<string, any> const operation = (method: string, path: string): Record<string, any> => (spec.paths as Record<string, any>)[path][method] +const resolveSchema = (schema: Record<string, any>): Record<string, any> => { + const ref = schema.$ref as string | undefined + return ref === undefined ? schema : schemas[ref.slice(ref.lastIndexOf("/") + 1)] +} +const schemaBranches = (schema: Record<string, any>): ReadonlyArray<Record<string, any>> => { + const resolved = resolveSchema(schema) + const alternatives = (resolved.anyOf ?? resolved.oneOf) as ReadonlyArray<Record<string, any>> | undefined + return alternatives === undefined ? [resolved] : alternatives.flatMap(schemaBranches) +} +const responseErrorTags = (method: string, path: string, status: string): ReadonlyArray<string> => { + const response = operation(method, path).responses[status] as Record<string, any> + const responseSchema = response.content["application/json"].schema as Record<string, any> + return schemaBranches(responseSchema).map((branch) => { + const tag = resolveSchema(branch).properties.error.properties._tag + expect(tag.type).toBe("string") + expect(tag.enum).toHaveLength(1) + return tag.enum[0] as string + }) +} const OpenApiOperationMetadata = Schema.Struct({ operationId: Schema.String, @@ -237,11 +256,11 @@ describe("MapleApiV2 OpenAPI", () => { expect(names).toEqual( expect.arrayContaining([ "InvalidRequestError", - "AuthenticationError", - "PermissionError", - "NotFoundError", + "InvalidCredentialsError", + "InsufficientScopeError", + "ApiKeyNotFoundError", "RateLimitError", - "ServiceUnavailableError", + "ApiKeyPersistenceError", ]), ) // No internal / v2-prefixed / namespaced identifiers leaked into the public spec. @@ -394,13 +413,13 @@ describe("MapleApiV2 OpenAPI", () => { "500", "503", ]) - // Only `channels` can 404 (not connected) or 502 (Slack rejected us). + // Only `channels` can 409 (not connected) or 502 (Slack rejected us). expect(declared("get", "/v2/integrations/slack/channels")).toEqual([ "200", "400", "401", "403", - "404", + "409", "429", "500", "502", @@ -408,30 +427,74 @@ describe("MapleApiV2 OpenAPI", () => { ]) }) - it("marks the admin-gated Slack operations' 403 as handler-declared, not just middleware", () => { - // Every operation carries the middleware's 403 (insufficient scope), so the - // status-code set alone can't tell an admin-gated endpoint from an open one. - // An endpoint that *also* declares `V2PermissionError` renders its 403 as an - // `anyOf` of two PermissionError refs — that duplication is the tell. - const permissionSchema = (method: string, path: string) => - operation(method, path).responses["403"].content["application/json"].schema - const isHandlerDeclared = (method: string, path: string) => - Array.isArray(permissionSchema(method, path).anyOf) - + it("documents the distinct scope and org-admin tags on Slack operations", () => { // install / uninstall / channels all call `requireAdmin`: `channels` // enumerates the workspace's channels, private ones included, so it is not // something any org member may read. - expect(isHandlerDeclared("post", "/v2/integrations/slack/install")).toBe(true) - expect(isHandlerDeclared("delete", "/v2/integrations/slack")).toBe(true) - expect(isHandlerDeclared("get", "/v2/integrations/slack/channels")).toBe(true) + const adminTags = [ + "@maple/http/v2/InsufficientPermissionsError", + "@maple/http/v2/InsufficientScopeError", + ] + expect([...responseErrorTags("post", "/v2/integrations/slack/install", "403")].sort()).toEqual( + adminTags, + ) + expect([...responseErrorTags("delete", "/v2/integrations/slack", "403")].sort()).toEqual(adminTags) + expect([...responseErrorTags("get", "/v2/integrations/slack/channels", "403")].sort()).toEqual( + adminTags, + ) expect(operation("get", "/v2/integrations/slack/channels").description).toContain("org-admin") // `status` stays UNGATED — the dashboard's Slack card renders install state // for every member, so its 403 comes from the scope middleware alone. - expect(isHandlerDeclared("get", "/v2/integrations/slack")).toBe(false) - expect(permissionSchema("get", "/v2/integrations/slack").$ref).toBe( - "#/components/schemas/PermissionError", - ) + expect(responseErrorTags("get", "/v2/integrations/slack", "403")).toEqual([ + "@maple/http/v2/InsufficientScopeError", + ]) + }) + + it("gives every error response an exhaustive literal-tag union", () => { + for (const [path, item] of Object.entries(spec.paths ?? {})) { + for (const [method, candidate] of Object.entries(item ?? {})) { + if (!["get", "post", "put", "patch", "delete"].includes(method)) continue + const op = candidate as Record<string, any> + for (const status of Object.keys(op.responses)) { + if (Number(status) < 400) continue + const tags = responseErrorTags(method, path, status) + expect(tags.length, `${method.toUpperCase()} ${path} ${status} has tags`).toBeGreaterThan( + 0, + ) + expect( + new Set(tags).size, + `${method.toUpperCase()} ${path} ${status} tags are unique`, + ).toBe(tags.length) + for (const tag of tags) { + expect(tag, `${method.toUpperCase()} ${path} ${status} tag`).toMatch(/^@maple\//) + } + } + } + } + }) + + it("publishes operation-specific tags for previously collapsed failures", () => { + expect(responseErrorTags("get", "/v2/api_keys/{id}", "404")).toEqual([ + "@maple/http/errors/ApiKeyNotFoundError", + ]) + expect(responseErrorTags("get", "/v2/integrations/planetscale/organizations", "401")).toEqual([ + "@maple/http/errors/IntegrationsRevokedError", + "@maple/http/v2/InvalidCredentialsError", + ]) + expect(responseErrorTags("get", "/v2/session_replays/{id}/events", "413")).toEqual([ + "@maple/http/v2/SessionReplayRangeTooLargeError", + ]) + expect(responseErrorTags("post", "/v2/alerts/rules/test", "429")).toEqual([ + "@maple/http/errors/WarehouseQuotaExceededError", + "@maple/http/v2/RateLimitError", + ]) + expect(responseErrorTags("post", "/v2/traces/timeseries", "500")).toEqual([ + "@maple/http/errors/WarehouseMalformedQueryError", + "@maple/http/errors/QueryEngineResultMismatchError", + "@maple/http/v2/ResponseSchemaError", + "@maple/http/v2/UnexpectedError", + ]) }) it("decodes slack-bot destination create/update params and rejects a blank channel_id", () => { @@ -539,8 +602,10 @@ describe("MapleApiV2 OpenAPI", () => { }) it("documents error responses with a stable code example", () => { - const notFound = schemas["NotFoundError"] - expect(notFound.properties.error.properties.code.examples).toEqual(["api_key_not_found"]) + const notFound = schemas["ApiKeyNotFoundError"] + expect(notFound.properties.error.properties._tag.enum).toEqual([ + "@maple/http/errors/ApiKeyNotFoundError", + ]) expect(notFound.properties.error.properties.message.description).toEqual(expect.any(String)) }) }) diff --git a/packages/domain/src/http/v2/organization.ts b/packages/domain/src/http/v2/organization.ts index c0662de57..546062017 100644 --- a/packages/domain/src/http/v2/organization.ts +++ b/packages/domain/src/http/v2/organization.ts @@ -1,9 +1,10 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { OrgId } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { OrganizationProviderError } from "../organizations" +import { AuthorizationV2 } from "./auth" import { Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2ServiceUnavailableError } from "./errors" +import { publicError } from "./public-error" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = <A>(example: object): A => example as A @@ -55,7 +56,7 @@ export class V2OrganizationApiGroup extends HttpApiGroup.make("organization") .add( HttpApiEndpoint.get("retrieve", "/", { success: V2Organization, - error: [V2InvalidRequestError, V2ServiceUnavailableError], + error: publicError(OrganizationProviderError), }).annotateMerge( OpenApi.annotations({ identifier: "getOrganization", @@ -67,7 +68,6 @@ export class V2OrganizationApiGroup extends HttpApiGroup.make("organization") ) .prefix("/v2/organization") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Organization", diff --git a/packages/domain/src/http/v2/public-error.test.ts b/packages/domain/src/http/v2/public-error.test.ts new file mode 100644 index 000000000..916350f0f --- /dev/null +++ b/packages/domain/src/http/v2/public-error.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "@effect/vitest" +import { Schema } from "effect" +import { ApiKeyId } from "../../primitives" +import { publicHttpErrorPolicy } from "../error-policy" +import { ApiKeyNotFoundError, ApiKeyPersistenceError } from "../api-keys" +import { IngestKeyEncryptionError } from "../ingest-keys" +import { + QueryEngineExecutionError, + QueryEngineResultMismatchError, + QueryEngineTimeoutError, + QueryEngineValidationError, +} from "../query-engine" +import { + WarehouseMalformedQueryError, + WarehouseQuotaExceededError, + WarehouseSchemaDriftError, + WarehouseUpstreamError, + WarehouseValidationError, +} from "../warehouse-errors" +import { publicError } from "./public-error" + +const keyId = Schema.decodeUnknownSync(ApiKeyId)("0f8fad5b-d9cb-469f-a165-70867728950e") + +describe("HttpTaggedError public body", () => { + it("encodes the original error directly with its exact tag", () => { + const error = new ApiKeyNotFoundError({ keyId, message: `missing ${keyId}` }) + const encoded = Schema.encodeSync(publicError(ApiKeyNotFoundError))(error) + + expect(encoded.error._tag).toBe(error._tag) + expect(encoded.error.type).toBe("not_found_error") + expect(encoded.error.code).toBe("api_key_not_found") + expect(publicHttpErrorPolicy(error).status).toBe(404) + }) + + it("redacts private internal messages", () => { + const error = new ApiKeyPersistenceError({ + message: "postgres://user:secret@internal SELECT * FROM api_keys", + }) + + expect(JSON.stringify(error.error)).not.toContain("postgres://") + expect(error.error._tag).toBe("@maple/http/errors/ApiKeyPersistenceError") + expect(publicHttpErrorPolicy(error).status).toBe(503) + }) + + it("keeps Maple defects distinct from dependency outages", () => { + const error = new IngestKeyEncryptionError({ message: "KMS key material leaked here" }) + + expect(error.error._tag).toBe(error._tag) + expect(error.error.code).toBe("ingest_key_encryption_failed") + expect(error.error.message).not.toContain(error.message) + expect(publicHttpErrorPolicy(error).status).toBe(500) + }) + + it("preserves warehouse validation, quota, outage, and Maple-fault statuses", () => { + const validation = new WarehouseValidationError({ + pipeName: "sqlQuery", + message: "start_time is after end_time", + }) + const quota = new WarehouseQuotaExceededError({ + pipeName: "listTraces", + message: "TIMEOUT_EXCEEDED", + setting: "max_execution_time", + }) + const upstream = new WarehouseUpstreamError({ + pipeName: "listTraces", + message: "521 error", + upstreamStatus: 521, + }) + const malformed = new WarehouseMalformedQueryError({ + pipeName: "traces_timeseries", + message: "NO_COMMON_TYPE", + }) + + expect(publicHttpErrorPolicy(validation).status).toBe(400) + expect(publicHttpErrorPolicy(quota).status).toBe(429) + expect(publicHttpErrorPolicy(upstream).status).toBe(503) + expect(publicHttpErrorPolicy(malformed).status).toBe(500) + }) + + it("owns warehouse remediation copy without exposing diagnostics", () => { + const error = new WarehouseSchemaDriftError({ + pipeName: "service_overview", + message: "Unknown column SecretCustomerColumn", + }) + + expect(error.error.message).toContain("schema apply") + expect(error.error.message).not.toContain("SecretCustomerColumn") + expect(publicHttpErrorPolicy(error).status).toBe(502) + }) + + it("serializes query-engine failures directly", () => { + const validation = new QueryEngineValidationError({ message: "invalid aggregation", details: [] }) + const execution = new QueryEngineExecutionError({ message: "execution failed" }) + const timeout = new QueryEngineTimeoutError({ message: "timed out" }) + const mismatch = new QueryEngineResultMismatchError({ + message: "expected timeseries, got scalar", + expectedKind: "timeseries", + actualKind: "scalar", + }) + + expect(publicHttpErrorPolicy(validation).status).toBe(400) + expect(publicHttpErrorPolicy(execution).status).toBe(502) + expect(publicHttpErrorPolicy(timeout).status).toBe(504) + expect(publicHttpErrorPolicy(mismatch).status).toBe(500) + }) +}) diff --git a/packages/domain/src/http/v2/public-error.ts b/packages/domain/src/http/v2/public-error.ts new file mode 100644 index 000000000..daeca0a35 --- /dev/null +++ b/packages/domain/src/http/v2/public-error.ts @@ -0,0 +1,55 @@ +import { + publicHttpErrorDefinitionFor, + type PublicHttpErrorStatusOf, + type SelfDescribingHttpErrorClass, + type SelfDescribingHttpError, +} from "../error-policy" +import { Schema } from "effect" +import { makeV2ErrorSchema, type V2ErrorTypeForStatus, type V2PublicError } from "./errors" + +export type V2ErrorEnvelopeFor<Error extends SelfDescribingHttpError> = Error extends SelfDescribingHttpError + ? V2PublicError<Error["_tag"], V2ErrorTypeForStatus<PublicHttpErrorStatusOf<Error>>> + : never + +export type V2PublicErrorSchema<Error extends SelfDescribingHttpError> = Schema.Codec< + V2ErrorEnvelopeFor<Error> +> +const publicErrorSchemaCache = new WeakMap<Function, Schema.Top>() + +/** + * Project a self-describing domain error into its exact public wire schema. + * The literal domain `_tag`, HTTP status, and envelope category all come from + * the same `HttpTaggedError` definition that exposes the runtime wire body. + */ +export const publicError = <Error extends SelfDescribingHttpError>( + errorClass: SelfDescribingHttpErrorClass<Error> & Schema.Schema<Error>, +): V2PublicErrorSchema<Error> => { + const cached = publicErrorSchemaCache.get(errorClass) + if (cached !== undefined) return cached as V2PublicErrorSchema<Error> + const schema = makePublicErrorSchema<Error>(errorClass) + publicErrorSchemaCache.set(errorClass, schema) + return schema as unknown as V2PublicErrorSchema<Error> +} + +/** Preserve every member of a class tuple as a distinct public error schema. */ +export const publicErrors = <const Errors extends ReadonlyArray<SelfDescribingHttpError>>( + ...errorClasses: { + readonly [Index in keyof Errors]: SelfDescribingHttpErrorClass<Errors[Index]> & + Schema.Schema<Errors[Index]> + } +): { readonly [Index in keyof Errors]: V2PublicErrorSchema<Errors[Index]> } => + errorClasses.map((errorClass) => publicError(errorClass)) as { + readonly [Index in keyof Errors]: V2PublicErrorSchema<Errors[Index]> + } + +const makePublicErrorSchema = <Error extends SelfDescribingHttpError>(errorClass: Function) => { + const { tag, policy } = publicHttpErrorDefinitionFor<Error>(errorClass) + return makeV2ErrorSchema({ + tag, + status: policy.status, + identifier: errorClass.name, + title: typeof policy.title === "string" ? policy.title : errorClass.name, + description: `The ${tag} failure. HTTP ${policy.status}.`, + ...(typeof policy.code === "string" ? { codeExample: policy.code } : {}), + }) +} diff --git a/packages/domain/src/http/v2/query-errors.ts b/packages/domain/src/http/v2/query-errors.ts new file mode 100644 index 000000000..05c32c985 --- /dev/null +++ b/packages/domain/src/http/v2/query-errors.ts @@ -0,0 +1,41 @@ +import { + QueryEngineExecutionError, + QueryEngineResultMismatchError, + QueryEngineTimeoutError, + QueryEngineValidationError, +} from "../query-engine" +import { + WarehouseAuthError, + WarehouseClientError, + WarehouseConfigError, + WarehouseMalformedQueryError, + WarehouseQueryError, + WarehouseQuotaExceededError, + WarehouseSchemaDriftError, + WarehouseUpstreamError, + WarehouseValidationError, +} from "../warehouse-errors" +import { publicErrors } from "./public-error" + +/** Exact public schemas for the complete WarehouseError union. */ +export const V2WarehouseErrors = publicErrors( + WarehouseQueryError, + WarehouseUpstreamError, + WarehouseAuthError, + WarehouseConfigError, + WarehouseClientError, + WarehouseSchemaDriftError, + WarehouseMalformedQueryError, + WarehouseQuotaExceededError, + WarehouseValidationError, +) + +/** Exact public schemas for failures added by the higher-level query engine. */ +export const V2QueryEngineErrors = publicErrors( + QueryEngineValidationError, + QueryEngineExecutionError, + QueryEngineTimeoutError, + QueryEngineResultMismatchError, +) + +export const V2QueryErrors = [...V2WarehouseErrors, ...V2QueryEngineErrors] as const diff --git a/packages/domain/src/http/v2/recommendations.ts b/packages/domain/src/http/v2/recommendations.ts index dde112fa2..4b50d1b7a 100644 --- a/packages/domain/src/http/v2/recommendations.ts +++ b/packages/domain/src/http/v2/recommendations.ts @@ -1,11 +1,17 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { RecommendationIssueId } from "../../primitives" -import { RecommendationIssueKind, RecommendationIssueStatus } from "../recommendation-issues" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { + RecommendationIssueKind, + RecommendationIssueNotFoundError, + RecommendationIssuePersistenceError, + RecommendationIssueStatus, +} from "../recommendation-issues" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2NotFoundError, V2ServiceUnavailableError } from "./errors" +import { V2ParameterInvalid } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" +import { publicErrors } from "./public-error" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = <A>(example: object): A => example as A @@ -80,7 +86,10 @@ export const V2Recommendation = Schema.Struct({ }) export type V2Recommendation = Schema.Schema.Type<typeof V2Recommendation> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +const [recommendationNotFound, recommendationPersistence] = publicErrors( + RecommendationIssueNotFoundError, + RecommendationIssuePersistenceError, +) const RecommendationList = ListOf(V2Recommendation).annotate({ identifier: "RecommendationList", @@ -95,7 +104,7 @@ export class V2InstrumentationRecommendationsApiGroup extends HttpApiGroup.make( HttpApiEndpoint.get("list", "/", { query: ListQuery, success: RecommendationList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, recommendationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listInstrumentationRecommendations", @@ -109,7 +118,7 @@ export class V2InstrumentationRecommendationsApiGroup extends HttpApiGroup.make( HttpApiEndpoint.post("dismiss", "/:id/dismiss", { params: { id: RecommendationPublicId }, success: V2Recommendation, - error: [...commonErrors, V2NotFoundError], + error: [recommendationNotFound, recommendationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "dismissInstrumentationRecommendation", @@ -123,7 +132,7 @@ export class V2InstrumentationRecommendationsApiGroup extends HttpApiGroup.make( HttpApiEndpoint.post("reopen", "/:id/reopen", { params: { id: RecommendationPublicId }, success: V2Recommendation, - error: [...commonErrors, V2NotFoundError], + error: [recommendationNotFound, recommendationPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "reopenInstrumentationRecommendation", @@ -135,7 +144,6 @@ export class V2InstrumentationRecommendationsApiGroup extends HttpApiGroup.make( ) .prefix("/v2/instrumentation/recommendations") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Instrumentation Recommendations", diff --git a/packages/domain/src/http/v2/scrape-targets.ts b/packages/domain/src/http/v2/scrape-targets.ts index a324af663..ed76f5b4f 100644 --- a/packages/domain/src/http/v2/scrape-targets.ts +++ b/packages/domain/src/http/v2/scrape-targets.ts @@ -1,9 +1,17 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ScrapeAuthType, ScrapeIntervalSeconds, ScrapeTargetId, ScrapeTargetType } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { + ScrapeTargetAuthError, + ScrapeTargetEncryptionError, + ScrapeTargetNotFoundError, + ScrapeTargetPersistenceError, + ScrapeTargetValidationError, +} from "../scrape-targets" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2NotFoundError, V2ServiceUnavailableError, V2UpstreamError } from "./errors" +import { V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ @@ -332,7 +340,13 @@ export const V2ScrapeTargetChecksQuery = Schema.Struct({ }) export type V2ScrapeTargetChecksQuery = Schema.Schema.Type<typeof V2ScrapeTargetChecksQuery> -const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +const [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption, scrapeAuth] = publicErrors( + ScrapeTargetNotFoundError, + ScrapeTargetValidationError, + ScrapeTargetPersistenceError, + ScrapeTargetEncryptionError, + ScrapeTargetAuthError, +) const ScrapeTargetList = ListOf(V2ScrapeTarget).annotate({ identifier: "ScrapeTargetList", @@ -351,7 +365,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.get("list", "/", { query: ListQuery, success: ScrapeTargetList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, scrapePersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listScrapeTargets", @@ -365,7 +379,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.post("create", "/", { payload: V2ScrapeTargetCreateParams, success: V2ScrapeTarget, - error: [...commonErrors], + error: [scrapeValidation, scrapePersistence, scrapeEncryption], }).annotateMerge( OpenApi.annotations({ identifier: "createScrapeTarget", @@ -379,7 +393,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: ScrapeTargetPublicId }, success: V2ScrapeTarget, - error: [...commonErrors, V2NotFoundError], + error: [scrapeNotFound, scrapePersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getScrapeTarget", @@ -394,7 +408,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") params: { id: ScrapeTargetPublicId }, payload: V2ScrapeTargetUpdateParams, success: V2ScrapeTarget, - error: [...commonErrors, V2NotFoundError], + error: [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption], }).annotateMerge( OpenApi.annotations({ identifier: "updateScrapeTarget", @@ -408,7 +422,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.delete("delete", "/:id", { params: { id: ScrapeTargetPublicId }, success: V2ScrapeTargetDeleteResponse, - error: [...commonErrors, V2NotFoundError], + error: [scrapeNotFound, scrapePersistence], }).annotateMerge( OpenApi.annotations({ identifier: "deleteScrapeTarget", @@ -422,7 +436,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.post("probe", "/:id/probe", { params: { id: ScrapeTargetPublicId }, success: V2ScrapeTargetProbeResult, - error: [...commonErrors, V2NotFoundError, V2UpstreamError], + error: [scrapeNotFound, scrapePersistence, scrapeEncryption, scrapeAuth], }).annotateMerge( OpenApi.annotations({ identifier: "probeScrapeTarget", @@ -437,7 +451,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") params: { id: ScrapeTargetPublicId }, query: V2ScrapeTargetChecksQuery, success: ScrapeTargetCheckList, - error: [...commonErrors, V2NotFoundError], + error: [V2ParameterInvalid.schema, scrapeNotFound, scrapePersistence], }).annotateMerge( OpenApi.annotations({ identifier: "listScrapeTargetChecks", @@ -449,7 +463,6 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") ) .prefix("/v2/scrape_targets") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Scrape Targets", diff --git a/packages/domain/src/http/v2/session-replays.ts b/packages/domain/src/http/v2/session-replays.ts index e81e311b1..2fd4a6774 100644 --- a/packages/domain/src/http/v2/session-replays.ts +++ b/packages/domain/src/http/v2/session-replays.ts @@ -1,17 +1,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { SessionId, TraceId } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { - V2InvalidRequestError, - V2NotFoundError, - V2PayloadTooLargeError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "./errors" +import { defineV2Error, V2ParameterInvalid } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" +import { V2WarehouseErrors } from "./query-errors" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = <A>(example: object): A => example as A @@ -420,13 +414,30 @@ export const V2SessionReplaysForTraceParams = Schema.Struct({ }) export type V2SessionReplaysForTraceParams = Schema.Schema.Type<typeof V2SessionReplaysForTraceParams> -// Full warehouse outcome range — see the matching comment in ./telemetry.ts. -const commonErrors = [ - V2InvalidRequestError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -] as const +export const V2SessionReplayNotFound = defineV2Error({ + tag: "@maple/http/v2/SessionReplayNotFoundError", + status: 404, + code: "session_replay_not_found", + title: "Session replay not found", + message: "No such session replay.", + retryable: false, + recovery: "none", + identifier: "SessionReplayNotFoundError", +}) + +export const V2SessionReplayRangeTooLarge = defineV2Error({ + tag: "@maple/http/v2/SessionReplayRangeTooLargeError", + status: 413, + code: "range_too_large", + title: "Session replay range too large", + message: + "That part of the recording is too large to load in one request. Request a narrower chunk range.", + retryable: false, + recovery: "fix_request", + identifier: "SessionReplayRangeTooLargeError", +}) + +const commonErrors = [V2ParameterInvalid.schema, ...V2WarehouseErrors] as const const SessionReplayList = ListOf(V2SessionReplayListItem).annotate({ identifier: "SessionReplayList", @@ -472,7 +483,7 @@ export class V2SessionReplaysApiGroup extends HttpApiGroup.make("sessionReplays" params: { id: SessionReplayPublicId }, query: V2SessionReplayWindowQuery, success: V2SessionReplay, - error: [...commonErrors, V2NotFoundError], + error: [...commonErrors, V2SessionReplayNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getSessionReplay", @@ -487,7 +498,7 @@ export class V2SessionReplaysApiGroup extends HttpApiGroup.make("sessionReplays" params: { id: SessionReplayPublicId }, query: V2SessionReplayWindowQuery, success: V2SessionReplayManifest, - error: [...commonErrors, V2NotFoundError], + error: [...commonErrors, V2SessionReplayNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getSessionReplayManifest", @@ -502,7 +513,7 @@ export class V2SessionReplaysApiGroup extends HttpApiGroup.make("sessionReplays" params: { id: SessionReplayPublicId }, query: V2SessionReplayEventsQuery, success: SessionReplayChunkList, - error: [...commonErrors, V2NotFoundError, V2PayloadTooLargeError], + error: [...commonErrors, V2SessionReplayNotFound.schema, V2SessionReplayRangeTooLarge.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getSessionReplayEvents", @@ -517,7 +528,7 @@ export class V2SessionReplaysApiGroup extends HttpApiGroup.make("sessionReplays" params: { id: SessionReplayPublicId }, query: V2SessionReplayCollectionQuery, success: SessionTranscriptList, - error: [...commonErrors, V2NotFoundError], + error: [...commonErrors, V2SessionReplayNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getSessionReplayTranscript", @@ -543,7 +554,6 @@ export class V2SessionReplaysApiGroup extends HttpApiGroup.make("sessionReplays" ) .prefix("/v2/session_replays") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Session Replays", diff --git a/packages/domain/src/http/v2/setup-audit.ts b/packages/domain/src/http/v2/setup-audit.ts index 54d7b4200..642c192ca 100644 --- a/packages/domain/src/http/v2/setup-audit.ts +++ b/packages/domain/src/http/v2/setup-audit.ts @@ -1,8 +1,9 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { SetupAuditUnavailableError } from "../../setup-audit" +import { AuthorizationV2 } from "./auth" import { Timestamp } from "./envelopes" -import { V2InvalidRequestError, V2ServiceUnavailableError } from "./errors" +import { publicError } from "./public-error" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = <A>(example: object): A => example as A @@ -182,7 +183,7 @@ export class V2InstrumentationAuditApiGroup extends HttpApiGroup.make("instrumen .add( HttpApiEndpoint.get("retrieve", "/", { success: V2SetupAudit, - error: [V2InvalidRequestError, V2ServiceUnavailableError], + error: publicError(SetupAuditUnavailableError), }).annotateMerge( OpenApi.annotations({ identifier: "getSetupAudit", @@ -195,7 +196,6 @@ export class V2InstrumentationAuditApiGroup extends HttpApiGroup.make("instrumen ) .prefix("/v2/instrumentation/audit") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Setup Audit", diff --git a/packages/domain/src/http/v2/telemetry.ts b/packages/domain/src/http/v2/telemetry.ts index bb8aa48a5..bcdacc223 100644 --- a/packages/domain/src/http/v2/telemetry.ts +++ b/packages/domain/src/http/v2/telemetry.ts @@ -1,28 +1,171 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { MetricName, ServiceName, SpanId, TraceId } from "../../primitives" -import { AuthorizationV2, V2SchemaErrors } from "./auth" +import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" -import { - V2InvalidRequestError, - V2NotFoundError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, -} from "./errors" +import { defineV2Error, V2CursorInvalid, V2ParameterInvalid, V2TimeRangeInvalid } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" +import { V2QueryErrors, V2WarehouseErrors } from "./query-errors" const wireExample = <A>(example: object): A => example as A -// Warehouse-backed endpoints surface the full outcome range: 400 for a bad -// request, 429 for a quota breach, 502 for an upstream/query fault, 503 for a -// genuine outage. These used to be collapsed into one 503 (see -// telemetry.http.ts's mapWarehouseError), which told a user with a bad time -// range that the service was down. -const commonErrors = [ - V2InvalidRequestError, - V2RateLimitError, - V2ServiceUnavailableError, - V2UpstreamError, +export const V2TelemetryRangeTooLarge = defineV2Error({ + tag: "@maple/http/v2/TelemetryRangeTooLargeError", + status: 400, + code: "time_range_too_large", + title: "Time range too large", + message: "The requested time range exceeds this operation's limit.", + retryable: false, + recovery: "fix_request", + identifier: "TelemetryRangeTooLargeError", +}) + +export const V2TelemetryBucketCountTooLarge = defineV2Error({ + tag: "@maple/http/v2/TelemetryBucketCountTooLargeError", + status: 400, + code: "bucket_count_too_large", + title: "Too many time buckets", + message: "bucket_seconds produces too many buckets.", + retryable: false, + recovery: "fix_request", + identifier: "TelemetryBucketCountTooLargeError", +}) + +export const V2TelemetryBreakdownFilterRequired = defineV2Error({ + tag: "@maple/http/v2/TelemetryBreakdownFilterRequiredError", + status: 400, + code: "breakdown_filter_required", + title: "Breakdown filter required", + message: "This breakdown range requires at least one narrowing filter.", + retryable: false, + recovery: "fix_request", + identifier: "TelemetryBreakdownFilterRequiredError", +}) + +export const V2TraceQueryInvalid = defineV2Error({ + tag: "@maple/http/v2/TraceQueryInvalidError", + status: 400, + code: "trace_query_invalid", + title: "Invalid trace query", + message: "The trace aggregation request is invalid.", + retryable: false, + recovery: "fix_request", + identifier: "TraceQueryInvalidError", +}) + +export const V2LogQueryInvalid = defineV2Error({ + tag: "@maple/http/v2/LogQueryInvalidError", + status: 400, + code: "log_query_invalid", + title: "Invalid log query", + message: "The log aggregation request is invalid.", + retryable: false, + recovery: "fix_request", + identifier: "LogQueryInvalidError", +}) + +export const V2MetricQueryInvalid = defineV2Error({ + tag: "@maple/http/v2/MetricQueryInvalidError", + status: 400, + code: "metric_query_invalid", + title: "Invalid metric query", + message: "The metric aggregation request is invalid.", + retryable: false, + recovery: "fix_request", + identifier: "MetricQueryInvalidError", +}) + +export const V2TraceNotFound = defineV2Error({ + tag: "@maple/http/v2/TraceNotFoundError", + status: 404, + code: "trace_not_found", + title: "Trace not found", + message: "No such trace.", + retryable: false, + recovery: "none", + identifier: "TraceNotFoundError", +}) + +export const V2SpanNotFound = defineV2Error({ + tag: "@maple/http/v2/SpanNotFoundError", + status: 404, + code: "span_not_found", + title: "Span not found", + message: "No such span.", + retryable: false, + recovery: "none", + identifier: "SpanNotFoundError", +}) + +export const V2LogIdInvalid = defineV2Error({ + tag: "@maple/http/v2/LogIdInvalidError", + status: 400, + code: "log_id_invalid", + title: "Invalid log ID", + message: "Malformed log ID.", + retryable: false, + recovery: "fix_request", + identifier: "LogIdInvalidError", +}) + +export const V2LogNotFound = defineV2Error({ + tag: "@maple/http/v2/LogNotFoundError", + status: 404, + code: "log_not_found", + title: "Log not found", + message: "No such log.", + retryable: false, + recovery: "none", + identifier: "LogNotFoundError", +}) + +export const V2ServiceNotFound = defineV2Error({ + tag: "@maple/http/v2/ServiceNotFoundError", + status: 404, + code: "service_not_found", + title: "Service not found", + message: "No such service.", + retryable: false, + recovery: "none", + identifier: "ServiceNotFoundError", +}) + +const windowErrors = [V2TimeRangeInvalid.schema, V2TelemetryRangeTooLarge.schema] as const +const warehouseWindowErrors = [...windowErrors, ...V2WarehouseErrors] as const +const traceTimeseriesErrors = [ + ...windowErrors, + V2TelemetryBucketCountTooLarge.schema, + V2TraceQueryInvalid.schema, + ...V2QueryErrors, +] as const +const traceBreakdownErrors = [ + ...windowErrors, + V2TelemetryBreakdownFilterRequired.schema, + V2TraceQueryInvalid.schema, + ...V2QueryErrors, +] as const +const logTimeseriesErrors = [ + ...windowErrors, + V2TelemetryBucketCountTooLarge.schema, + V2LogQueryInvalid.schema, + ...V2QueryErrors, +] as const +const logBreakdownErrors = [ + ...windowErrors, + V2TelemetryBreakdownFilterRequired.schema, + V2LogQueryInvalid.schema, + ...V2QueryErrors, +] as const +const metricTimeseriesErrors = [ + ...windowErrors, + V2TelemetryBucketCountTooLarge.schema, + V2MetricQueryInvalid.schema, + ...V2QueryErrors, +] as const +const metricBreakdownErrors = [ + ...windowErrors, + V2TelemetryBreakdownFilterRequired.schema, + V2MetricQueryInvalid.schema, + ...V2QueryErrors, ] as const const PositiveInteger = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)) const PositiveFinite = Schema.Number.check(Schema.isFinite(), Schema.isGreaterThan(0)) @@ -559,7 +702,7 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") HttpApiEndpoint.post("search", "/search", { payload: V2TraceSearchParams, success: TraceList, - error: [...commonErrors], + error: [V2CursorInvalid.schema, ...warehouseWindowErrors], }).annotateMerge( OpenApi.annotations({ identifier: "searchTraces", @@ -572,7 +715,7 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") HttpApiEndpoint.post("timeseries", "/timeseries", { payload: V2TraceTimeseriesParams, success: V2TraceTimeseriesResult, - error: [...commonErrors], + error: traceTimeseriesErrors, }).annotateMerge( OpenApi.annotations({ identifier: "queryTraceTimeseries", @@ -585,7 +728,7 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") HttpApiEndpoint.post("breakdown", "/breakdown", { payload: V2TraceBreakdownParams, success: V2TraceBreakdownResult, - error: [...commonErrors], + error: traceBreakdownErrors, }).annotateMerge( OpenApi.annotations({ identifier: "queryTraceBreakdown", @@ -598,7 +741,7 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") HttpApiEndpoint.get("retrieve", "/:trace_id", { params: { trace_id: TraceId }, success: V2Trace, - error: [...commonErrors, V2NotFoundError], + error: [...V2WarehouseErrors, V2TraceNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getTrace", @@ -612,7 +755,7 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") HttpApiEndpoint.get("retrieveSpan", "/:trace_id/spans/:span_id", { params: { trace_id: TraceId, span_id: SpanId }, success: V2Span, - error: [...commonErrors, V2NotFoundError], + error: [...V2WarehouseErrors, V2SpanNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getSpan", @@ -623,7 +766,6 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") ) .prefix("/v2/traces") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Traces", @@ -676,7 +818,7 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") HttpApiEndpoint.post("search", "/search", { payload: V2LogSearchParams, success: LogList, - error: [...commonErrors], + error: [V2CursorInvalid.schema, ...warehouseWindowErrors], }).annotateMerge( OpenApi.annotations({ identifier: "searchLogs", @@ -689,7 +831,7 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") HttpApiEndpoint.post("timeseries", "/timeseries", { payload: V2LogTimeseriesParams, success: V2LogTimeseriesResult, - error: [...commonErrors], + error: logTimeseriesErrors, }).annotateMerge( OpenApi.annotations({ identifier: "queryLogTimeseries", @@ -702,7 +844,7 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") HttpApiEndpoint.post("breakdown", "/breakdown", { payload: V2LogBreakdownParams, success: V2LogBreakdownResult, - error: [...commonErrors], + error: logBreakdownErrors, }).annotateMerge( OpenApi.annotations({ identifier: "queryLogBreakdown", @@ -715,7 +857,7 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: LogPublicId }, success: V2Log, - error: [...commonErrors, V2NotFoundError], + error: [V2LogIdInvalid.schema, ...V2WarehouseErrors, V2LogNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getLog", @@ -726,7 +868,6 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") ) .prefix("/v2/logs") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Logs", @@ -769,7 +910,7 @@ export class V2MetricsApiGroup extends HttpApiGroup.make("metrics") HttpApiEndpoint.get("list", "/", { query: V2MetricListQuery, success: MetricList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, ...warehouseWindowErrors], }).annotateMerge( OpenApi.annotations({ identifier: "listMetrics", @@ -783,7 +924,7 @@ export class V2MetricsApiGroup extends HttpApiGroup.make("metrics") HttpApiEndpoint.post("timeseries", "/timeseries", { payload: V2MetricsTimeseriesParams, success: V2MetricTimeseriesResult, - error: [...commonErrors], + error: metricTimeseriesErrors, }).annotateMerge( OpenApi.annotations({ identifier: "queryMetricsTimeseries", @@ -796,7 +937,7 @@ export class V2MetricsApiGroup extends HttpApiGroup.make("metrics") HttpApiEndpoint.post("breakdown", "/breakdown", { payload: V2MetricsBreakdownParams, success: V2MetricBreakdownResult, - error: [...commonErrors], + error: metricBreakdownErrors, }).annotateMerge( OpenApi.annotations({ identifier: "queryMetricBreakdown", @@ -807,7 +948,6 @@ export class V2MetricsApiGroup extends HttpApiGroup.make("metrics") ) .prefix("/v2/metrics") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Metrics", @@ -880,7 +1020,7 @@ export class V2ServicesApiGroup extends HttpApiGroup.make("services") HttpApiEndpoint.get("list", "/", { query: V2ServiceListQuery, success: ServiceList, - error: [...commonErrors], + error: [V2ParameterInvalid.schema, ...warehouseWindowErrors], }).annotateMerge( OpenApi.annotations({ identifier: "listServices", @@ -895,7 +1035,7 @@ export class V2ServicesApiGroup extends HttpApiGroup.make("services") params: { name: ServiceName }, query: V2TelemetryWindowQuery, success: V2Service, - error: [...commonErrors, V2NotFoundError], + error: [...warehouseWindowErrors, V2ServiceNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getService", @@ -907,7 +1047,6 @@ export class V2ServicesApiGroup extends HttpApiGroup.make("services") ) .prefix("/v2/services") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Services", @@ -920,7 +1059,7 @@ export class V2ServiceMapApiGroup extends HttpApiGroup.make("serviceMap") HttpApiEndpoint.get("retrieve", "/", { query: V2ServiceMapQuery, success: V2ServiceMap, - error: [...commonErrors], + error: warehouseWindowErrors, }).annotateMerge( OpenApi.annotations({ identifier: "getServiceMap", @@ -932,7 +1071,6 @@ export class V2ServiceMapApiGroup extends HttpApiGroup.make("serviceMap") ) .prefix("/v2/service_map") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) .annotateMerge( OpenApi.annotations({ title: "Service Map", diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 8d9fb053f..5608e0e3d 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -477,16 +477,16 @@ describe("v2 error envelope", () => { expect("_tag" in wire).toBe(false) }) - it("decodes the pre-metadata envelope during rolling upgrades", () => { - const decoded = Schema.decodeUnknownSync(V2NotFoundError)({ - error: { - type: "not_found_error", - code: "resource_missing", - message: "gone", - }, - }) - expect(decoded.error._tag).toBeUndefined() - expect(decoded.error.retryable).toBeUndefined() + it("requires a semantic tag on every public error", () => { + expect(() => + Schema.decodeUnknownSync(V2NotFoundError)({ + error: { + type: "not_found_error", + code: "resource_missing", + message: "gone", + }, + }), + ).toThrow() }) it("omits param when not provided", () => { diff --git a/packages/domain/src/http/warehouse-error-meta.test.ts b/packages/domain/src/http/warehouse-error-meta.test.ts index 0db3446a7..30f491785 100644 --- a/packages/domain/src/http/warehouse-error-meta.test.ts +++ b/packages/domain/src/http/warehouse-error-meta.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest" import { WAREHOUSE_ERROR_TAGS, - warehouseErrorMeta, + warehouseErrorCode, warehouseErrorStatus, presentWarehouseError, isWarehouseErrorTag, @@ -14,10 +14,7 @@ describe("warehouse error meta", () => { expect(new Set(WAREHOUSE_ERROR_TAGS).size).toBe(warehouseHttpErrors.length) }) - it("covers every derived tag in the meta table and the status map", () => { - // The Record type already fails compilation on a missing key; this guards - // the other direction — a stray key for a tag that no longer exists. - expect(Object.keys(warehouseErrorMeta).sort()).toEqual([...WAREHOUSE_ERROR_TAGS].sort()) + it("covers every derived tag in the status map", () => { for (const tag of WAREHOUSE_ERROR_TAGS) { expect(warehouseErrorStatus.get(tag), tag).toBeTypeOf("number") expect(isWarehouseErrorTag(tag)).toBe(true) @@ -34,7 +31,7 @@ describe("warehouse error meta", () => { }) it("unique codes per tag", () => { - const codes = Object.values(warehouseErrorMeta).map((meta) => meta.code) + const codes = WAREHOUSE_ERROR_TAGS.map((tag) => warehouseErrorCode({ _tag: tag })) expect(new Set(codes).size).toBe(codes.length) }) diff --git a/packages/domain/src/http/warehouse-error-meta.ts b/packages/domain/src/http/warehouse-error-meta.ts index 8e8106186..28bb77ade 100644 --- a/packages/domain/src/http/warehouse-error-meta.ts +++ b/packages/domain/src/http/warehouse-error-meta.ts @@ -8,8 +8,7 @@ // // - `WAREHOUSE_ERROR_TAGS` / `warehouseErrorStatus` are DERIVED from the error // classes themselves (same annotation-reading as `anticipated-errors.ts`). -// - `warehouseErrorMeta` is a `Record` keyed by the tag union, so a tenth tag -// is a compile error HERE and nowhere else. +// - Public HTTP codes/titles live on the error classes themselves. // - `presentWarehouseError` is the shared human-facing formatter. It is // structural (works on wire-decoded objects, not just class instances) so // the web app can feed it errors that crossed the HTTP boundary. @@ -19,7 +18,12 @@ import type { WarehouseError } from "./warehouse-errors" import { warehouseHttpErrors } from "./warehouse-errors" -import type { HttpErrorPolicy } from "./error-policy" +import { + publicHttpErrorPolicyFor, + type PublicHttpErrorPolicy, + type PublicHttpErrorStatus, + type PublicTaggedError, +} from "./error-policy" export type WarehouseErrorTag = WarehouseError["_tag"] @@ -55,100 +59,38 @@ export const warehouseErrorStatus: ReadonlyMap<WarehouseErrorTag, number> = new }), ) -export interface WarehouseErrorMeta extends HttpErrorPolicy { - /** Stable machine-readable slug for envelopes/failure categories. */ - readonly code: string - /** Whose fault this is — drives copy tone, alerting, and on-call routing. */ - readonly blame: "maple" | "customer" | "upstream" +const warehouseErrorClassByTag = new Map<WarehouseErrorTag, Function>( + warehouseHttpErrors.map((cls) => [readTag(cls) as WarehouseErrorTag, cls]), +) + +type WarehousePublicError = PublicTaggedError & WarehouseErrorLike + +const warehouseErrorPolicy = ( + error: WarehouseErrorLike, +): PublicHttpErrorPolicy<WarehousePublicError, PublicHttpErrorStatus> => { + const errorClass = warehouseErrorClassByTag.get(error._tag) + if (errorClass === undefined) throw new Error(`Unknown warehouse error tag: ${error._tag}`) + return publicHttpErrorPolicyFor<WarehousePublicError>(errorClass) } -/** - * Per-tag presentation metadata. Keyed by the tag UNION, so adding a warehouse - * error class without extending this table is a compile error — in exactly one - * file. - */ -export const warehouseErrorMeta: Record<WarehouseErrorTag, WarehouseErrorMeta> = { - "@maple/http/errors/WarehouseQueryError": { - code: "warehouse_query_failed", - blame: "upstream", - title: "Database query failed", - retry: "never", - recovery: "contact_support", - origin: "dependency", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseUpstreamError": { - code: "warehouse_unavailable", - blame: "upstream", - title: "Database is temporarily unavailable", - retry: "backoff", - recovery: "retry", - origin: "dependency", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseAuthError": { - code: "warehouse_auth_failed", - blame: "customer", - title: "Database rejected our credentials", - retry: "never", - recovery: "reconnect", - origin: "client", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseConfigError": { - code: "warehouse_config_invalid", - blame: "customer", - title: "Database is not configured correctly", - retry: "never", - recovery: "reconnect", - origin: "client", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseClientError": { - code: "warehouse_client_error", - blame: "upstream", - title: "Database response could not be decoded", - retry: "never", - recovery: "contact_support", - origin: "dependency", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseSchemaDriftError": { - code: "warehouse_schema_drift", - blame: "customer", - title: "Database schema is out of date", - retry: "never", - recovery: "reconnect", - origin: "client", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseMalformedQueryError": { - code: "warehouse_malformed_query", - blame: "maple", - title: "This chart hit a bug in Maple", - retry: "never", - recovery: "contact_support", - origin: "maple", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseQuotaExceededError": { - code: "warehouse_quota_exceeded", - blame: "customer", - title: "Query was too expensive", - retry: "never", - recovery: "fix_request", - origin: "client", - exposure: "redacted", - }, - "@maple/http/errors/WarehouseValidationError": { - code: "warehouse_validation_failed", - blame: "customer", - title: "Invalid query", - retry: "never", - recovery: "fix_request", - origin: "client", - exposure: "public_message", - }, +const resolvePolicyValue = <Value>( + value: Value | ((error: WarehousePublicError) => Value), + error: WarehouseErrorLike, +): Value => + typeof value === "function" + ? (value as (error: WarehousePublicError) => Value)({ message: error.message ?? "", ...error }) + : value + +/** Stable machine-readable code owned by the warehouse error class. */ +export const warehouseErrorCode = (error: WarehouseErrorLike): string => { + const policy = warehouseErrorPolicy(error) + return resolvePolicyValue(policy.code, error) +} + +/** Public title owned by the warehouse error class. */ +export const warehouseErrorTitle = (error: WarehouseErrorLike): string => { + const policy = warehouseErrorPolicy(error) + return resolvePolicyValue(policy.title, error) } /** @@ -212,32 +154,32 @@ export interface PresentedWarehouseError { * instances; both get identical copy. */ export const presentWarehouseError = (error: WarehouseErrorLike): PresentedWarehouseError => { - const meta = warehouseErrorMeta[error._tag] + const title = warehouseErrorTitle(error) const message = error.message !== undefined ? cleanErrorMessage(error.message) : undefined switch (error._tag) { case "@maple/http/errors/WarehouseQuotaExceededError": { const setting = error.setting ?? "limit" return { - title: meta.title, + title, description: QUOTA_DESCRIPTIONS[setting] ?? `Query exceeded the ${setting} limit. Narrow the time range or add filters.`, } } case "@maple/http/errors/WarehouseAuthError": - return { title: meta.title, description: authDescription(error.upstreamStatus) } + return { title, description: authDescription(error.upstreamStatus) } case "@maple/http/errors/WarehouseUpstreamError": return { - title: meta.title, + title, description: error.upstreamStatus !== undefined ? `The query backend returned ${error.upstreamStatus}. Retry in a few seconds.` : "The query backend is unreachable. Retry in a few seconds.", } case "@maple/http/errors/WarehouseConfigError": - return { title: meta.title, description: message ?? "Database is not configured correctly." } + return { title, description: message ?? "Database is not configured correctly." } case "@maple/http/errors/WarehouseClientError": - return { title: meta.title, description: message ?? "Database response could not be decoded." } + return { title, description: message ?? "Database response could not be decoded." } case "@maple/http/errors/WarehouseSchemaDriftError": { // `kind: "decode"` = the CLUSTER answered fine but the rows failed // Maple's own row schema — schema apply cannot fix that, so don't @@ -249,7 +191,7 @@ export const presentWarehouseError = (error: WarehouseErrorLike): PresentedWareh } } return { - title: meta.title, + title, description: `${message ?? "A column Maple expects is missing from the cluster."} Run schema apply from your ClickHouse settings.`, } } @@ -257,12 +199,12 @@ export const presentWarehouseError = (error: WarehouseErrorLike): PresentedWareh // Maple generated SQL the database refused to plan. Nothing the user // can do — do not send them to their database settings. return { - title: meta.title, + title, description: "Maple built a query its own database rejected. This is our fault, not a problem with your data or your cluster — we have been alerted.", } case "@maple/http/errors/WarehouseValidationError": - return { title: meta.title, description: message ?? "The query was rejected before running." } + return { title, description: message ?? "The query was rejected before running." } case "@maple/http/errors/WarehouseQueryError": { // Generic SQL/query failure. Some transient failures still arrive with // only a status code embedded in the message (e.g. a 5xx HTML body), so @@ -271,23 +213,23 @@ export const presentWarehouseError = (error: WarehouseErrorLike): PresentedWareh const upstreamStatus = extractUpstreamStatus(text) if (upstreamStatus === 401 || upstreamStatus === 403) { return { - title: warehouseErrorMeta["@maple/http/errors/WarehouseAuthError"].title, + title: warehouseErrorTitle({ _tag: "@maple/http/errors/WarehouseAuthError" }), description: authDescription(upstreamStatus), } } if (upstreamStatus !== undefined && upstreamStatus >= 500 && upstreamStatus < 600) { return { - title: warehouseErrorMeta["@maple/http/errors/WarehouseUpstreamError"].title, + title: warehouseErrorTitle({ _tag: "@maple/http/errors/WarehouseUpstreamError" }), description: `The query backend returned ${upstreamStatus}. Retry in a few seconds.`, } } - return { title: meta.title, description: text } + return { title, description: text } } } } export const isWarehouseErrorTag = (tag: string): tag is WarehouseErrorTag => - Object.hasOwn(warehouseErrorMeta, tag) + warehouseErrorClassByTag.has(tag as WarehouseErrorTag) /** * Presentation with the raw upstream message REDACTED — every description diff --git a/packages/domain/src/http/warehouse-errors.ts b/packages/domain/src/http/warehouse-errors.ts index 4ba9c6775..a61699301 100644 --- a/packages/domain/src/http/warehouse-errors.ts +++ b/packages/domain/src/http/warehouse-errors.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { HttpTaggedError } from "./error-policy" // Pure error definitions for warehouse queries. This module imports ONLY // `effect` Schema — never `effect/unstable/httpapi` — so non-HTTP consumers @@ -18,7 +19,7 @@ import { Schema } from "effect" // under the far larger per-request budget). // Fields common to every warehouse error. `cause` carries the original thrown -// defect; `clickhouse*` carry CH diagnostics extracted by `mapWarehouseError`. +// defect; `clickhouse*` carry CH diagnostics extracted by the warehouse classifier. const warehouseErrorBaseFields = { message: Schema.String, pipeName: Schema.String, @@ -28,38 +29,84 @@ const warehouseErrorBaseFields = { } /** Generic ClickHouse/SQL query failure — the default when nothing more specific matches. */ -export class WarehouseQueryError extends Schema.TaggedError<WarehouseQueryError>()( +export class WarehouseQueryError extends HttpTaggedError<WarehouseQueryError>()( "@maple/http/errors/WarehouseQueryError", warehouseErrorBaseFields, - { httpApiStatus: 502 }, + { + status: 502, + code: "warehouse_query_failed", + title: "Database query failed", + message: "The database query could not be completed.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} /** Transient query-backend / CDN / network failure. Retryable; mapped to 503. */ -export class WarehouseUpstreamError extends Schema.TaggedError<WarehouseUpstreamError>()( +export class WarehouseUpstreamError extends HttpTaggedError<WarehouseUpstreamError>()( "@maple/http/errors/WarehouseUpstreamError", { ...warehouseErrorBaseFields, upstreamStatus: Schema.optional(Schema.Number) }, - { httpApiStatus: 503 }, + { + status: 503, + code: "warehouse_unavailable", + title: "Database is temporarily unavailable", + message: (error) => + error.upstreamStatus === undefined + ? "The query backend is unreachable. Retry in a few seconds." + : `The query backend returned ${error.upstreamStatus}. Retry in a few seconds.`, + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} /** Upstream 401/403 or database credentials failure. */ -export class WarehouseAuthError extends Schema.TaggedError<WarehouseAuthError>()( +export class WarehouseAuthError extends HttpTaggedError<WarehouseAuthError>()( "@maple/http/errors/WarehouseAuthError", { ...warehouseErrorBaseFields, upstreamStatus: Schema.optional(Schema.Number) }, - { httpApiStatus: 502 }, + { + status: 502, + code: "warehouse_auth_failed", + title: "Database rejected our credentials", + message: (error) => + error.upstreamStatus === 403 + ? "The configured database credentials are missing required permissions." + : "The configured database credentials are invalid or expired. Update them in settings.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, ) {} /** Backend/database is misconfigured (unknown database/table, bad URL, etc.). */ -export class WarehouseConfigError extends Schema.TaggedError<WarehouseConfigError>()( +export class WarehouseConfigError extends HttpTaggedError<WarehouseConfigError>()( "@maple/http/errors/WarehouseConfigError", warehouseErrorBaseFields, - { httpApiStatus: 502 }, + { + status: 502, + code: "warehouse_config_invalid", + title: "Database is not configured correctly", + message: "Database is not configured correctly.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, ) {} /** Maple's query client could not decode/consume the response. */ -export class WarehouseClientError extends Schema.TaggedError<WarehouseClientError>()( +export class WarehouseClientError extends HttpTaggedError<WarehouseClientError>()( "@maple/http/errors/WarehouseClientError", warehouseErrorBaseFields, - { httpApiStatus: 502 }, + { + status: 502, + code: "warehouse_client_error", + title: "Database response could not be decoded", + message: "Database response could not be decoded.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} /** @@ -73,10 +120,24 @@ export class WarehouseClientError extends Schema.TaggedError<WarehouseClientErro * rows failed Maple's own row schema — schema apply cannot fix that and the * presenter must not suggest it. */ -export class WarehouseSchemaDriftError extends Schema.TaggedError<WarehouseSchemaDriftError>()( +export class WarehouseSchemaDriftError extends HttpTaggedError<WarehouseSchemaDriftError>()( "@maple/http/errors/WarehouseSchemaDriftError", { ...warehouseErrorBaseFields, kind: Schema.optional(Schema.Literals(["cluster", "decode"])) }, - { httpApiStatus: 502 }, + { + status: 502, + code: "warehouse_schema_drift", + title: (error) => + error.kind === "decode" + ? "Query results did not match what Maple expected" + : "Database schema is out of date", + message: (error) => + error.kind === "decode" + ? "The database answered with rows Maple could not decode. This is likely a Maple bug, not a problem with your cluster." + : "A column Maple expects is missing from the cluster. Run schema apply from your ClickHouse settings.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, ) {} /** @@ -91,20 +152,46 @@ export class WarehouseSchemaDriftError extends Schema.TaggedError<WarehouseSchem * UI stops telling people to check their database. Mapped to 500 — the fault is * ours. */ -export class WarehouseMalformedQueryError extends Schema.TaggedError<WarehouseMalformedQueryError>()( +export class WarehouseMalformedQueryError extends HttpTaggedError<WarehouseMalformedQueryError>()( "@maple/http/errors/WarehouseMalformedQueryError", warehouseErrorBaseFields, - { httpApiStatus: 500 }, + { + status: 500, + code: "warehouse_malformed_query", + title: "This chart hit a bug in Maple", + message: + "Maple built a query its own database rejected. This is our fault, not a problem with your data or your cluster.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} /** A query exceeded a ClickHouse resource quota. Mapped to 429. */ -export class WarehouseQuotaExceededError extends Schema.TaggedError<WarehouseQuotaExceededError>()( +export class WarehouseQuotaExceededError extends HttpTaggedError<WarehouseQuotaExceededError>()( "@maple/http/errors/WarehouseQuotaExceededError", { ...warehouseErrorBaseFields, setting: Schema.Literals(["max_execution_time", "max_memory_usage", "max_threads"]), }, - { httpApiStatus: 429 }, + { + status: 429, + code: "warehouse_quota_exceeded", + title: "Query was too expensive", + message: (error) => { + switch (error.setting) { + case "max_execution_time": + return "Query exceeded the 30s execution limit. Narrow the time range or add filters." + case "max_memory_usage": + return "Query exceeded the memory limit. Add filters or reduce cardinality." + case "max_threads": + return "Query exceeded the thread limit. Try a smaller scan." + } + }, + retry: "never", + recovery: "fix_request", + exposure: "redacted", + }, ) {} /** @@ -112,10 +199,17 @@ export class WarehouseQuotaExceededError extends Schema.TaggedError<WarehouseQuo * missing OrgId filter, unsupported pipe). This is a bad request, not a backend * failure — mapped to 400. */ -export class WarehouseValidationError extends Schema.TaggedError<WarehouseValidationError>()( +export class WarehouseValidationError extends HttpTaggedError<WarehouseValidationError>()( "@maple/http/errors/WarehouseValidationError", warehouseErrorBaseFields, - { httpApiStatus: 400 }, + { + status: 400, + code: "warehouse_validation_failed", + title: "Invalid query", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} /** Every warehouse error. Use this as the error channel of warehouse-facing effects. */ diff --git a/packages/domain/src/query-engine.ts b/packages/domain/src/query-engine.ts index fa5ebef84..b7c1026df 100644 --- a/packages/domain/src/query-engine.ts +++ b/packages/domain/src/query-engine.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { PublicHttpErrorBodySchema } from "./http/error-policy" import { CommitSha, DeploymentEnvironment, @@ -507,15 +508,8 @@ export class QueryEngineExecuteResponse extends Schema.Class<QueryEngineExecuteR */ export const QUERY_ENGINE_BATCH_MAX = 4 -/** - * A per-item failure. Carries the original error's `_tag` so the client can - * hand it straight to its existing error normalization — `@maple/http/errors/*` - * tags pass through untouched and keep their UI copy. - */ -export const QueryEngineBatchFailure = Schema.Struct({ - _tag: Schema.String, - message: Schema.String, -}) +/** A per-item failure uses the same complete public body as a failed HTTP response. */ +export const QueryEngineBatchFailure = PublicHttpErrorBodySchema export type QueryEngineBatchFailure = Schema.Schema.Type<typeof QueryEngineBatchFailure> /** diff --git a/packages/domain/src/setup-audit.ts b/packages/domain/src/setup-audit.ts index eb15f0911..11f61838a 100644 --- a/packages/domain/src/setup-audit.ts +++ b/packages/domain/src/setup-audit.ts @@ -16,6 +16,27 @@ // tested (delivery works fine without ever pressing Test), onboarding checklist incomplete (owned by // the onboarding surface). A check that fires on every healthy org is noise, not an audit. +import { Schema } from "effect" +import { HttpTaggedError } from "./http/error-policy" + +export class SetupAuditUnavailableError extends HttpTaggedError<SetupAuditUnavailableError>()( + "@maple/setup-audit/SetupAuditUnavailableError", + { + message: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, + { + status: 503, + code: "setup_audit_unavailable", + title: "Setup audit is temporarily unavailable", + message: "The setup audit is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, +) {} + export type AuditSeverity = "critical" | "warn" | "info" export type AuditStatus = "pass" | "fail" | "skip"