From 245b09a476c5f12b8912e6b7faa00a664964345e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 13 Aug 2026 18:58:09 +0200 Subject: [PATCH 1/4] refactor(api): preserve v2 error tags end to end --- .../src/http/v2-worker-unavailable.test.ts | 15 + apps/api/src/http/v2-worker-unavailable.ts | 16 + apps/api/src/routes/v1/anomalies.http.ts | 20 +- apps/api/src/routes/v1/errors.http.ts | 48 +- .../routes/v2/phase1-resources.http.test.ts | 39 +- .../services/alerts/AlertReadModelsService.ts | 69 +- apps/api/src/services/alerts/AlertsService.ts | 1 + .../alerts/AnomalyDetectionService.ts | 35 +- .../services/alerts/NotificationDispatcher.ts | 13 +- .../services/auth/ApiAuthorizationV2Layer.ts | 4 +- apps/api/src/services/auth/clerk-request.ts | 15 +- .../api/src/services/billing/autumn-client.ts | 19 +- .../errors/ErrorIssueReadModelsService.ts | 34 +- .../services/errors/InvestigationService.ts | 4 +- .../src/services/errors/ai-triage-enqueue.ts | 2 +- .../errors/investigation-fanout-error.ts | 20 +- .../CloudflareAnalyticsService.ts | 16 +- .../integrations/SlackIntegrationService.ts | 26 +- .../integrations/TinybirdOrgTokenService.ts | 13 +- .../planetscale/PlanetScaleWebhookQueue.ts | 13 +- .../services/integrations/slack-bot-token.ts | 9 +- .../vcs/vendor/github/GithubAppClient.ts | 16 +- .../api/src/services/org/OrgMembersService.ts | 15 +- .../warehouse/WarehouseQueryService.test.ts | 5 +- .../warehouse/WarehouseQueryService.ts | 30 +- .../warehouse/warehouse-error-handlers.ts | 1 + apps/api/src/worker.ts | 13 +- .../lib/services/common/retry-policy.test.ts | 6 + .../src/lib/services/common/retry-policy.ts | 14 +- .../alchemy-maple/src/AlertDestination.ts | 14 +- packages/alchemy-maple/src/AlertRule.ts | 14 +- packages/alchemy-maple/src/ApiKey.ts | 14 +- packages/alchemy-maple/src/Dashboard.ts | 18 +- packages/alchemy-maple/src/MapleApi.ts | 183 ++-- packages/alchemy-maple/src/errors.ts | 110 ++- packages/alchemy-maple/src/index.ts | 12 +- packages/alchemy-maple/test/contract.test.ts | 14 + packages/alchemy-maple/test/maple-api.test.ts | 151 ++++ packages/alchemy-maple/test/providers.test.ts | 17 +- packages/domain/src/http/current-tenant.ts | 25 +- packages/domain/src/http/error-policy.ts | 63 +- .../src/http/org-clickhouse-settings.ts | 70 +- packages/domain/src/http/v2/alert-rules.ts | 6 +- packages/domain/src/http/v2/anomalies.ts | 3 +- packages/domain/src/http/v2/api-keys.ts | 9 +- packages/domain/src/http/v2/auth.ts | 6 +- packages/domain/src/http/v2/error-issues.ts | 10 +- packages/domain/src/http/v2/errors.ts | 783 +----------------- packages/domain/src/http/v2/ingest-keys.ts | 8 +- packages/domain/src/http/v2/openapi.test.ts | 43 +- packages/domain/src/http/v2/public-error.ts | 5 +- packages/domain/src/http/v2/query-errors.ts | 15 + .../domain/src/http/v2/v2-contract.test.ts | 38 +- .../domain/src/http/warehouse-error-meta.ts | 7 +- packages/domain/src/http/warehouse-errors.ts | 20 + packages/query-engine/src/execution/errors.ts | 16 +- .../query-engine/src/execution/executor.ts | 15 +- packages/query-engine/src/execution/ports.ts | 23 +- 58 files changed, 1083 insertions(+), 1160 deletions(-) create mode 100644 apps/api/src/http/v2-worker-unavailable.test.ts create mode 100644 apps/api/src/http/v2-worker-unavailable.ts create mode 100644 packages/alchemy-maple/test/maple-api.test.ts diff --git a/apps/api/src/http/v2-worker-unavailable.test.ts b/apps/api/src/http/v2-worker-unavailable.test.ts new file mode 100644 index 000000000..e7b966528 --- /dev/null +++ b/apps/api/src/http/v2-worker-unavailable.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@effect/vitest" +import { Schema } from "effect" +import { V2WorkerUnavailable } from "@maple/domain/http/v2" +import { v2WorkerUnavailableResponse } from "./v2-worker-unavailable" + +describe("v2 worker fallback", () => { + it("uses the declared 504 tag and canonical body", async () => { + const response = v2WorkerUnavailableResponse() + const body = await response.json() + + expect(response.status).toBe(504) + expect(response.headers.get("retry-after")).toBe("1") + expect(() => Schema.decodeUnknownSync(V2WorkerUnavailable.schema)(body)).not.toThrow() + }) +}) diff --git a/apps/api/src/http/v2-worker-unavailable.ts b/apps/api/src/http/v2-worker-unavailable.ts new file mode 100644 index 000000000..f3cb4d6ef --- /dev/null +++ b/apps/api/src/http/v2-worker-unavailable.ts @@ -0,0 +1,16 @@ +import type { AnyPublicHttpErrorBody } from "@maple/domain/http" + +/** Canonical v2 fallback used when the route graph could not finish bootstrapping. */ +export const v2WorkerUnavailableResponse = (): Response => { + const error = { + _tag: "@maple/http/v2/WorkerUnavailableError", + type: "api_error", + code: "worker_unavailable", + title: "Maple API is temporarily unavailable", + message: "Maple API is temporarily unavailable. Retry in a few seconds.", + retryable: true, + recovery: "retry", + retry_after_seconds: 1, + } as const satisfies AnyPublicHttpErrorBody + return Response.json({ error }, { status: 504, headers: { "retry-after": "1" } }) +} diff --git a/apps/api/src/routes/v1/anomalies.http.ts b/apps/api/src/routes/v1/anomalies.http.ts index 8b6cd0ec4..73c789e39 100644 --- a/apps/api/src/routes/v1/anomalies.http.ts +++ b/apps/api/src/routes/v1/anomalies.http.ts @@ -10,9 +10,17 @@ import { type OrgId, } from "@maple/domain/http" import { Effect } from "effect" -import { AnomalyDetectionService } from "@/services/alerts/AnomalyDetectionService" +import { + AnomalyDetectionService, + makePersistenceError as makeAnomalyPersistenceError, +} from "@/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@/services/errors/ErrorsService" import { requireAdmin } from "@/services/auth/auth" +import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" + +// v1 keeps its historical generic persistence failure; v2 exposes each warehouse tag directly. +const legacyPersistenceFailure = (error: { readonly message: string }) => + Effect.fail(makeAnomalyPersistenceError(error)) export const HttpAnomaliesLive = HttpApiBuilder.group(MapleApi, "anomalies", (handlers) => Effect.gen(function* () { @@ -83,10 +91,12 @@ export const HttpAnomaliesLive = HttpApiBuilder.group(MapleApi, "anomalies", (ha orgId: tenant.orgId, incidentId: params.incidentId, }) - return yield* anomalies.getIncidentTimeseries(tenant, params.incidentId, { - startTime: query.startTime, - endTime: query.endTime, - }) + return yield* anomalies + .getIncidentTimeseries(tenant, params.incidentId, { + startTime: query.startTime, + endTime: query.endTime, + }) + .pipe(Effect.catchTags(warehouseHandlers(legacyPersistenceFailure))) }).pipe(Effect.withSpan("HttpAnomalies.getIncidentTimeseries")), ) .handle("resolveIncident", ({ params }) => diff --git a/apps/api/src/routes/v1/errors.http.ts b/apps/api/src/routes/v1/errors.http.ts index 6b6a3acb3..44e10c9ea 100644 --- a/apps/api/src/routes/v1/errors.http.ts +++ b/apps/api/src/routes/v1/errors.http.ts @@ -7,6 +7,12 @@ import { ErrorIssueWorkflowService } from "@/services/errors/ErrorIssueWorkflowS import { ErrorPolicyService } from "@/services/errors/ErrorPolicyService" import { ErrorsService } from "@/services/errors/ErrorsService" import { requireAdmin } from "@/services/auth/auth" +import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" +import { makePersistenceError } from "@/services/errors/error-persistence" + +// v1 keeps its historical generic persistence failure; v2 exposes each warehouse tag directly. +const legacyPersistenceFailure = (error: { readonly message: string }) => + Effect.fail(makePersistenceError(error)) export const HttpErrorsLive = HttpApiBuilder.group(MapleApi, "errors", (handlers) => Effect.gen(function* () { @@ -25,19 +31,21 @@ export const HttpErrorsLive = HttpApiBuilder.group(MapleApi, "errors", (handlers workflowState: query.workflowState ?? "all", limit: query.limit ?? 100, }) - const response = yield* readModels.listIssues(tenant.orgId, { - workflowState: query.workflowState, - severity: query.severity, - kind: query.kind, - service: query.service, - deploymentEnv: query.deploymentEnv, - assignedActorId: query.assignedActorId, - includeArchived: query.includeArchived === "1", - startTime: query.startTime, - endTime: query.endTime, - limit: query.limit, - cursor: query.cursor, - }) + const response = yield* readModels + .listIssues(tenant.orgId, { + workflowState: query.workflowState, + severity: query.severity, + kind: query.kind, + service: query.service, + deploymentEnv: query.deploymentEnv, + assignedActorId: query.assignedActorId, + includeArchived: query.includeArchived === "1", + startTime: query.startTime, + endTime: query.endTime, + limit: query.limit, + cursor: query.cursor, + }) + .pipe(Effect.catchTags(warehouseHandlers(legacyPersistenceFailure))) yield* Effect.annotateCurrentSpan("issueCount", response.issues.length) return response }).pipe(Effect.withSpan("HttpErrors.listIssues")), @@ -49,12 +57,14 @@ export const HttpErrorsLive = HttpApiBuilder.group(MapleApi, "errors", (handlers orgId: tenant.orgId, issueId: params.issueId, }) - return yield* readModels.getIssue(tenant.orgId, params.issueId, { - startTime: query.startTime, - endTime: query.endTime, - bucketSeconds: query.bucketSeconds, - sampleLimit: query.sampleLimit, - }) + return yield* readModels + .getIssue(tenant.orgId, params.issueId, { + startTime: query.startTime, + endTime: query.endTime, + bucketSeconds: query.bucketSeconds, + sampleLimit: query.sampleLimit, + }) + .pipe(Effect.catchTags(warehouseHandlers(legacyPersistenceFailure))) }).pipe(Effect.withSpan("HttpErrors.getIssue")), ) .handle("transitionIssue", ({ params, payload }) => 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 fbec58ea6..d2dec61a2 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -40,6 +40,7 @@ import { SpanId, TraceId, UserId, + WarehouseConfigLookupError, } from "@maple/domain/http" import { MapleApiV2, encodePublicId } from "@maple/domain/http/v2" import { WarehouseResponseLimitError } from "@maple/query-engine/execution" @@ -358,10 +359,11 @@ const ORG = Schema.decodeUnknownSync(OrgId)("org_phase1_e2e") const USER = Schema.decodeUnknownSync(UserId)("user_phase1_e2e") type InvestigationStartMode = "success" | "quota" | "unavailable" | "rejected" | "restart_not_found" +type IssueReadFailure = "none" | "persistence" | "warehouse_config_lookup" const makeHarness = ( warehouseService: WarehouseQueryServiceShape = warehouseStub, - failIssueReads = false, + issueReadFailure: IssueReadFailure = "none", investigationStartMode: InvestigationStartMode = "success", ) => { const testDb = createTestDb(createdDbs) @@ -377,6 +379,15 @@ const makeHarness = ( readonly orgId: string readonly options: Record } | null = null + const issueReadFailureEffect = () => + issueReadFailure === "warehouse_config_lookup" + ? Effect.fail( + new WarehouseConfigLookupError({ + pipeName: "errorIssues", + message: "SECRET_CONFIG_LOOKUP_FAILURE", + }), + ) + : Effect.fail(new ErrorPersistenceError({ message: "database unavailable" })) const startInvestigation = () => { switch (investigationStartMode) { case "quota": @@ -474,15 +485,15 @@ const makeHarness = ( Layer.succeed(ErrorIssueReadModelsService, { listIssues: (orgId, options) => { lastIssueListCall = { orgId, options } - if (failIssueReads) { - return Effect.fail(new ErrorPersistenceError({ message: "database unavailable" })) + if (issueReadFailure !== "none") { + return issueReadFailureEffect() } return Effect.succeed(new ErrorIssuesListResponse({ issues: [errorIssueFixture] })) }, getIssue: (orgId, issueId, options) => { lastIssueDetailCall = { orgId, options } - return failIssueReads - ? Effect.fail(new ErrorPersistenceError({ message: "warehouse unavailable" })) + return issueReadFailure !== "none" + ? issueReadFailureEffect() : issueId === errorIssueFixture.id ? Effect.succeed(errorIssueDetailFixture) : Effect.fail(ErrorIssueNotFoundError.forIssue(issueId)) @@ -695,7 +706,7 @@ describe("v2 error_issues over HTTP", () => { }) it("maps list and rich-retrieve dependency failures to v2 503 errors", async () => { - const harness = makeHarness(warehouseStub, true) + const harness = makeHarness(warehouseStub, "persistence") const key = await harness.bootstrapKey(["error_issues:read"]) const list = await harness.request("GET", "/v2/error_issues", { token: key.secret, @@ -710,6 +721,22 @@ describe("v2 error_issues over HTTP", () => { expect(detail.body.error.type).toBe("api_error") await harness.dispose() }) + + it("preserves exact warehouse failures instead of relabeling them as persistence", async () => { + const harness = makeHarness(warehouseStub, "warehouse_config_lookup") + const key = await harness.bootstrapKey(["error_issues:read"]) + const response = await harness.request("GET", "/v2/error_issues", { token: key.secret }) + + expect(response.status).toBe(503) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/WarehouseConfigLookupError", + code: "warehouse_config_lookup_unavailable", + retryable: true, + recovery: "retry", + }) + expect(JSON.stringify(response.body)).not.toContain("SECRET_CONFIG_LOOKUP_FAILURE") + await harness.dispose() + }) }) describe("v2 investigations over HTTP", () => { diff --git a/apps/api/src/services/alerts/AlertReadModelsService.ts b/apps/api/src/services/alerts/AlertReadModelsService.ts index 0abe757c4..71a1b04c7 100644 --- a/apps/api/src/services/alerts/AlertReadModelsService.ts +++ b/apps/api/src/services/alerts/AlertReadModelsService.ts @@ -25,6 +25,8 @@ import { UserId, type AlertIncidentId, type AlertRuleId, + type ManagedWarehouseError, + type WarehouseError, } from "@maple/domain/http" import { alertDeliveryEvents, @@ -101,7 +103,10 @@ export interface AlertReadModelsServiceShape { readonly beforeTimestamp?: string readonly beforeGroupKey?: string }, - ) => Effect.Effect + ) => Effect.Effect< + AlertChecksListResponse, + AlertPersistenceError | AlertNotFoundError | ManagedWarehouseError + > readonly summarizeRuleChecks: ( orgId: OrgId, ruleId: AlertRuleId, @@ -109,7 +114,10 @@ export interface AlertReadModelsServiceShape { readonly since: string readonly until: string }, - ) => Effect.Effect + ) => Effect.Effect< + AlertChecksSummary, + AlertPersistenceError | AlertNotFoundError | AlertValidationError | ManagedWarehouseError + > readonly listDeliveryEvents: ( orgId: OrgId, options?: ListAlertDeliveryEventsOptions, @@ -141,6 +149,14 @@ const toIso = (value: Date | null | undefined): IsoDateTimeValue | null => const makeValidationError = (message: string) => new AlertValidationError({ message, details: [] }) +/** Assert the `.routing("ingest")` invariant and remove its impossible config-lookup branch. */ +const managedWarehouseQuery = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTag("@maple/http/errors/WarehouseConfigLookupError", (error) => Effect.die(error)), + ) + const rowToIncidentDocument = (row: AlertIncidentRow) => new AlertIncidentDocument({ id: decodeAlertIncidentIdSync(row.id), @@ -309,21 +325,14 @@ export class AlertReadModelsService extends Context.Service< }, ) - const rows = yield* warehouse - // listRuleChecksQuery declares .routing("ingest") — alert_checks only - // exists in the managed Tinybird pipeline. - .compiledQuery(systemTenant(orgId), compiled, { + // listRuleChecksQuery declares .routing("ingest") — alert_checks only + // exists in the managed Tinybird pipeline. + const rows = yield* managedWarehouseQuery( + warehouse.compiledQuery(systemTenant(orgId), compiled, { profile: "list", context: "listAlertChecks", - }) - .pipe( - Effect.mapError( - (error) => - new AlertPersistenceError({ - message: `Failed to list alert checks: ${error.message}`, - }), - ), - ) + }), + ) const checks = yield* Effect.try({ try: () => @@ -419,8 +428,8 @@ export class AlertReadModelsService extends Context.Service< // stable across refreshes and matches alert evaluation granularity. const bucketSeconds = Math.max(1, Math.ceil((endMs - startMs) / 1000 / 720 / 60)) * 60 const tenant = systemTenant(orgId) - const groupRows = yield* warehouse - .compiledQuery( + const groupRows = yield* managedWarehouseQuery( + warehouse.compiledQuery( tenant, CH.compile(CH.alertCheckGroupTotalsQuery({ since, until, limit: 20 }), { orgId, @@ -429,18 +438,11 @@ export class AlertReadModelsService extends Context.Service< until, }), { profile: "aggregation", context: "alertCheckSummaryGroups" }, - ) - .pipe( - Effect.mapError( - (error) => - new AlertPersistenceError({ - message: `Failed to summarize alert check groups: ${error.message}`, - }), - ), - ) + ), + ) const topGroupKeys = groupRows.map((row) => String(row.groupKey ?? "")) - const rows = yield* warehouse - .compiledQuery( + const rows = yield* managedWarehouseQuery( + warehouse.compiledQuery( tenant, CH.compile(CH.alertChecksSummaryQuery({ topGroupKeys }), { orgId, @@ -450,15 +452,8 @@ export class AlertReadModelsService extends Context.Service< bucketSeconds, }), { profile: "aggregation", context: "alertCheckSummary" }, - ) - .pipe( - Effect.mapError( - (error) => - new AlertPersistenceError({ - message: `Failed to summarize alert checks: ${error.message}`, - }), - ), - ) + ), + ) const points: AlertChecksSummaryPoint[] = rows.map((row) => ({ bucket: String(row.bucket), diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index a2d8f4bf3..883569e2c 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -121,6 +121,7 @@ const WAREHOUSE_FAILURE_CATEGORIES = { "@maple/http/errors/WarehouseUpstreamError": "tinybird_upstream", "@maple/http/errors/WarehouseAuthError": "tinybird_auth", "@maple/http/errors/WarehouseConfigError": "tinybird_config", + "@maple/http/errors/WarehouseConfigLookupError": "tinybird_config_lookup", "@maple/http/errors/WarehouseClientError": "tinybird_client", "@maple/http/errors/WarehouseSchemaDriftError": "tinybird_schema_drift", "@maple/http/errors/WarehouseMalformedQueryError": "malformed_query", diff --git a/apps/api/src/services/alerts/AnomalyDetectionService.ts b/apps/api/src/services/alerts/AnomalyDetectionService.ts index 4d77185a3..6fe47f2f6 100644 --- a/apps/api/src/services/alerts/AnomalyDetectionService.ts +++ b/apps/api/src/services/alerts/AnomalyDetectionService.ts @@ -21,6 +21,7 @@ import { RoleName, type UserId, UserId as UserIdSchema, + type WarehouseError, } from "@maple/domain/http" import { anomalyDetectorSettings, @@ -125,7 +126,7 @@ const ANOMALY_ACTIVE_DISCOVERY_WINDOW_MS = 2 * HOUR_MS const ANOMALY_ACTIVE_ORGS_CACHE_BUCKET = "anomaly-active-orgs" const ANOMALY_ACTIVE_ORGS_CACHE_KEY = "active" const ANOMALY_ACTIVE_ORGS_CACHE_TTL_S = 6 * 60 * 60 -const makePersistenceError = makePersistenceErrorMapper( +export const makePersistenceError = makePersistenceErrorMapper( AnomalyPersistenceError, "Anomaly persistence failure", ) @@ -200,7 +201,7 @@ export interface AnomalyDetectionServiceShape { opts: { readonly startTime?: string; readonly endTime?: string }, ) => Effect.Effect< AnomalyIncidentTimeseriesResponse, - AnomalyPersistenceError | AnomalyIncidentNotFoundError + AnomalyPersistenceError | AnomalyIncidentNotFoundError | WarehouseError > readonly getSettings: ( orgId: OrgId, @@ -673,12 +674,10 @@ const make = Effect.gen(function* () { deploymentEnv: row.deploymentEnv, bucketSeconds, }) - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "list", - context: "anomalyIncidentTimeseries", - }) - .pipe(Effect.mapError(makePersistenceError)) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "anomalyIncidentTimeseries", + }) buckets = rollingCountBuckets( rows.map((r) => ({ bucketMs: parseWarehouseDateTime(String(r.bucket ?? "")), @@ -706,12 +705,10 @@ const make = Effect.gen(function* () { serviceName: row.serviceName, deploymentEnv: row.deploymentEnv, }) - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "list", - context: "anomalyIncidentTimeseries", - }) - .pipe(Effect.mapError(makePersistenceError)) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "anomalyIncidentTimeseries", + }) buckets = rows.map((r) => { const hourMs = parseWarehouseDateTime(String(r.hour ?? "")) const errorLogCount = Number(r.errorLogCount ?? 0) @@ -728,12 +725,10 @@ const make = Effect.gen(function* () { serviceName: row.serviceName, deploymentEnv: row.deploymentEnv, }) - const rows = yield* warehouse - .compiledQuery(tenant, compiled, { - profile: "list", - context: "anomalyIncidentTimeseries", - }) - .pipe(Effect.mapError(makePersistenceError)) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "anomalyIncidentTimeseries", + }) const signalType = row.signalType unit = signalType === "error_rate" diff --git a/apps/api/src/services/alerts/NotificationDispatcher.ts b/apps/api/src/services/alerts/NotificationDispatcher.ts index cb8043843..955a372ed 100644 --- a/apps/api/src/services/alerts/NotificationDispatcher.ts +++ b/apps/api/src/services/alerts/NotificationDispatcher.ts @@ -10,7 +10,7 @@ import { type OrgId, } from "@maple/domain/http" import { and, eq, inArray } from "drizzle-orm" -import { Clock, Context, Data, Effect, Layer, Redacted } from "effect" +import { Clock, Context, Effect, Layer, Redacted, Schema } from "effect" import { buildAlertChatUrl, dispatchDelivery as dispatchDeliveryImpl, @@ -35,10 +35,13 @@ import { Env } from "@/platform/Env" const DELIVERY_TIMEOUT_MS = 15_000 const NOTIFICATION_DELIVERY_CONCURRENCY = 5 -class NotificationDispatchError extends Data.TaggedError("@maple/api/services/NotificationDispatchError")<{ - readonly message: string - readonly cause?: unknown -}> {} +class NotificationDispatchError extends Schema.TaggedError()( + "@maple/api/services/NotificationDispatchError", + { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} export interface NotificationRequest { readonly deliveryKey: string diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 10638b29f..b48a781b5 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -108,9 +108,7 @@ export const ApiAuthorizationV2Layer = Layer.effect( return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant) } - const tenant = yield* resolveTenant(request.headers).pipe( - Effect.mapError(() => V2InvalidCredentials.make()), - ) + const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) return yield* Effect.provideService( httpEffect, diff --git a/apps/api/src/services/auth/clerk-request.ts b/apps/api/src/services/auth/clerk-request.ts index 13fe9b028..6e99dd79b 100644 --- a/apps/api/src/services/auth/clerk-request.ts +++ b/apps/api/src/services/auth/clerk-request.ts @@ -1,10 +1,13 @@ -import { Data, Effect } from "effect" +import { Effect, Schema } from "effect" -export class ClerkRequestError extends Data.TaggedError("@maple/api/services/auth/ClerkRequestError")<{ - readonly operation: string - readonly message: string - readonly cause: unknown -}> {} +export class ClerkRequestError extends Schema.TaggedError()( + "@maple/api/services/auth/ClerkRequestError", + { + operation: Schema.String, + message: Schema.String, + cause: Schema.Defect(), + }, +) {} type ClerkSpanAttributes = Readonly> diff --git a/apps/api/src/services/billing/autumn-client.ts b/apps/api/src/services/billing/autumn-client.ts index 3c007bf49..ab0daf7b0 100644 --- a/apps/api/src/services/billing/autumn-client.ts +++ b/apps/api/src/services/billing/autumn-client.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Schema } from "effect" +import { Effect, Schema } from "effect" import type { EdgeCacheServiceShape } from "@maple/cache" import { isActivePlanSubscription } from "@maple/domain/billing" import { BillingUpstreamError } from "@maple/domain/http" @@ -40,9 +40,13 @@ export const responseHasActivePlan = (response: unknown): boolean => { // Sentinel keeping non-200 Autumn responses out of the edge cache: the compute // fails with this so `getOrCompute` never stores it, then the caller recovers it // into the normal path. Mirrors `AutumnResult` so `.result` stays typed. -class UncacheableAutumnResult extends Data.TaggedError("@maple/api/billing/UncacheableAutumnResult")<{ - readonly result: AutumnResult -}> {} +class UncacheableAutumnResult extends Schema.TaggedError()( + "@maple/api/billing/UncacheableAutumnResult", + { + message: Schema.String, + result: Schema.Struct({ statusCode: Schema.Number, response: Schema.Unknown }), + }, +) {} /** * Run `getOrCreateCustomer` through the per-org edge cache (200-only). Active-plan @@ -69,7 +73,12 @@ export const readCustomerCached = ( Effect.flatMap((res) => res.statusCode === 200 ? Effect.succeed(res) - : Effect.fail(new UncacheableAutumnResult({ result: res })), + : Effect.fail( + new UncacheableAutumnResult({ + message: `Autumn returned HTTP ${res.statusCode}; response must not be cached`, + result: res, + }), + ), ), ), ) diff --git a/apps/api/src/services/errors/ErrorIssueReadModelsService.ts b/apps/api/src/services/errors/ErrorIssueReadModelsService.ts index a6ed7c2e8..356c6222c 100644 --- a/apps/api/src/services/errors/ErrorIssueReadModelsService.ts +++ b/apps/api/src/services/errors/ErrorIssueReadModelsService.ts @@ -20,6 +20,7 @@ import { RoleName, UserId as UserIdSchema, type WorkflowState, + type WarehouseError, } from "@maple/domain/http" import { errorIncidents, type ErrorIncidentRow, errorIssues } from "@maple/db" import { and, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm" @@ -35,7 +36,7 @@ import { dateToMs, msToDate } from "@/platform/time" import type { TenantContext } from "@/services/auth/AuthService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { ErrorIssueWorkflowService } from "./ErrorIssueWorkflowService" -import { makeErrorDatabaseExecute, makePersistenceError } from "./error-persistence" +import { makeErrorDatabaseExecute } from "./error-persistence" const decodeErrorIssueIdSync = Schema.decodeUnknownSync(ErrorIssueDocument.fields.id) const encodeIssueListCursor = Schema.encodeSync(IssueListCursor) @@ -98,7 +99,7 @@ export interface ErrorIssueReadModelsPublicShape { readonly actionable?: boolean readonly sort?: "last_seen" | "severity" }, - ) => Effect.Effect + ) => Effect.Effect /** Fleet-level open (actionable-state) error-issue counts grouped by service. */ readonly countOpenIssuesByService: ( orgId: OrgId, @@ -115,7 +116,10 @@ export interface ErrorIssueReadModelsPublicShape { readonly bucketSeconds?: number readonly sampleLimit?: number }, - ) => Effect.Effect + ) => Effect.Effect< + ErrorIssueDetailResponse, + ErrorPersistenceError | ErrorIssueNotFoundError | WarehouseError + > readonly listIssueIncidents: ( orgId: OrgId, issueId: ErrorIssueId, @@ -197,11 +201,9 @@ const make: Effect.Effect< endTime: formatWarehouseDateTime(scanEndMs), }, ) - const fingerprintRows = yield* warehouse - .compiledQuery(systemTenant(orgId), compiled, { - context: "errorIssueEnvFingerprints", - }) - .pipe(Effect.mapError(makePersistenceError)) + const fingerprintRows = yield* warehouse.compiledQuery(systemTenant(orgId), compiled, { + context: "errorIssueEnvFingerprints", + }) const hashes = fingerprintRows .map((row) => row.fingerprintHash) .filter((hash) => hash.length > 0) @@ -335,11 +337,9 @@ const make: Effect.Effect< bucketSeconds, }) const timeseriesEffect = isErrorKind - ? warehouse - .compiledQuery(tenant, timeseriesCompiled, { - context: "errorIssueTimeseries", - }) - .pipe(Effect.mapError(makePersistenceError)) + ? warehouse.compiledQuery(tenant, timeseriesCompiled, { + context: "errorIssueTimeseries", + }) : Effect.succeed([]) const samplesCompiled = CH.compile( @@ -353,11 +353,9 @@ const make: Effect.Effect< { rowSchema: CH.ErrorIssueSampleTracesOutputSchema }, ) const samplesEffect = isErrorKind - ? warehouse - .compiledQuery(tenant, samplesCompiled, { - context: "errorIssueSampleTraces", - }) - .pipe(Effect.mapError(makePersistenceError)) + ? warehouse.compiledQuery(tenant, samplesCompiled, { + context: "errorIssueSampleTraces", + }) : Effect.succeed([]) const incidentsEffect = dbExecute((db) => diff --git a/apps/api/src/services/errors/InvestigationService.ts b/apps/api/src/services/errors/InvestigationService.ts index e72a0235a..785b8be1b 100644 --- a/apps/api/src/services/errors/InvestigationService.ts +++ b/apps/api/src/services/errors/InvestigationService.ts @@ -571,7 +571,7 @@ export class InvestigationService extends Context.Service new FanoutStartError({ cause: String(cause) }), + catch: FanoutStartError.fromCause, }), ) @@ -914,7 +914,7 @@ export class InvestigationService extends Context.Service new FanoutStartError({ cause: String(cause) }), + catch: FanoutStartError.fromCause, }), ).pipe( // An instance that already finished cannot be terminated, and diff --git a/apps/api/src/services/errors/ai-triage-enqueue.ts b/apps/api/src/services/errors/ai-triage-enqueue.ts index d16983a1e..d1d157737 100644 --- a/apps/api/src/services/errors/ai-triage-enqueue.ts +++ b/apps/api/src/services/errors/ai-triage-enqueue.ts @@ -365,7 +365,7 @@ export const maybeEnqueueTriage: ( attempt: 0, }, }), - catch: (cause) => new FanoutStartError({ cause: String(cause) }), + catch: FanoutStartError.fromCause, }), ) if (Exit.isFailure(created)) { diff --git a/apps/api/src/services/errors/investigation-fanout-error.ts b/apps/api/src/services/errors/investigation-fanout-error.ts index afb3d812c..128ed8929 100644 --- a/apps/api/src/services/errors/investigation-fanout-error.ts +++ b/apps/api/src/services/errors/investigation-fanout-error.ts @@ -1,10 +1,18 @@ -import { Data } from "effect" +import { Schema } from "effect" /** Internal workflow-start failure shared by both investigation entry points. */ -export class FanoutStartError extends Data.TaggedError("@maple/api/errors/FanoutStartError")<{ - readonly cause: string -}> { - override get message(): string { - return `Investigation fanout failed to start: ${this.cause}` +export class FanoutStartError extends Schema.TaggedError()( + "@maple/api/errors/FanoutStartError", + { + message: Schema.String, + cause: Schema.String, + }, +) { + static fromCause(cause: unknown): FanoutStartError { + const detail = String(cause) + return new FanoutStartError({ + message: `Investigation fanout failed to start: ${detail}`, + cause: detail, + }) } } diff --git a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts index 2d332478e..329b44c23 100644 --- a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts +++ b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts @@ -52,7 +52,6 @@ import { Cause, Clock, Context, - Data, Effect, Layer, Match, @@ -826,14 +825,15 @@ interface DatasetPollFailure { * `find_errors` / the errors page through the SAME pipeline as every other error, instead of being * buried in `cloudflare_analytics_state.lastError` where nothing watches it. */ -class CloudflareAnalyticsPollError extends Data.TaggedError( +class CloudflareAnalyticsPollError extends Schema.TaggedError()( "@maple/api/integrations/CloudflareAnalyticsPollError", -)<{ - readonly message: string - readonly orgId: OrgId - readonly dataset: string - readonly kind: DatasetPollFailure["kind"] -}> {} + { + message: Schema.String, + orgId: OrgId, + dataset: Schema.String, + kind: Schema.Literals(["authz", "upstream", "revoked", "billing", "other"]), + }, +) {} type PollOutcome = | { readonly kind: "advanced"; readonly ingested: number } diff --git a/apps/api/src/services/integrations/SlackIntegrationService.ts b/apps/api/src/services/integrations/SlackIntegrationService.ts index 80fd5ddb8..8bbd38398 100644 --- a/apps/api/src/services/integrations/SlackIntegrationService.ts +++ b/apps/api/src/services/integrations/SlackIntegrationService.ts @@ -15,7 +15,7 @@ import { import { slackWorkspaces, type SlackWorkspaceRow } from "@maple/db" import { EdgeCacheService } from "@maple/cache" import { and, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" -import { Array as Arr, Clock, Context, Data, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Array as Arr, Clock, Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { decryptAes256Gcm, @@ -168,14 +168,14 @@ export const missingBotScopes = (grantedScope: string | null): ReadonlyArray { - override get message(): string { - return `Slack team ${this.teamId} is already connected to org ${this.orgId}` - } -} +class SlackCrossOrgConflict extends Schema.TaggedError()( + "@maple/api/integrations/SlackCrossOrgConflict", + { + teamId: Schema.String, + orgId: OrgId, + message: Schema.String, + }, +) {} const decodeApiKeyIdOption = Schema.decodeUnknownOption(ApiKeyId) const decodeOrgId = Schema.decodeUnknownEffect(OrgId) @@ -789,7 +789,13 @@ const make: Effect.Effect< // Zero rows means the same-team conflict hit an active row owned by a // different org (the setWhere blocked it) — abort so revoke-others // rolls back too. - if (upserted.length === 0) throw new SlackCrossOrgConflict({ teamId, orgId }) + if (upserted.length === 0) { + throw new SlackCrossOrgConflict({ + teamId, + orgId, + message: `Slack team ${teamId} is already connected to org ${orgId}`, + }) + } return { revokedOtherKeyIds: revokedOthers.map((r) => r.apiKeyId) } }), diff --git a/apps/api/src/services/integrations/TinybirdOrgTokenService.ts b/apps/api/src/services/integrations/TinybirdOrgTokenService.ts index a4b07caf3..1b054a9b6 100644 --- a/apps/api/src/services/integrations/TinybirdOrgTokenService.ts +++ b/apps/api/src/services/integrations/TinybirdOrgTokenService.ts @@ -1,5 +1,5 @@ import type { OrgId } from "@maple/domain" -import { Clock, Context, Data, Effect, Layer, Option, Redacted } from "effect" +import { Clock, Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { listOrgScopedDatasourceNames } from "@/services/warehouse/warehouse-catalog" import { mintOrgReadJwt } from "@/services/auth/tinybird-jwt" import { Env } from "@/platform/Env" @@ -29,10 +29,13 @@ export interface TinybirdOrgTokenServiceShape { readonly getOrgReadToken: (orgId: OrgId) => Effect.Effect } -export class TinybirdOrgTokenError extends Data.TaggedError("@maple/api/services/TinybirdOrgTokenError")<{ - readonly reason: "MissingSigningKey" | "MissingWorkspaceId" | "MintFailed" - readonly message: string -}> {} +export class TinybirdOrgTokenError extends Schema.TaggedError()( + "@maple/api/services/TinybirdOrgTokenError", + { + reason: Schema.Literals(["MissingSigningKey", "MissingWorkspaceId", "MintFailed"]), + message: Schema.String, + }, +) {} export class TinybirdOrgTokenService extends Context.Service< TinybirdOrgTokenService, diff --git a/apps/api/src/services/integrations/planetscale/PlanetScaleWebhookQueue.ts b/apps/api/src/services/integrations/planetscale/PlanetScaleWebhookQueue.ts index bdf053340..e4b5bd433 100644 --- a/apps/api/src/services/integrations/planetscale/PlanetScaleWebhookQueue.ts +++ b/apps/api/src/services/integrations/planetscale/PlanetScaleWebhookQueue.ts @@ -1,7 +1,7 @@ import type { Queue } from "@cloudflare/workers-types" import { OrgId } from "@maple/domain/http" import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" -import { Context, Data, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { PlanetScaleWebhookPayload } from "./webhook-events" const QUEUE_BINDING = "PLANETSCALE_WEBHOOK_QUEUE" @@ -15,12 +15,13 @@ export const PlanetScaleWebhookJob = Schema.Struct({ }) export type PlanetScaleWebhookJob = Schema.Schema.Type -export class PlanetScaleWebhookQueueError extends Data.TaggedError( +export class PlanetScaleWebhookQueueError extends Schema.TaggedError()( "@maple/api/services/planetscale/PlanetScaleWebhookQueueError", -)<{ - readonly message: string - readonly cause?: unknown -}> {} + { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} export interface PlanetScaleWebhookQueueShape { readonly send: (job: PlanetScaleWebhookJob) => Effect.Effect diff --git a/apps/api/src/services/integrations/slack-bot-token.ts b/apps/api/src/services/integrations/slack-bot-token.ts index ffc2e35ab..3e35991c6 100644 --- a/apps/api/src/services/integrations/slack-bot-token.ts +++ b/apps/api/src/services/integrations/slack-bot-token.ts @@ -1,7 +1,7 @@ import { slackWorkspaces } from "@maple/db" import { AlertDeliveryError, OrgId } from "@maple/domain/http" import { and, eq, isNull } from "drizzle-orm" -import { Context, Data, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { decryptAes256Gcm, parseBase64Aes256GcmKey } from "@/platform/Crypto" import { Database, type DatabaseShape } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" @@ -74,9 +74,10 @@ export const resolveSlackBotTokenForDispatch = Effect.fn("SlackBotTokenResolver. ) }) -class SlackBotTokenConfigError extends Data.TaggedError("@maple/api/services/SlackBotTokenConfigError")<{ - readonly message: string -}> {} +class SlackBotTokenConfigError extends Schema.TaggedError()( + "@maple/api/services/SlackBotTokenConfigError", + { message: Schema.String }, +) {} export interface SlackBotTokenResolverShape { readonly resolve: (orgId: OrgId) => Effect.Effect diff --git a/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts b/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts index 646eb6dae..2932b073e 100644 --- a/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts +++ b/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts @@ -1,5 +1,5 @@ import { GitCommitSha } from "@maple/domain/http" -import { Clock, Context, Data, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" import { Env } from "@/platform/Env" import { GithubHttp } from "./GithubHttp" @@ -10,17 +10,17 @@ import { GithubHttp } from "./GithubHttp" // `GithubAppError` is internal to the GitHub layer; `GithubProvider` maps it to // the generic `VcsProviderError` at the port boundary. -export class GithubAppError extends Data.TaggedError("@maple/api/vcs/GithubAppError")<{ - message: string - status?: number +export class GithubAppError extends Schema.TaggedError()("@maple/api/vcs/GithubAppError", { + message: Schema.String, + status: Schema.optionalKey(Schema.Number), // Which resource the failing call addressed, so the provider can tell an // installation-auth failure (the gone/suspended signal) from a repo-level one. - scope?: "installation" | "repository" + scope: Schema.optionalKey(Schema.Literals(["installation", "repository"])), // Set when the failure is a rate limit too far out to wait through inline: // seconds until the budget returns. The provider maps this to VcsRateLimitedError. - retryAfterSeconds?: number - cause?: unknown -}> {} + retryAfterSeconds: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), +}) {} const GITHUB_API_VERSION = "2022-11-28" const USER_AGENT = "maple-vcs-integration" diff --git a/apps/api/src/services/org/OrgMembersService.ts b/apps/api/src/services/org/OrgMembersService.ts index 1715620c2..9781fcaf9 100644 --- a/apps/api/src/services/org/OrgMembersService.ts +++ b/apps/api/src/services/org/OrgMembersService.ts @@ -1,14 +1,17 @@ import { createClerkClient } from "@clerk/backend" import type { OrgId } from "@maple/domain/http" -import { Context, Data, Effect, Layer, Option, Redacted } from "effect" +import { Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { Env } from "@/platform/Env" import { clerkRequest } from "@/services/auth/clerk-request" -export class OrgMembersError extends Data.TaggedError("@maple/api/services/OrgMembersError")<{ - readonly message: string - /** User ids the caller supplied that are not members of the org. */ - readonly unknownUserIds?: ReadonlyArray -}> {} +export class OrgMembersError extends Schema.TaggedError()( + "@maple/api/services/OrgMembersError", + { + message: Schema.String, + /** User ids the caller supplied that are not members of the org. */ + unknownUserIds: Schema.optionalKey(Schema.Array(Schema.String)), + }, +) {} export interface OrgMember { readonly userId: string diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts index 46e8a9ced..1d32e8311 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts @@ -3,6 +3,7 @@ import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Schema } from "effe import { WarehouseQueryError, WarehouseConfigError, + WarehouseConfigLookupError, MAX_RAW_SQL_RESULT_BYTES, WarehouseSchemaDriftError, WarehouseUpstreamError, @@ -253,7 +254,7 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { const cases = [ { source: new OrgClickHouseSettingsPersistenceError({ message: "database unavailable" }), - expected: WarehouseUpstreamError, + expected: WarehouseConfigLookupError, }, { source: new OrgClickHouseSettingsEncryptionError({ message: "decrypt failed" }), @@ -286,7 +287,7 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { const mapped = getError(exit) assert.instanceOf(mapped, expected) assert.strictEqual( - (mapped as WarehouseConfigError | WarehouseUpstreamError).cause, + (mapped as WarehouseConfigError | WarehouseConfigLookupError).cause, source, ) }).pipe(Effect.provide(layer)) diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.ts b/apps/api/src/services/warehouse/WarehouseQueryService.ts index a383a29a7..fd2536a9c 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.ts @@ -1,7 +1,11 @@ import { createClient as createClickHouseClient } from "@clickhouse/client-web" import { Tinybird } from "@tinybirdco/sdk" import { Context, Effect, Layer, Option, Redacted } from "effect" -import { WarehouseConfigError, WarehouseUpstreamError, type WarehouseQueryRequest } from "@maple/domain/http" +import { + WarehouseConfigError, + WarehouseConfigLookupError, + type WarehouseQueryRequest, +} from "@maple/domain/http" import { BackendDialect, makeWarehouseExecutor, @@ -349,25 +353,13 @@ export class WarehouseQueryService extends Context.Service< // already tenant-isolated). const override = yield* orgClickHouseSettings.resolveRuntimeConfig(tenant.orgId).pipe( Effect.catchTags({ - // A Postgres read of org_clickhouse_settings failed — not a warehouse - // outage. The 503 contract (WarehouseUpstreamError) is kept for - // clients, but the span carries the original tag so trace inspection - // can tell DB failures from genuine warehouse failures. (Span - // attributes don't reach error_events_mv — the error page still shows - // the re-tagged error; this discriminator is for trace search.) The - // sibling Encryption/Validation branches below stay unannotated on - // purpose: their WarehouseConfigError re-tag is not misleading. "@maple/http/errors/OrgClickHouseSettingsPersistenceError": (error) => - Effect.annotateCurrentSpan("warehouse.error.origin", error._tag).pipe( - Effect.andThen( - Effect.fail( - new WarehouseUpstreamError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), - ), + Effect.fail( + new WarehouseConfigLookupError({ + pipeName: label, + message: error.message, + cause: error, + }), ), "@maple/http/errors/OrgClickHouseSettingsEncryptionError": (error) => Effect.fail( diff --git a/apps/api/src/services/warehouse/warehouse-error-handlers.ts b/apps/api/src/services/warehouse/warehouse-error-handlers.ts index 2d015ce07..24c0d9509 100644 --- a/apps/api/src/services/warehouse/warehouse-error-handlers.ts +++ b/apps/api/src/services/warehouse/warehouse-error-handlers.ts @@ -15,6 +15,7 @@ export const warehouseHandlers = (f: (error: WarehouseError) => Effect. "@maple/http/errors/WarehouseUpstreamError": f, "@maple/http/errors/WarehouseAuthError": f, "@maple/http/errors/WarehouseConfigError": f, + "@maple/http/errors/WarehouseConfigLookupError": f, "@maple/http/errors/WarehouseClientError": f, "@maple/http/errors/WarehouseSchemaDriftError": f, "@maple/http/errors/WarehouseMalformedQueryError": f, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index d64845ea6..973c43004 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -14,6 +14,7 @@ import * as Etag from "effect/unstable/http/Etag" import * as HttpPlatform from "effect/unstable/http/HttpPlatform" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { serverErrorSpanMiddleware } from "./http/server-error-span" +import { v2WorkerUnavailableResponse } from "./http/v2-worker-unavailable" import { persistSession, preloadSession, type SessionsBinding } from "./mcp/lib/session-store" import { classifyWorkerQueue } from "./queue-dispatch" @@ -224,6 +225,14 @@ const isMcpPost = (request: Request): boolean => { } } +const isV2Request = (request: Request): boolean => { + try { + return new URL(request.url).pathname.startsWith("/v2/") + } catch { + return false + } +} + const readMcpSessionsBinding = (env: Record): SessionsBinding | undefined => { const candidate = env.MCP_SESSIONS if (candidate && typeof candidate === "object" && "get" in candidate && "put" in candidate) { @@ -336,7 +345,9 @@ const handle = async ( ) } ctx.waitUntil(flushTelemetry(env)) - return new Response("The API worker is temporarily unavailable.", { status: 504 }) + return isV2Request(request) + ? v2WorkerUnavailableResponse() + : new Response("The API worker is temporarily unavailable.", { status: 504 }) } } diff --git a/apps/web/src/lib/services/common/retry-policy.test.ts b/apps/web/src/lib/services/common/retry-policy.test.ts index 57b481d4b..ee362f0b0 100644 --- a/apps/web/src/lib/services/common/retry-policy.test.ts +++ b/apps/web/src/lib/services/common/retry-policy.test.ts @@ -63,6 +63,12 @@ describe("isRetryableResponse", () => { ).toBe(false) }) + it.each([500, 502, 503])("leaves v2 %s retry decisions to the decoded error body", (status) => { + expect( + isRetryableResponse(response(HttpClientRequest.get("https://api.maple.dev/v2/services"), status)), + ).toBe(false) + }) + it.live("replays a raw transient response before API error decoding", () => Effect.gen(function* () { let attempts = 0 diff --git a/apps/web/src/lib/services/common/retry-policy.ts b/apps/web/src/lib/services/common/retry-policy.ts index aa3c83088..0d7d729b2 100644 --- a/apps/web/src/lib/services/common/retry-policy.ts +++ b/apps/web/src/lib/services/common/retry-policy.ts @@ -22,9 +22,21 @@ export const isRetryableTransportError = (error: unknown): boolean => { return isIdempotentRequest(error.request) } -// 504 repeats expensive timed-out queries; 408/429 require visible pacing. +const isV2Request = (request: HttpClientRequest.HttpClientRequest): boolean => { + try { + return new URL(request.url).pathname.startsWith("/v2/") + } catch { + return false + } +} + +// v2 retryability lives in the decoded public error body. This transport layer +// cannot consume that body without stealing it from HttpApi decoding, so it +// never infers v2 retry behavior from status. Legacy response behavior remains +// unchanged until v1 is migrated separately. export const isRetryableResponse = (response: HttpClientResponse.HttpClientResponse): boolean => isIdempotentRequest(response.request) && + !isV2Request(response.request) && (response.status === 500 || response.status === 502 || response.status === 503) export const mapleRetrySchedule = Schedule.exponential("300 millis") diff --git a/packages/alchemy-maple/src/AlertDestination.ts b/packages/alchemy-maple/src/AlertDestination.ts index ff79addb7..4b8d1c544 100644 --- a/packages/alchemy-maple/src/AlertDestination.ts +++ b/packages/alchemy-maple/src/AlertDestination.ts @@ -147,7 +147,11 @@ export const AlertDestinationProvider = () => if (output?.destinationId) { const fetched = yield* api .get(`/v2/alerts/destinations/${output.destinationId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched !== undefined) observed = yield* decodeWireDestination(fetched) } @@ -172,13 +176,17 @@ export const AlertDestinationProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/alerts/destinations/${output.destinationId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + .pipe(Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => Effect.void)) }), read: Effect.fn(function* ({ output }) { if (!output?.destinationId) return undefined const fetched = yield* api .get(`/v2/alerts/destinations/${output.destinationId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched === undefined) return undefined return toAttributes(yield* decodeWireDestination(fetched)) }), diff --git a/packages/alchemy-maple/src/AlertRule.ts b/packages/alchemy-maple/src/AlertRule.ts index fc4e9c675..999a3a595 100644 --- a/packages/alchemy-maple/src/AlertRule.ts +++ b/packages/alchemy-maple/src/AlertRule.ts @@ -150,7 +150,11 @@ export const AlertRuleProvider = () => if (output?.ruleId) { observedRaw = yield* api .get(`/v2/alerts/rules/${output.ruleId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.succeed(undefined), + ), + ) } if (observedRaw === undefined) { const adopted = yield* findByName(news.name) @@ -171,13 +175,17 @@ export const AlertRuleProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/alerts/rules/${output.ruleId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + .pipe(Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => Effect.void)) }), read: Effect.fn(function* ({ olds, output }) { if (output?.ruleId) { const fetched = yield* api .get(`/v2/alerts/rules/${output.ruleId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched !== undefined) return toAttributes(yield* decodeWireRule(fetched)) } if (olds?.name !== undefined) { diff --git a/packages/alchemy-maple/src/ApiKey.ts b/packages/alchemy-maple/src/ApiKey.ts index 2ba9759a2..9acf75920 100644 --- a/packages/alchemy-maple/src/ApiKey.ts +++ b/packages/alchemy-maple/src/ApiKey.ts @@ -114,7 +114,11 @@ export const ApiKeyProvider = () => if (output?.keyId) { const fetched = yield* api .get(`/v2/api_keys/${output.keyId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/ApiKeyNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched !== undefined) { const wire = yield* decodeWireApiKey(fetched) if (!wire.revoked) observed = wire @@ -143,13 +147,17 @@ export const ApiKeyProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/api_keys/${output.keyId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + .pipe(Effect.catchTag("@maple/http/errors/ApiKeyNotFoundError", () => Effect.void)) }), read: Effect.fn(function* ({ output }) { if (!output?.keyId) return undefined const fetched = yield* api .get(`/v2/api_keys/${output.keyId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/ApiKeyNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched === undefined) return undefined const wire = yield* decodeWireApiKey(fetched) if (wire.revoked) return undefined diff --git a/packages/alchemy-maple/src/Dashboard.ts b/packages/alchemy-maple/src/Dashboard.ts index 7db57a7cb..89a52554e 100644 --- a/packages/alchemy-maple/src/Dashboard.ts +++ b/packages/alchemy-maple/src/Dashboard.ts @@ -90,9 +90,7 @@ const drifted = (props: DashboardProps, observed: Schema.Schema.Type), sections: observed.sections ?? [], } - return Object.keys(body).some( - (key) => !deepEqual(body[key], seen[key], { stripNullish: true }), - ) + return Object.keys(body).some((key) => !deepEqual(body[key], seen[key], { stripNullish: true })) } const toAttributes = (observed: Schema.Schema.Type) => ({ @@ -119,7 +117,11 @@ export const DashboardProvider = () => if (output?.dashboardId) { const fetched = yield* api .get(`/v2/dashboards/${output.dashboardId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/DashboardNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched !== undefined) observed = yield* decodeWireDashboard(fetched) } @@ -136,13 +138,17 @@ export const DashboardProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/dashboards/${output.dashboardId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + .pipe(Effect.catchTag("@maple/http/errors/DashboardNotFoundError", () => Effect.void)) }), read: Effect.fn(function* ({ output }) { if (!output?.dashboardId) return undefined const fetched = yield* api .get(`/v2/dashboards/${output.dashboardId}`) - .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + .pipe( + Effect.catchTag("@maple/http/errors/DashboardNotFoundError", () => + Effect.succeed(undefined), + ), + ) if (fetched === undefined) return undefined return toAttributes(yield* decodeWireDashboard(fetched)) }), diff --git a/packages/alchemy-maple/src/MapleApi.ts b/packages/alchemy-maple/src/MapleApi.ts index d8ce70b9f..30db0a6be 100644 --- a/packages/alchemy-maple/src/MapleApi.ts +++ b/packages/alchemy-maple/src/MapleApi.ts @@ -1,15 +1,17 @@ import * as Context from "effect/Context" +import * as Clock from "effect/Clock" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Redacted from "effect/Redacted" -import * as Schedule from "effect/Schedule" +import * as Schema from "effect/Schema" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { - MapleApiError, - MapleConflictError, - MapleNotFoundError, - MapleUnauthorizedError, + MapleApiClientError, + MaplePublicErrorBodySchema, + isMapleApiResponseError, + makeMapleApiResponseError, + type MapleApiResponseError, type MapleError, } from "./errors" import { MapleEnvironment } from "./MapleEnvironment" @@ -21,9 +23,8 @@ import { MapleEnvironment } from "./MapleEnvironment" * with zero runtime dependencies beyond `effect`. Responses are returned as * parsed JSON (`unknown`); each provider decodes just the fields it needs. * - * Known statuses map to typed errors (404 / 409 / 401·403); everything else - * non-2xx is a {@link MapleApiError}. 429s and 5xx are retried with bounded - * exponential backoff (the v2 API allows 600 requests per 60s per key). + * Declared non-2xx responses retain the server's complete public error body. + * Retry behavior comes from that body rather than being inferred from status. */ export interface MapleApiShape { readonly get: (path: string) => Effect.Effect @@ -34,100 +35,114 @@ export interface MapleApiShape { export class MapleApi extends Context.Service()("Maple::Api") {} +const ErrorEnvelope = Schema.Struct({ error: MaplePublicErrorBodySchema }) +const decodeErrorEnvelope = Schema.decodeUnknownSync(Schema.fromJsonString(ErrorEnvelope)) + const errorFromResponse = (status: number, bodyText: string): MapleError => { - let message = `Maple API request failed with status ${status}` - let errorType: string | undefined - let code: string | undefined try { - const parsed = JSON.parse(bodyText) as { error?: { type?: string; code?: string; message?: string } } - if (parsed?.error?.message) message = parsed.error.message - errorType = parsed?.error?.type - code = parsed?.error?.code - } catch { - if (bodyText.length > 0) message = `${message}: ${bodyText.slice(0, 200)}` - } - const fields = { - status, - message, - ...(errorType !== undefined ? { errorType } : {}), - ...(code !== undefined ? { code } : {}), + return makeMapleApiResponseError(status, decodeErrorEnvelope(bodyText).error) + } catch (cause) { + return new MapleApiClientError({ + status, + message: `Maple API returned an invalid error response with status ${status}`, + cause, + }) } - if (status === 404) return new MapleNotFoundError(fields) - if (status === 409) return new MapleConflictError(fields) - if (status === 401 || status === 403) return new MapleUnauthorizedError(fields) - return new MapleApiError(fields) } -const isRetryable = (error: MapleError): boolean => - error._tag === "Maple::ApiError" && (error.status === 429 || error.status >= 500) - -// Exponential backoff capped at 10s (`min` = fastest of the two), bounded to -// six recurrences (`max` = continue only while every schedule still recurs). -const retryPolicy = Schedule.max([ - Schedule.min([Schedule.exponential(Duration.millis(500), 2), Schedule.spaced(Duration.seconds(10))]), - Schedule.recurs(6), -]) +const retryDelay = Effect.fn("MapleApi.retryDelay")(function* ( + error: MapleApiResponseError, + attempt: number, +) { + if (error.error.retry_after_seconds !== undefined) { + return Duration.seconds(error.error.retry_after_seconds) + } + if (error.error.retry_at !== undefined) { + const retryAt = Date.parse(error.error.retry_at) + if (Number.isFinite(retryAt)) { + const now = yield* Clock.currentTimeMillis + return Duration.millis(Math.max(0, retryAt - now)) + } + } + return Duration.millis(Math.min(500 * 2 ** attempt, 10_000)) +}) export const make = Effect.gen(function* () { const { baseUrl, apiKey } = yield* MapleEnvironment const httpClient = yield* HttpClient.HttpClient - const request = (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown) => - Effect.gen(function* () { - let req = HttpClientRequest.make(method)(`${baseUrl}${path}`).pipe( - HttpClientRequest.setHeaders({ - Authorization: `Bearer ${Redacted.value(apiKey)}`, - Accept: "application/json", - }), - ) - if (body !== undefined) { - req = yield* HttpClientRequest.bodyJson(req, body).pipe( + const request = (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown) => { + const canAutomaticallyRetry = method === "GET" || method === "DELETE" + const execute: (attempt: number) => Effect.Effect = Effect.fn( + "MapleApi.requestAttempt", + )(function* (attempt: number) { + return yield* Effect.gen(function* () { + let req = HttpClientRequest.make(method)(`${baseUrl}${path}`).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${Redacted.value(apiKey)}`, + Accept: "application/json", + }), + ) + if (body !== undefined) { + req = yield* HttpClientRequest.bodyJson(req, body).pipe( + Effect.mapError( + (error) => + new MapleApiClientError({ + status: 0, + message: `Failed to encode request body: ${String(error)}`, + cause: error, + }), + ), + ) + } + const response = yield* httpClient.execute(req).pipe( Effect.mapError( (error) => - new MapleApiError({ + new MapleApiClientError({ status: 0, - message: `Failed to encode request body: ${String(error)}`, + message: `Maple API request failed: ${error.message}`, + cause: error, }), ), ) - } - const response = yield* httpClient.execute(req).pipe( - Effect.mapError( - (error) => - new MapleApiError({ - status: 0, - message: `Maple API request failed: ${error.message}`, - }), - ), - ) - // Drain the body either way so the connection is released. - const text = yield* response.text.pipe( - Effect.mapError( - (error) => - new MapleApiError({ - status: response.status, - message: `Failed to read response: ${error.message}`, - }), + // Drain the body either way so the connection is released. + const text = yield* response.text.pipe( + Effect.mapError( + (error) => + new MapleApiClientError({ + status: response.status, + message: `Failed to read response: ${error.message}`, + cause: error, + }), + ), + ) + if (response.status >= 200 && response.status < 300) { + if (text.length === 0) return undefined as unknown + return yield* Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (cause) => + new MapleApiClientError({ + status: response.status, + message: `Maple API returned invalid JSON (status ${response.status})`, + cause, + }), + }) + } + return yield* Effect.fail(errorFromResponse(response.status, text)) + }).pipe( + Effect.catchIf(isMapleApiResponseError, (error) => + canAutomaticallyRetry && error.error.retryable && attempt < 6 + ? retryDelay(error, attempt).pipe( + Effect.flatMap((delay) => Effect.sleep(delay)), + Effect.andThen(execute(attempt + 1)), + ) + : Effect.fail(error), ), ) - if (response.status >= 200 && response.status < 300) { - if (text.length === 0) return undefined as unknown - return yield* Effect.try({ - try: () => JSON.parse(text) as unknown, - catch: () => - new MapleApiError({ - status: response.status, - message: `Maple API returned invalid JSON (status ${response.status})`, - }), - }) - } - return yield* Effect.fail(errorFromResponse(response.status, text)) - }).pipe( - Effect.retry({ - schedule: retryPolicy, - while: isRetryable, - }), - ) + }) + + return execute(0) + } return { get: (path: string) => request("GET", path), diff --git a/packages/alchemy-maple/src/errors.ts b/packages/alchemy-maple/src/errors.ts index 33419562a..48170bb19 100644 --- a/packages/alchemy-maple/src/errors.ts +++ b/packages/alchemy-maple/src/errors.ts @@ -1,36 +1,94 @@ import { Schema } from "effect" -/** - * Typed failures surfaced by the Maple API client. The v2 error envelope is - * `{ error: { type, code, message, param? } }`; the client maps well-known - * statuses to dedicated tags so providers can `catchTag` (404 → adopt/recreate, - * 409 → adopt-by-name) and leaves everything else on `MapleApiError`. - */ - -const errorFields = { - status: Schema.Number, +export const MaplePublicErrorType = Schema.Literals([ + "invalid_request_error", + "authentication_error", + "permission_error", + "not_found_error", + "conflict_error", + "rate_limit_error", + "api_error", +]) + +export const MapleErrorRecovery = Schema.Literals([ + "none", + "fix_request", + "reauthenticate", + "request_access", + "reconnect", + "refresh", + "retry", + "contact_support", +]) + +/** Published mirror of Maple's canonical v2 error body. Kept honest by the domain contract test. */ +export const MaplePublicErrorBodySchema = Schema.Struct({ + _tag: Schema.String.check(Schema.isPattern(/^@maple\//)), + type: MaplePublicErrorType, + code: Schema.String, + title: Schema.String, message: Schema.String, - /** The v2 envelope `error.type`, when the body carried one. */ - errorType: Schema.optionalKey(Schema.String), - /** The v2 envelope `error.code`, when the body carried one. */ - code: Schema.optionalKey(Schema.String), + retryable: Schema.Boolean, + recovery: MapleErrorRecovery, + 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 MaplePublicErrorBody = Schema.Schema.Type + +/** A declared v2 API failure whose Effect tag is the exact server tag. */ +export interface MapleApiResponseError extends Error { + readonly _tag: Tag + readonly status: number + readonly error: MaplePublicErrorBody & { readonly _tag: Tag } } -export class MapleApiError extends Schema.TaggedError()("Maple::ApiError", errorFields) {} +type MapleApiResponseErrorConstructor = new (fields: { + readonly status: number + readonly error: MaplePublicErrorBody +}) => MapleApiResponseError -export class MapleNotFoundError extends Schema.TaggedError()( - "Maple::NotFoundError", - errorFields, -) {} +const responseErrorClasses = new Map() -export class MapleConflictError extends Schema.TaggedError()( - "Maple::ConflictError", - errorFields, -) {} +/** Build a real Schema.TaggedError class per public server tag, cached for reuse. */ +export const makeMapleApiResponseError = ( + status: number, + error: MaplePublicErrorBody, +): MapleApiResponseError => { + let ErrorClass = responseErrorClasses.get(error._tag) + if (ErrorClass === undefined) { + class TaggedResponseError extends Schema.TaggedError()(error._tag, { + status: Schema.Number, + error: MaplePublicErrorBodySchema, + }) { + override get message(): string { + return this.error.message + } + } + ErrorClass = TaggedResponseError + responseErrorClasses.set(error._tag, ErrorClass) + } + return new ErrorClass({ status, error }) +} -export class MapleUnauthorizedError extends Schema.TaggedError()( - "Maple::UnauthorizedError", - errorFields, +/** A client-side transport, encoding, body-read, or protocol failure. */ +export class MapleApiClientError extends Schema.TaggedError()( + "@maple/alchemy/errors/ApiClientError", + { + status: Schema.Number, + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, ) {} -export type MapleError = MapleApiError | MapleNotFoundError | MapleConflictError | MapleUnauthorizedError +export type MapleError = MapleApiResponseError | MapleApiClientError + +export const isMapleApiResponseError = (error: MapleError): error is MapleApiResponseError => + error._tag !== "@maple/alchemy/errors/ApiClientError" diff --git a/packages/alchemy-maple/src/index.ts b/packages/alchemy-maple/src/index.ts index 4136badd3..55166d92f 100644 --- a/packages/alchemy-maple/src/index.ts +++ b/packages/alchemy-maple/src/index.ts @@ -43,11 +43,15 @@ export { export { ApiKey, ApiKeyProvider, type ApiKeyProps } from "./ApiKey" export { Dashboard, DashboardProvider, type DashboardProps } from "./Dashboard" export { - MapleApiError, - MapleConflictError, - MapleNotFoundError, - MapleUnauthorizedError, + isMapleApiResponseError, + makeMapleApiResponseError, + MapleApiClientError, + MapleErrorRecovery, + MaplePublicErrorBodySchema, + MaplePublicErrorType, type MapleError, + type MapleApiResponseError, + type MaplePublicErrorBody, } from "./errors" export { IngestKeys, IngestKeysProvider, type IngestKeysProps } from "./IngestKeys" export { listAll, MapleApi, MapleApiFromHttpClient, MapleApiLive, type MapleApiShape } from "./MapleApi" diff --git a/packages/alchemy-maple/test/contract.test.ts b/packages/alchemy-maple/test/contract.test.ts index b043af939..82cc2e78b 100644 --- a/packages/alchemy-maple/test/contract.test.ts +++ b/packages/alchemy-maple/test/contract.test.ts @@ -5,21 +5,35 @@ */ import { describe, expect, it } from "vitest" import { Effect, Schema } from "effect" +import { PublicHttpErrorBodySchema, type AnyPublicHttpErrorBody } from "@maple/domain/http" import { V2AlertDestinationCreateParams, V2AlertRuleCreateParams, V2ApiKeyCreateParams, + V2InvalidRequest, V2DashboardCreateParams, } from "@maple/domain/http/v2" import { _alertDestinationCreateBody } from "../src/AlertDestination" import { _alertRuleCreateBody } from "../src/AlertRule" import { _apiKeyCreateBody } from "../src/ApiKey" import { _dashboardCreateBody } from "../src/Dashboard" +import { MaplePublicErrorBodySchema, type MaplePublicErrorBody } from "../src/errors" + +const _clientErrorBodySatisfiesDomain = (body: MaplePublicErrorBody): AnyPublicHttpErrorBody => body +const _domainErrorBodySatisfiesClient = (body: AnyPublicHttpErrorBody): MaplePublicErrorBody => body +void _clientErrorBodySatisfiesDomain +void _domainErrorBodySatisfiesClient const decodes = >(schema: S, wire: unknown) => Effect.runSync(Schema.decodeUnknownEffect(schema)(wire).pipe(Effect.asVoid)) describe("provider request bodies decode against the real v2 create-param schemas", () => { + it("public error body", () => { + const body = V2InvalidRequest.make().error + expect(() => decodes(PublicHttpErrorBodySchema, body)).not.toThrow() + expect(() => decodes(MaplePublicErrorBodySchema, body)).not.toThrow() + }) + it("dashboard create body", () => { const body = _dashboardCreateBody({ name: "Service health", diff --git a/packages/alchemy-maple/test/maple-api.test.ts b/packages/alchemy-maple/test/maple-api.test.ts new file mode 100644 index 000000000..3d43e17f0 --- /dev/null +++ b/packages/alchemy-maple/test/maple-api.test.ts @@ -0,0 +1,151 @@ +import { Duration, Effect, Fiber, Layer, Redacted } from "effect" +import { TestClock } from "effect/testing" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { describe, expect, it } from "@effect/vitest" +import { MapleApi, MapleApiFromHttpClient } from "../src/MapleApi" +import { isMapleApiResponseError } from "../src/errors" +import { MapleEnvironment } from "../src/MapleEnvironment" + +const environment = Layer.succeed(MapleEnvironment, { + baseUrl: "https://maple.test", + apiKey: Redacted.make("maple_ak_test"), +}) + +const errorEnvelope = (overrides: { + readonly retryable: boolean + readonly retry_after_seconds?: number + readonly retry_at?: string +}) => ({ + error: { + _tag: "@maple/http/errors/ApiKeyNotFoundError", + type: "not_found_error", + code: "api_key_not_found", + title: "API key not found", + message: "No such API key.", + recovery: "none", + ...overrides, + }, +}) + +const clientLayer = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, respond(request)))), + ) +describe("MapleApi errors", () => { + it.effect("preserves the complete semantic error body", () => { + const http = clientLayer( + () => + new Response(JSON.stringify(errorEnvelope({ retryable: false })), { + status: 404, + headers: { "content-type": "application/json" }, + }), + ) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.get("/v2/api_keys/key_missing")) + expect(isMapleApiResponseError(error)).toBe(true) + if (!isMapleApiResponseError(error)) return + expect(error.status).toBe(404) + expect(error._tag).toBe("@maple/http/errors/ApiKeyNotFoundError") + expect(error.error).toEqual(errorEnvelope({ retryable: false }).error) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("retries only when the public body says to retry", () => { + let attempts = 0 + const http = clientLayer(() => { + attempts += 1 + return attempts === 1 + ? new Response( + JSON.stringify( + errorEnvelope({ retryable: true, retry_at: "1970-01-01T00:00:00.000Z" }), + ), + { status: 503, headers: { "content-type": "application/json" } }, + ) + : new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) + return Effect.gen(function* () { + const api = yield* MapleApi + expect(yield* api.get("/v2/api_keys/key_retry")).toEqual({ ok: true }) + expect(attempts).toBe(2) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("does not automatically replay a retryable mutation", () => { + let attempts = 0 + const http = clientLayer(() => { + attempts += 1 + return new Response(JSON.stringify(errorEnvelope({ retryable: true })), { + status: 503, + headers: { "content-type": "application/json" }, + }) + }) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.post("/v2/api_keys", { name: "ci" })) + expect(isMapleApiResponseError(error)).toBe(true) + expect(attempts).toBe(1) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("uses retry_after_seconds before retry_at", () => { + let attempts = 0 + const http = clientLayer(() => { + attempts += 1 + return attempts === 1 + ? new Response( + JSON.stringify( + errorEnvelope({ + retryable: true, + retry_after_seconds: 5, + retry_at: "1970-01-01T00:00:02.000Z", + }), + ), + { status: 503, headers: { "content-type": "application/json" } }, + ) + : new Response(JSON.stringify({ ok: true }), { status: 200 }) + }) + return Effect.gen(function* () { + const api = yield* MapleApi + const fiber = yield* Effect.forkChild(api.get("/v2/api_keys/key_retry")) + yield* TestClock.adjust(Duration.zero) + expect(attempts).toBe(1) + yield* TestClock.adjust(Duration.seconds(2)) + expect(attempts).toBe(1) + yield* TestClock.adjust(Duration.seconds(3)) + expect(yield* Fiber.join(fiber)).toEqual({ ok: true }) + expect(attempts).toBe(2) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("stops after six retries", () => { + let attempts = 0 + const http = clientLayer(() => { + attempts += 1 + return new Response( + JSON.stringify(errorEnvelope({ retryable: true, retry_at: "1970-01-01T00:00:00.000Z" })), + { status: 503, headers: { "content-type": "application/json" } }, + ) + }) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.get("/v2/api_keys/key_retry")) + expect(isMapleApiResponseError(error)).toBe(true) + expect(attempts).toBe(7) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) +}) diff --git a/packages/alchemy-maple/test/providers.test.ts b/packages/alchemy-maple/test/providers.test.ts index af17b56db..f5ba2b3e8 100644 --- a/packages/alchemy-maple/test/providers.test.ts +++ b/packages/alchemy-maple/test/providers.test.ts @@ -5,7 +5,18 @@ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli" import { ApiKey, ApiKeyProvider } from "../src/ApiKey" import { Dashboard, DashboardProvider } from "../src/Dashboard" import { MapleApi, type MapleApiShape } from "../src/MapleApi" -import { MapleNotFoundError, type MapleError } from "../src/errors" +import { makeMapleApiResponseError, type MapleError } from "../src/errors" + +const missingRoute = (message: string) => + makeMapleApiResponseError(404, { + _tag: "@maple/http/errors/DashboardNotFoundError", + type: "not_found_error", + code: "resource_missing", + title: "Not found", + message, + retryable: false, + recovery: "none", + }) /** In-memory stub of the v2 API: canned responses + a call log. */ const makeStub = ( @@ -16,9 +27,7 @@ const makeStub = ( calls.push(`${method} ${path}`) const handler = routes[`${method} ${path}`] if (handler === undefined) { - return Effect.fail( - new MapleNotFoundError({ status: 404, message: `no route: ${method} ${path}` }), - ) + return Effect.fail(missingRoute(`no route: ${method} ${path}`)) } return handler(body) } diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 384767cd8..b96e2c0c4 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -1,20 +1,37 @@ import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" import { Schema, Context as EffectContext } from "effect" import { AuthMode, OrgId, RoleName, UserId } from "../primitives" +import { HttpTaggedError } from "./error-policy" -export class UnauthorizedError extends Schema.TaggedError()( +export class UnauthorizedError extends HttpTaggedError()( "@maple/http/errors/UnauthorizedError", { message: Schema.String, }, - { httpApiStatus: 401 }, + { + status: 401, + code: "invalid_credentials", + title: "Sign in required", + message: "Invalid or missing credentials.", + retry: "never", + recovery: "reauthenticate", + exposure: "redacted", + }, ) {} /** Credential storage could not be consulted; this is not an invalid token. */ -export class AuthorizationUnavailableError extends Schema.TaggedError()( +export class AuthorizationUnavailableError extends HttpTaggedError()( "@maple/http/errors/AuthorizationUnavailableError", { message: Schema.String }, - { httpApiStatus: 503 }, + { + status: 503, + code: "authorization_unavailable", + title: "Authentication is temporarily unavailable", + message: "Authentication is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} export class TenantSchema extends Schema.Class("TenantSchema")({ diff --git a/packages/domain/src/http/error-policy.ts b/packages/domain/src/http/error-policy.ts index 9d493fb9f..f0fde7ce1 100644 --- a/packages/domain/src/http/error-policy.ts +++ b/packages/domain/src/http/error-policy.ts @@ -57,26 +57,61 @@ export interface PublicHttpErrorBody 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." }), ), - param: Schema.optionalKey(Schema.String), - doc_url: Schema.optionalKey(Schema.String), -}) + doc_url: Schema.optionalKey( + Schema.String.annotate({ description: "Reference documentation for this failure." }), + ), +} + +/** + * Build the one public error body contract with either broad or literal tag/type schemas. + * Runtime decoding and endpoint-specific OpenAPI branches share these fields so they cannot drift. + */ +export const makePublicHttpErrorBodySchema = ( + tag: Schema.Codec, + type: Schema.Codec, +) => + Schema.Struct({ + _tag: tag, + type, + ...publicHttpErrorBodyFields, + }) + +/** Runtime contract for a public error body when its exact tag/status are not known statically. */ +export const PublicHttpErrorBodySchema = makePublicHttpErrorBodySchema( + Schema.String.check(Schema.isPattern(/^@maple\//)), + PublicHttpErrorType, +) export type AnyPublicHttpErrorBody = Schema.Schema.Type type ErrorValue = Value | ((error: Error) => Value) diff --git a/packages/domain/src/http/org-clickhouse-settings.ts b/packages/domain/src/http/org-clickhouse-settings.ts index 01de01db3..a16a4a156 100644 --- a/packages/domain/src/http/org-clickhouse-settings.ts +++ b/packages/domain/src/http/org-clickhouse-settings.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" import { IsoDateTimeString } from "../primitives" /** @@ -196,46 +197,91 @@ export class OrgClickHouseCollectorConfigResponse extends Schema.Class()( +export class OrgClickHouseSettingsForbiddenError extends HttpTaggedError()( "@maple/http/errors/OrgClickHouseSettingsForbiddenError", { message: Schema.String }, - { httpApiStatus: 403 }, + { + status: 403, + code: "clickhouse_settings_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} -export class OrgClickHouseSettingsValidationError extends Schema.TaggedError()( +export class OrgClickHouseSettingsValidationError extends HttpTaggedError()( "@maple/http/errors/OrgClickHouseSettingsValidationError", { message: Schema.String }, - { httpApiStatus: 400 }, + { + status: 400, + code: "clickhouse_settings_invalid", + title: "Invalid ClickHouse settings", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, ) {} -export class OrgClickHouseSettingsPersistenceError extends Schema.TaggedError()( +export class OrgClickHouseSettingsPersistenceError extends HttpTaggedError()( "@maple/http/errors/OrgClickHouseSettingsPersistenceError", { message: Schema.String }, - { httpApiStatus: 503 }, + { + status: 503, + code: "clickhouse_settings_unavailable", + title: "ClickHouse settings are temporarily unavailable", + message: "ClickHouse settings are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class OrgClickHouseSettingsEncryptionError extends Schema.TaggedError()( +export class OrgClickHouseSettingsEncryptionError extends HttpTaggedError()( "@maple/http/errors/OrgClickHouseSettingsEncryptionError", { message: Schema.String }, - { httpApiStatus: 500 }, + { + status: 500, + code: "clickhouse_settings_encryption_failed", + title: "Maple could not read these settings", + message: "Maple could not securely read the saved ClickHouse settings.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, ) {} -export class OrgClickHouseSettingsUpstreamRejectedError extends Schema.TaggedError()( +export class OrgClickHouseSettingsUpstreamRejectedError extends HttpTaggedError()( "@maple/http/errors/OrgClickHouseSettingsUpstreamRejectedError", { message: Schema.String, statusCode: Schema.NullOr(Schema.Number), }, - { httpApiStatus: 400 }, + { + status: 400, + code: "clickhouse_connection_rejected", + title: "ClickHouse rejected the connection", + retry: "never", + recovery: "reconnect", + exposure: "public_message", + }, ) {} -export class OrgClickHouseSettingsUpstreamUnavailableError extends Schema.TaggedError()( +export class OrgClickHouseSettingsUpstreamUnavailableError extends HttpTaggedError()( "@maple/http/errors/OrgClickHouseSettingsUpstreamUnavailableError", { message: Schema.String, statusCode: Schema.NullOr(Schema.Number), }, - { httpApiStatus: 503 }, + { + status: 503, + code: "clickhouse_connection_unavailable", + title: "ClickHouse is temporarily unavailable", + message: "The configured ClickHouse service is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} export class OrgClickHouseSettingsApiGroup extends HttpApiGroup.make("orgClickHouseSettings") diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts index b6adf2190..b64992bae 100644 --- a/packages/domain/src/http/v2/alert-rules.ts +++ b/packages/domain/src/http/v2/alert-rules.ts @@ -22,7 +22,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicErrors } from "./public-error" -import { V2WarehouseErrors } from "./query-errors" +import { V2ManagedWarehouseErrors, V2WarehouseErrors } from "./query-errors" import { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" export { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" @@ -825,7 +825,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, query: ChecksQuery, success: AlertCheckList, - error: [V2ParameterInvalid.schema, alertPersistence, alertNotFound], + error: [V2ParameterInvalid.schema, alertPersistence, alertNotFound, ...V2ManagedWarehouseErrors], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertRuleChecks", @@ -840,7 +840,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, query: AlertCheckSummaryQuery, success: AlertCheckSummary, - error: [alertValidation, alertPersistence, alertNotFound], + error: [alertValidation, alertPersistence, alertNotFound, ...V2ManagedWarehouseErrors], }).annotateMerge( OpenApi.annotations({ identifier: "summarizeAlertRuleChecks", diff --git a/packages/domain/src/http/v2/anomalies.ts b/packages/domain/src/http/v2/anomalies.ts index 1b6d29beb..2ea307e52 100644 --- a/packages/domain/src/http/v2/anomalies.ts +++ b/packages/domain/src/http/v2/anomalies.ts @@ -21,6 +21,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError, publicErrors } from "./public-error" +import { V2WarehouseErrors } from "./query-errors" import { AnomalyIncidentPublicId, ErrorIssuePublicId } from "./resource-ids" export { AnomalyIncidentPublicId } from "./resource-ids" @@ -332,7 +333,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") params: { id: AnomalyIncidentPublicId }, query: V2AnomalyTimeseriesQuery, success: V2AnomalyIncidentTimeseries, - error: [anomalyPersistence, anomalyNotFound], + error: [anomalyPersistence, anomalyNotFound, ...V2WarehouseErrors], }).annotateMerge( OpenApi.annotations({ identifier: "getAnomalyIncidentTimeseries", diff --git a/packages/domain/src/http/v2/api-keys.ts b/packages/domain/src/http/v2/api-keys.ts index cd4cdb56a..f7e16ba5e 100644 --- a/packages/domain/src/http/v2/api-keys.ts +++ b/packages/domain/src/http/v2/api-keys.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ApiKeyId, PostgresTransactionId, UserId } from "../../primitives" -import { ApiKeyForbiddenError, ApiKeyKind, ApiKeyNotFoundError, ApiKeyPersistenceError } from "../api-keys" +import { ApiKeyKind, ApiKeyNotFoundError, ApiKeyPersistenceError } from "../api-keys" import { AuthorizationV2, V2Scope } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2InsufficientPermissions, V2ParameterInvalid } from "./errors" @@ -197,7 +197,6 @@ export const V2ApiKeyCreateParams = Schema.Struct({ }) export type V2ApiKeyCreateParams = Schema.Schema.Type -const apiKeyForbidden = publicError(ApiKeyForbiddenError) const apiKeyNotFound = publicError(ApiKeyNotFoundError) const apiKeyPersistence = publicError(ApiKeyPersistenceError) @@ -227,7 +226,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.post("create", "/", { payload: V2ApiKeyCreateParams, success: V2ApiKeyWithSecret, - error: [V2InsufficientPermissions.schema, apiKeyForbidden, apiKeyPersistence], + error: [V2InsufficientPermissions.schema, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "createApiKey", @@ -255,7 +254,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.post("roll", "/:id/roll", { params: { id: ApiKeyPublicId }, success: V2ApiKeyWithSecret, - error: [V2InsufficientPermissions.schema, apiKeyForbidden, apiKeyNotFound, apiKeyPersistence], + error: [V2InsufficientPermissions.schema, apiKeyNotFound, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "rollApiKey", @@ -269,7 +268,7 @@ export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") HttpApiEndpoint.delete("revoke", "/:id", { params: { id: ApiKeyPublicId }, success: V2ApiKeyMutationResponse, - error: [V2InsufficientPermissions.schema, apiKeyForbidden, apiKeyNotFound, apiKeyPersistence], + error: [V2InsufficientPermissions.schema, apiKeyNotFound, apiKeyPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "revokeApiKey", diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index 3cc629af6..40d0875fd 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -1,7 +1,7 @@ import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ApiKeyLookupPersistenceError } from "../api-keys" -import { Context } from "../current-tenant" +import { Context, UnauthorizedError } from "../current-tenant" import { V2InsufficientScope, V2InvalidCredentials, @@ -9,6 +9,7 @@ import { V2RateLimited, V2ResponseSchemaFailure, V2UnexpectedFailure, + V2WorkerUnavailable, } from "./errors" import { publicError } from "./public-error" @@ -31,6 +32,7 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< V2InsufficientScope.schema, V2RateLimited.schema, publicError(ApiKeyLookupPersistenceError), + publicError(UnauthorizedError), ], security: { bearer: HttpApiSecurity.bearer.pipe( @@ -48,7 +50,7 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< /** Converts unexpected route defects into the public v2 API-error envelope. */ export class V2UnexpectedErrors extends HttpApiMiddleware.Service()( "V2UnexpectedErrors", - { error: V2UnexpectedFailure.schema }, + { error: [V2UnexpectedFailure.schema, V2WorkerUnavailable.schema] }, ) {} /** diff --git a/packages/domain/src/http/v2/error-issues.ts b/packages/domain/src/http/v2/error-issues.ts index a9474d785..16d460d87 100644 --- a/packages/domain/src/http/v2/error-issues.ts +++ b/packages/domain/src/http/v2/error-issues.ts @@ -14,6 +14,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2CursorInvalid, V2CursorSortMismatch } from "./errors" import { publicErrors } from "./public-error" +import { V2WarehouseErrors } from "./query-errors" import { ActorPublicId, ErrorIncidentPublicId, ErrorIssuePublicId } from "./resource-ids" export const V2ErrorIssueActor = Schema.Struct({ @@ -165,7 +166,12 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") HttpApiEndpoint.get("list", "/", { query: V2ErrorIssueListQuery, success: ErrorIssueList, - error: [V2CursorInvalid.schema, V2CursorSortMismatch.schema, errorPersistence], + error: [ + V2CursorInvalid.schema, + V2CursorSortMismatch.schema, + errorPersistence, + ...V2WarehouseErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "listErrorIssues", @@ -194,7 +200,7 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") params: { id: ErrorIssuePublicId }, query: V2ErrorIssueDetailQuery, success: V2ErrorIssueDetail, - error: [errorIssueNotFound, errorPersistence], + error: [errorIssueNotFound, errorPersistence, ...V2WarehouseErrors], }).annotateMerge( OpenApi.annotations({ identifier: "getErrorIssue", diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts index cc2186d03..e2b607d72 100644 --- a/packages/domain/src/http/v2/errors.ts +++ b/packages/domain/src/http/v2/errors.ts @@ -2,669 +2,28 @@ import { Schema } from "effect" import { HttpErrorRecovery, PublicHttpErrorType, + makePublicHttpErrorBodySchema, publicHttpErrorTypeForStatus, + type PublicHttpErrorBody, type PublicHttpErrorStatus, - type PublicHttpErrorTypeForStatus, } from "../error-policy" /** - * v2 error envelope (see docs/api-v2.md): every error response body is - * `{ "error": { "_tag", "type", "code", "title", "message", ... } }` with a - * closed set of `type`s, a stable semantic error tag, and a stable public code. - * - * 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 errors expose their original tag - * directly; errors born at the v2 boundary derive one from their stable code. + * Every v2 failure uses the same public body. Endpoint schemas narrow `_tag` + * and `type` to literals; the runtime value preserves that body unchanged. */ - export const V2ErrorType = PublicHttpErrorType export type V2ErrorType = Schema.Schema.Type export const V2ErrorRecovery = HttpErrorRecovery export type V2ErrorRecovery = Schema.Schema.Type -export type V2ErrorForStatus = 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 = PublicHttpErrorTypeForStatus - -export interface V2PublicError { - 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 interface V2PublicError { + readonly error: PublicHttpErrorBody } export const errorTypeForStatus = publicHttpErrorTypeForStatus -/** Presentation/recovery metadata shared by every v2 error constructor. */ -export interface V2ErrorMetadata { - /** Stable semantic identity for errors created at the v2 boundary. */ - readonly tag?: string - readonly title?: string - readonly retryable?: boolean - readonly recovery?: V2ErrorRecovery - readonly retryAfterSeconds?: number - readonly retryAt?: string -} - -interface ErrorExample { - readonly code: string - readonly message: string - readonly param?: string -} - -const errorBodyFields = (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", - }), - ).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"], - }), - ), -}) - -const errorBody = (type: T, example: ErrorExample) => - Schema.Struct({ - _tag: Schema.String.check(Schema.isPattern(/^@maple\//)).annotate({ - description: - "Stable semantic error tag. Branch on this for the exact failure; new tags may be added without changing the envelope shape.", - }), - ...errorBodyFields(type, example), - }) - -const defaultTitle: Record = { - invalid_request_error: "Invalid request", - authentication_error: "Sign in required", - permission_error: "Permission required", - not_found_error: "Not found", - conflict_error: "Could not save changes", - rate_limit_error: "Too many requests", - api_error: "Maple could not complete the request", -} - -const defaultRecovery: Record< - V2ErrorType, - { readonly retryable: boolean; readonly recovery: V2ErrorRecovery } -> = { - invalid_request_error: { retryable: false, recovery: "fix_request" }, - authentication_error: { retryable: false, recovery: "reauthenticate" }, - permission_error: { retryable: false, recovery: "request_access" }, - not_found_error: { retryable: false, recovery: "none" }, - conflict_error: { retryable: false, recovery: "refresh" }, - rate_limit_error: { retryable: true, recovery: "retry" }, - api_error: { retryable: false, recovery: "contact_support" }, -} - -const errorMetadata = ( - type: V2ErrorType, - code: string, - defaults: V2ErrorMetadata = {}, - overrides: V2ErrorMetadata = {}, -) => { - const metadata = { ...defaults, ...overrides } - return { - _tag: metadata.tag ?? `@maple/http/v2/${code}`, - title: metadata.title ?? defaultTitle[type], - retryable: metadata.retryable ?? defaultRecovery[type].retryable, - recovery: metadata.recovery ?? defaultRecovery[type].recovery, - ...(metadata.retryAfterSeconds === undefined - ? {} - : { retry_after_seconds: metadata.retryAfterSeconds }), - ...(metadata.retryAt === undefined ? {} : { retry_at: metadata.retryAt }), - } -} - -export class V2InvalidRequestError extends Schema.Error( - "@maple/http/v2/InvalidRequestError", -)( - Schema.Struct({ - error: errorBody("invalid_request_error", { - code: "parameter_invalid", - message: "Invalid request query: limit must be between 1 and 100.", - param: "limit", - }), - }).annotate({ identifier: "InvalidRequestError" }), - { - httpApiStatus: 400, - identifier: "InvalidRequestError", - title: "Invalid request error", - description: - "The request was malformed — a parameter is missing, of the wrong type, or out of range. HTTP 400.", - }, -) { - override get message(): string { - return this.error.message - } -} - -export class V2AuthenticationError extends Schema.Error( - "@maple/http/v2/AuthenticationError", -)( - Schema.Struct({ - error: errorBody("authentication_error", { - code: "invalid_credentials", - message: "Invalid or missing credentials.", - }), - }).annotate({ identifier: "AuthenticationError" }), - { - httpApiStatus: 401, - identifier: "AuthenticationError", - title: "Authentication error", - description: "The Bearer token is missing, malformed, or invalid. HTTP 401.", - }, -) { - override get message(): string { - return this.error.message - } -} - -export class V2PermissionError extends Schema.Error("@maple/http/v2/PermissionError")( - Schema.Struct({ - error: errorBody("permission_error", { - code: "insufficient_scope", - message: 'This API key does not have the "api_keys:write" scope required for this request.', - }), - }).annotate({ identifier: "PermissionError" }), - { - httpApiStatus: 403, - identifier: "PermissionError", - title: "Permission error", - description: - "The credentials are valid but lack the required scope or org role for this operation. HTTP 403.", - }, -) { - override get message(): string { - return this.error.message - } -} - -export class V2NotFoundError extends Schema.Error("@maple/http/v2/NotFoundError")( - Schema.Struct({ - error: errorBody("not_found_error", { - code: "api_key_not_found", - message: "No such api_key.", - param: "id", - }), - }).annotate({ identifier: "NotFoundError" }), - { - httpApiStatus: 404, - identifier: "NotFoundError", - title: "Not found error", - description: "No object exists for the given ID. HTTP 404.", - }, -) { - override get message(): string { - return this.error.message - } -} - -export class V2ConflictError extends Schema.Error("@maple/http/v2/ConflictError")( - Schema.Struct({ - error: errorBody("conflict_error", { - code: "resource_conflict", - message: "The object was modified concurrently; retry the request.", - }), - }).annotate({ identifier: "ConflictError" }), - { - httpApiStatus: 409, - identifier: "ConflictError", - title: "Conflict error", - description: "The request conflicts with the current state of the object. HTTP 409.", - }, -) { - override get message(): string { - return this.error.message - } -} - -/** - * The request asked for more data than one response may carry. - * - * Reuses the `invalid_request_error` type — the closed enum stays closed — but - * keeps 413 so the distinction from an ordinary 400 survives: nothing about the - * request is malformed, the window is simply too wide. It is a statement about - * the size of the answer, so retrying it unchanged can only fail identically; - * the message says what to narrow. - */ -export class V2PayloadTooLargeError extends Schema.Error( - "@maple/http/v2/PayloadTooLargeError", -)( - Schema.Struct({ - error: errorBody("invalid_request_error", { - code: "range_too_large", - message: - "That part of the recording is too large to load in one request. Request a narrower chunk range.", - param: "to_chunk_seq", - }), - }).annotate({ identifier: "PayloadTooLargeError" }), - { - httpApiStatus: 413, - identifier: "PayloadTooLargeError", - title: "Payload too large error", - description: - "The requested range would exceed the endpoint's response budget. Narrow the range and retry. HTTP 413.", - }, -) { - override get message(): string { - return this.error.message - } -} - -export class V2RateLimitError extends Schema.Error("@maple/http/v2/RateLimitError")( - Schema.Struct({ - error: errorBody("rate_limit_error", { - code: "rate_limited", - message: "Too many requests; slow down and retry after the interval in the Retry-After header.", - }), - }).annotate({ identifier: "RateLimitError" }), - { - httpApiStatus: 429, - identifier: "RateLimitError", - title: "Rate limit error", - description: "Too many requests in a given window. Back off and retry. HTTP 429.", - }, -) { - override get message(): string { - return this.error.message - } -} - -export class V2ApiError extends Schema.Error("@maple/http/v2/ApiError")( - Schema.Struct({ - error: errorBody("api_error", { - code: "internal_error", - message: "An unexpected error occurred on our end.", - }), - }).annotate({ identifier: "ApiError" }), - { - httpApiStatus: 500, - identifier: "ApiError", - title: "API error", - description: - "A sanitized unexpected server-side error. Retryability is carried by the public envelope metadata. HTTP 500.", - }, -) { - override get message(): string { - return this.error.message - } -} - -/** - * `api_error` flavor for a misbehaving upstream provider (502) — the target of - * an outbound call (e.g. a scrape target's discovery endpoint) rejected our - * credentials or failed at the transport level. Distinct from 503 so consumers - * can tell "the provider is misbehaving" from "Maple's storage is unavailable". - */ -export class V2UpstreamError extends Schema.Error("@maple/http/v2/UpstreamError")( - Schema.Struct({ - error: errorBody("api_error", { - code: "upstream_error", - message: "The upstream provider rejected the request.", - }), - }).annotate({ identifier: "UpstreamError" }), - { - httpApiStatus: 502, - identifier: "UpstreamError", - title: "Upstream error", - description: - "An upstream provider the operation depends on failed or rejected our credentials. Check the integration's connection before retrying. HTTP 502.", - }, -) { - override get message(): string { - return this.error.message - } -} - -/** `api_error` flavor for upstream/persistence unavailability (503). */ -export class V2ServiceUnavailableError extends Schema.Error( - "@maple/http/v2/ServiceUnavailableError", -)( - Schema.Struct({ - error: errorBody("api_error", { - code: "api_key_lookup_unavailable", - message: "The service is temporarily unavailable; retry after a short delay.", - }), - }).annotate({ identifier: "ServiceUnavailableError" }), - { - httpApiStatus: 503, - identifier: "ServiceUnavailableError", - title: "Service unavailable error", - description: - "The operation is unavailable. Retryability and recovery are carried by the public envelope metadata. HTTP 503.", - }, -) { - override get message(): string { - return this.error.message - } -} - -/** `api_error` flavor for an operation that exceeded its server-side deadline (504). */ -export class V2GatewayTimeoutError extends Schema.Error( - "@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, - message: string, - param?: string, - metadata: V2ErrorMetadata = {}, -) => - new V2InvalidRequestError({ - error: { - type: "invalid_request_error", - code, - message, - ...errorMetadata("invalid_request_error", code, {}, metadata), - ...(param !== undefined ? { param } : {}), - }, - }) - -export const authenticationError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => - new V2AuthenticationError({ - error: { - type: "authentication_error", - code, - message, - ...errorMetadata("authentication_error", code, {}, metadata), - }, - }) - -export const permissionError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => - new V2PermissionError({ - error: { - type: "permission_error", - code, - message, - ...errorMetadata("permission_error", code, {}, metadata), - }, - }) - -/** `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, - message, - ...errorMetadata("not_found_error", code, {}, metadata), - ...(param !== undefined ? { param } : {}), - }, - }) - -/** Resource-specific 404 code for stable public branching. */ -export const resourceNotFound = ( - resource: string, - message: string, - param = "id", - metadata: V2ErrorMetadata = {}, -) => { - const code = `${resource}_not_found` - return notFoundError(code, message, param, metadata) -} - -export const conflict = (code: string, message: string, metadata: V2ErrorMetadata = {}) => - new V2ConflictError({ - error: { - type: "conflict_error", - code, - message, - ...errorMetadata("conflict_error", code, {}, metadata), - }, - }) - -/** - * The message crosses the public boundary verbatim: unlike the warehouse - * errors, this one carries no database diagnostics — only the range the caller - * asked for and the caps it exceeded — and it is the one error here where - * 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, - message, - ...errorMetadata("invalid_request_error", code, {}, metadata), - ...(param !== undefined ? { param } : {}), - }, - }) - -export const rateLimitError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => - new V2RateLimitError({ - error: { - type: "rate_limit_error", - code, - message, - ...errorMetadata("rate_limit_error", code, {}, metadata), - }, - }) - -export const rateLimited = (metadata: V2ErrorMetadata = {}) => { - const retryAfterSeconds = metadata.retryAfterSeconds ?? 60 - return rateLimitError("rate_limited", `Too many requests. Retry after ${retryAfterSeconds} seconds.`, { - retryAfterSeconds, - ...metadata, - }) -} - -/** - * The daily-budget 429. - * - * Names the ceiling that was hit — runs and model passes are separate settings, - * and collapsing both into "quota reached" made a raised run cap look ignored - * when it was the pass cap that stopped the start. The ISO timestamp stays at - * the end for API consumers; the dashboard rewrites it as a relative time. - */ -export const investigationQuotaReached = ( - input: { - readonly dimension: "runs" | "passes" - readonly limit: number - readonly retryableAt: string - }, - metadata: V2ErrorMetadata = {}, -) => - new V2RateLimitError({ - error: { - type: "rate_limit_error", - code: "investigation_daily_quota", - message: - input.dimension === "runs" - ? `Daily limit of ${input.limit} investigations reached. Resets at ${input.retryableAt}.` - : `Daily limit of ${input.limit} model passes reached. Resets at ${input.retryableAt}.`, - ...errorMetadata( - "rate_limit_error", - "investigation_daily_quota", - { - title: "Investigation limit reached", - retryAt: input.retryableAt, - }, - metadata, - ), - }, - }) - -export const upstreamError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => - new V2UpstreamError({ - error: { - type: "api_error", - code, - message, - ...errorMetadata( - "api_error", - code, - { title: "Connected service unavailable", recovery: "reconnect" }, - metadata, - ), - }, - }) - -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, - message, - ...errorMetadata("api_error", code, {}, metadata), - }, - }) - -export const serviceError = (code: string, message: string, metadata: V2ErrorMetadata = {}) => - new V2ServiceUnavailableError({ - error: { - type: "api_error", - code, - message, - ...errorMetadata( - "api_error", - code, - { title: "Service temporarily unavailable", retryable: true, recovery: "retry" }, - metadata, - ), - }, - }) - -export const serviceUnavailable = (message: string, metadata: V2ErrorMetadata = {}) => - serviceError("service_unavailable", message, metadata) - -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, @@ -692,7 +51,6 @@ export interface V2ErrorSchemaOptions { const type = errorTypeForStatus(options.status) return Schema.Struct({ - error: Schema.Struct({ - _tag: Schema.Literal(options.tag).annotate({ + error: makePublicHttpErrorBodySchema( + Schema.Literal(options.tag).annotate({ description: "Stable semantic error tag. Branch on this exact value.", }), - type: Schema.Literal(type).annotate({ + 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, @@ -753,9 +76,8 @@ export const makeV2ErrorSchema = (definition.identifier)(definition.tag, { + error: errorBodySchema, + }) { + override get message(): string { + return this.error.message + } + } - const make = ( - message: string = definition.message, - options: V2ErrorMakeOptions = {}, - ): V2ErrorForStatus & V2PublicError> => { - const metadata = { - tag: definition.tag, + const make = (message: string = definition.message, options: V2ErrorMakeOptions = {}) => { + const error = { + _tag: definition.tag, + type, + code: definition.code, title: definition.title, + message, retryable: definition.retryable, recovery: definition.recovery, ...(options.retryAfterSeconds === undefined ? {} - : { retryAfterSeconds: options.retryAfterSeconds }), - ...(options.retryAt === undefined ? {} : { retryAt: options.retryAt }), - } - let error: V2ErrorForStatus - 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 & V2PublicError> + : { retry_after_seconds: options.retryAfterSeconds }), + ...(options.retryAt === undefined ? {} : { retry_at: options.retryAt }), + ...(options.param === undefined ? {} : { param: options.param }), + } satisfies PublicHttpErrorBody + return new BoundaryError({ error }) } return { ...definition, type, schema, make } as const @@ -971,3 +268,15 @@ export const V2UnexpectedFailure = defineV2Error({ recovery: "contact_support", identifier: "UnexpectedError", }) + +/** App bootstrap failed before the v2 HttpApi graph could handle the request. */ +export const V2WorkerUnavailable = defineV2Error({ + tag: "@maple/http/v2/WorkerUnavailableError", + status: 504, + code: "worker_unavailable", + title: "Maple API is temporarily unavailable", + message: "Maple API is temporarily unavailable. Retry in a few seconds.", + retryable: true, + recovery: "retry", + identifier: "WorkerUnavailableError", +}) diff --git a/packages/domain/src/http/v2/ingest-keys.ts b/packages/domain/src/http/v2/ingest-keys.ts index 97d903805..6155a0bab 100644 --- a/packages/domain/src/http/v2/ingest-keys.ts +++ b/packages/domain/src/http/v2/ingest-keys.ts @@ -1,6 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { IngestKeyEncryptionError, IngestKeyForbiddenError, IngestKeyPersistenceError } from "../ingest-keys" +import { IngestKeyEncryptionError, IngestKeyPersistenceError } from "../ingest-keys" import { AuthorizationV2 } from "./auth" import { Timestamp } from "./envelopes" import { V2InsufficientPermissions } from "./errors" @@ -50,11 +50,7 @@ export const V2IngestKeys = Schema.Struct({ }) export type V2IngestKeys = Schema.Schema.Type -const ingestKeyErrors = publicErrors( - IngestKeyForbiddenError, - IngestKeyPersistenceError, - IngestKeyEncryptionError, -) +const ingestKeyErrors = publicErrors(IngestKeyPersistenceError, IngestKeyEncryptionError) export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") .add( diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index ec99ccb8c..0a876e93e 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -224,7 +224,7 @@ describe("MapleApiV2 OpenAPI", () => { expect(op.description.length).toBeGreaterThan(20) expect(op.tags).toHaveLength(1) expect(op.security).toEqual([{ bearer: [] }]) - for (const status of ["400", "401", "403", "429", "500"]) { + for (const status of ["400", "401", "403", "429", "500", "504"]) { expect( op.responses[status], `${method.toUpperCase()} ${path} declares ${status}`, @@ -385,7 +385,7 @@ describe("MapleApiV2 OpenAPI", () => { const declared = (method: string, path: string) => Object.keys(operation(method, path).responses).sort() - // 400/401/403/429/500 come from the middleware; 503 from the handlers. + // 400/401/403/429/500/504 come from shared boundaries; 503 from the handlers. expect(declared("get", "/v2/integrations/slack")).toEqual([ "200", "400", @@ -394,6 +394,7 @@ describe("MapleApiV2 OpenAPI", () => { "429", "500", "503", + "504", ]) expect(declared("post", "/v2/integrations/slack/install")).toEqual([ "200", @@ -403,6 +404,7 @@ describe("MapleApiV2 OpenAPI", () => { "429", "500", "503", + "504", ]) expect(declared("delete", "/v2/integrations/slack")).toEqual([ "200", @@ -412,6 +414,7 @@ describe("MapleApiV2 OpenAPI", () => { "429", "500", "503", + "504", ]) // Only `channels` can 409 (not connected) or 502 (Slack rejected us). expect(declared("get", "/v2/integrations/slack/channels")).toEqual([ @@ -424,6 +427,7 @@ describe("MapleApiV2 OpenAPI", () => { "500", "502", "503", + "504", ]) }) @@ -481,6 +485,7 @@ describe("MapleApiV2 OpenAPI", () => { expect(responseErrorTags("get", "/v2/integrations/planetscale/organizations", "401")).toEqual([ "@maple/http/errors/IntegrationsRevokedError", "@maple/http/v2/InvalidCredentialsError", + "@maple/http/errors/UnauthorizedError", ]) expect(responseErrorTags("get", "/v2/session_replays/{id}/events", "413")).toEqual([ "@maple/http/v2/SessionReplayRangeTooLargeError", @@ -497,6 +502,40 @@ describe("MapleApiV2 OpenAPI", () => { ]) }) + it("does not advertise service errors that v2 handlers cannot emit", () => { + expect(responseErrorTags("post", "/v2/api_keys", "403")).toEqual([ + "@maple/http/v2/InsufficientPermissionsError", + "@maple/http/v2/InsufficientScopeError", + ]) + expect(responseErrorTags("get", "/v2/ingest_keys", "403")).toEqual([ + "@maple/http/v2/InsufficientPermissionsError", + "@maple/http/v2/InsufficientScopeError", + ]) + }) + + it("preserves warehouse failures on v2 read-model endpoints", () => { + for (const [method, path] of [ + ["get", "/v2/error_issues"], + ["get", "/v2/error_issues/{id}"], + ["get", "/v2/anomalies/incidents/{id}/timeseries"], + ] as const) { + expect(responseErrorTags(method, path, "429")).toContain( + "@maple/http/errors/WarehouseQuotaExceededError", + ) + expect(responseErrorTags(method, path, "503")).toContain( + "@maple/http/errors/WarehouseConfigLookupError", + ) + } + for (const path of ["/v2/alerts/rules/{id}/checks", "/v2/alerts/rules/{id}/checks/summary"]) { + expect(responseErrorTags("get", path, "429")).toContain( + "@maple/http/errors/WarehouseQuotaExceededError", + ) + expect(responseErrorTags("get", path, "503")).not.toContain( + "@maple/http/errors/WarehouseConfigLookupError", + ) + } + }) + it("decodes slack-bot destination create/update params and rejects a blank channel_id", () => { expect(schemas["AlertDestinationCreateSlackBot"], "create component present").toBeDefined() expect(schemas["AlertDestinationUpdateSlackBot"], "update component present").toBeDefined() diff --git a/packages/domain/src/http/v2/public-error.ts b/packages/domain/src/http/v2/public-error.ts index daeca0a35..4145cc755 100644 --- a/packages/domain/src/http/v2/public-error.ts +++ b/packages/domain/src/http/v2/public-error.ts @@ -5,10 +5,10 @@ import { type SelfDescribingHttpError, } from "../error-policy" import { Schema } from "effect" -import { makeV2ErrorSchema, type V2ErrorTypeForStatus, type V2PublicError } from "./errors" +import { makeV2ErrorSchema, type V2PublicError } from "./errors" export type V2ErrorEnvelopeFor = Error extends SelfDescribingHttpError - ? V2PublicError>> + ? V2PublicError> : never export type V2PublicErrorSchema = Schema.Codec< @@ -50,6 +50,5 @@ const makePublicErrorSchema = (errorClass 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 index 05c32c985..1e38e4ef4 100644 --- a/packages/domain/src/http/v2/query-errors.ts +++ b/packages/domain/src/http/v2/query-errors.ts @@ -8,6 +8,7 @@ import { WarehouseAuthError, WarehouseClientError, WarehouseConfigError, + WarehouseConfigLookupError, WarehouseMalformedQueryError, WarehouseQueryError, WarehouseQuotaExceededError, @@ -28,6 +29,20 @@ export const V2WarehouseErrors = publicErrors( WarehouseMalformedQueryError, WarehouseQuotaExceededError, WarehouseValidationError, + WarehouseConfigLookupError, +) + +/** Managed-only routes never consult the per-org warehouse configuration. */ +export const V2ManagedWarehouseErrors = publicErrors( + WarehouseQueryError, + WarehouseUpstreamError, + WarehouseAuthError, + WarehouseConfigError, + WarehouseClientError, + WarehouseSchemaDriftError, + WarehouseMalformedQueryError, + WarehouseQuotaExceededError, + WarehouseValidationError, ) /** Exact public schemas for failures added by the higher-level query engine. */ diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 5608e0e3d..1c3043466 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -21,7 +21,7 @@ import { paginateOffsetQuery, Timestamp, } from "./envelopes" -import { notFound, permissionError, rateLimited, V2NotFoundError, V2RateLimitError } from "./errors" +import { defineV2Error, V2InsufficientScope, V2RateLimited } from "./errors" import { encodePublicId } from "./public-id" import { LogPublicId, @@ -451,18 +451,30 @@ describe("V2 alerts wire format", () => { }) describe("v2 error envelope", () => { + const TestNotFound = defineV2Error({ + tag: "@maple/http/v2/TestNotFoundError", + status: 404, + code: "resource_missing", + title: "Not found", + message: "The resource does not exist.", + retryable: false, + recovery: "none", + identifier: "TestNotFoundError", + }) + it("exposes the public message to Effect and telemetry without changing the wire shape", () => { - const error = notFound("No such api_key", "id") + const error = TestNotFound.make("No such api_key", { param: "id" }) + expect(error._tag).toBe("@maple/http/v2/TestNotFoundError") expect(error.message).toBe("No such api_key") expect(String(error)).toContain("No such api_key") }) it("encodes the semantic tag and recovery contract inside the public envelope", () => { - const error = notFound("No such api_key", "id") - const wire = Schema.encodeSync(V2NotFoundError)(error) as Record + const error = TestNotFound.make("No such api_key", { param: "id" }) + const wire = Schema.encodeSync(TestNotFound.schema)(error) as Record expect(wire).toEqual({ error: { - _tag: "@maple/http/v2/resource_missing", + _tag: "@maple/http/v2/TestNotFoundError", type: "not_found_error", code: "resource_missing", title: "Not found", @@ -479,7 +491,7 @@ describe("v2 error envelope", () => { it("requires a semantic tag on every public error", () => { expect(() => - Schema.decodeUnknownSync(V2NotFoundError)({ + Schema.decodeUnknownSync(TestNotFound.schema)({ error: { type: "not_found_error", code: "resource_missing", @@ -490,24 +502,26 @@ describe("v2 error envelope", () => { }) it("omits param when not provided", () => { - const wire = Schema.encodeSync(V2NotFoundError)(notFound("gone")) as { + const wire = Schema.encodeSync(TestNotFound.schema)(TestNotFound.make("gone")) as { error: Record } expect("param" in wire.error).toBe(false) }) - it("permissionError has type permission_error", () => { - expect(permissionError("insufficient_scope", "nope").error.type).toBe("permission_error") + it("permission errors carry their declared category", () => { + expect(V2InsufficientScope.make("nope").error.type).toBe("permission_error") }) it("rateLimited has the stable public 429 envelope", () => { - expect(Schema.encodeSync(V2RateLimitError)(rateLimited())).toEqual({ + expect( + Schema.encodeSync(V2RateLimited.schema)(V2RateLimited.make(undefined, { retryAfterSeconds: 60 })), + ).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, diff --git a/packages/domain/src/http/warehouse-error-meta.ts b/packages/domain/src/http/warehouse-error-meta.ts index 28bb77ade..d4f377479 100644 --- a/packages/domain/src/http/warehouse-error-meta.ts +++ b/packages/domain/src/http/warehouse-error-meta.ts @@ -1,6 +1,6 @@ // Warehouse error presentation metadata — the single source of truth. // -// The nine warehouse error classes used to be re-enumerated by hand in five +// The warehouse error classes used to be re-enumerated by hand in five // places (two MCP tables, the alerts v2 map, AlertsService's failure // categories, and the web's error formatter), so adding a tag meant five // lockstep edits across five packages and any missed arm silently changed @@ -178,6 +178,11 @@ export const presentWarehouseError = (error: WarehouseErrorLike): PresentedWareh } case "@maple/http/errors/WarehouseConfigError": return { title, description: message ?? "Database is not configured correctly." } + case "@maple/http/errors/WarehouseConfigLookupError": + return { + title, + description: "Maple could not load the database settings. Retry in a few seconds.", + } case "@maple/http/errors/WarehouseClientError": return { title, description: message ?? "Database response could not be decoded." } case "@maple/http/errors/WarehouseSchemaDriftError": { diff --git a/packages/domain/src/http/warehouse-errors.ts b/packages/domain/src/http/warehouse-errors.ts index a61699301..2227697db 100644 --- a/packages/domain/src/http/warehouse-errors.ts +++ b/packages/domain/src/http/warehouse-errors.ts @@ -94,6 +94,21 @@ export class WarehouseConfigError extends HttpTaggedError( }, ) {} +/** Maple could not read the per-org warehouse routing configuration. */ +export class WarehouseConfigLookupError extends HttpTaggedError()( + "@maple/http/errors/WarehouseConfigLookupError", + warehouseErrorBaseFields, + { + status: 503, + code: "warehouse_config_lookup_unavailable", + title: "Database settings are temporarily unavailable", + message: "Maple could not load the database settings. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, +) {} + /** Maple's query client could not decode/consume the response. */ export class WarehouseClientError extends HttpTaggedError()( "@maple/http/errors/WarehouseClientError", @@ -223,6 +238,10 @@ export type WarehouseError = | WarehouseMalformedQueryError | WarehouseQuotaExceededError | WarehouseValidationError + | WarehouseConfigLookupError + +/** Errors possible on managed-only routes, which never read per-org routing config. */ +export type ManagedWarehouseError = Exclude /** * The full set of warehouse error classes, for reuse in `HttpApiEndpoint` @@ -240,4 +259,5 @@ export const warehouseHttpErrors = [ WarehouseMalformedQueryError, WarehouseQuotaExceededError, WarehouseValidationError, + WarehouseConfigLookupError, ] as const diff --git a/packages/query-engine/src/execution/errors.ts b/packages/query-engine/src/execution/errors.ts index 4a65f75dc..caa4308ba 100644 --- a/packages/query-engine/src/execution/errors.ts +++ b/packages/query-engine/src/execution/errors.ts @@ -4,6 +4,7 @@ import { WarehouseAuthError, WarehouseClientError, WarehouseConfigError, + WarehouseConfigLookupError, WarehouseMalformedQueryError, WarehouseQueryError, WarehouseQuotaExceededError, @@ -22,7 +23,7 @@ export { cleanErrorMessage, extractUpstreamStatus } * (`WarehouseValidationError`) are raised by the executor before a query runs, * not by this classifier, so they're intentionally absent here. */ -export type WarehouseSqlError = +export type WarehouseClassifiedError = | WarehouseQueryError | WarehouseUpstreamError | WarehouseAuthError @@ -32,6 +33,15 @@ export type WarehouseSqlError = | WarehouseMalformedQueryError | WarehouseQuotaExceededError +/** Complete error channel for an executed warehouse operation. */ +export type WarehouseExecutionError = WarehouseClassifiedError | WarehouseConfigLookupError + +/** + * Backwards-compatible name for the complete warehouse operation error channel. + * New code should prefer `WarehouseExecutionError`. + */ +export type WarehouseSqlError = WarehouseExecutionError + type ClickHouseErrorDetails = { readonly message: string readonly code?: string @@ -86,7 +96,7 @@ type ClassificationRule = { /** Restricts the rule to SQL with this authorship. Unset means either. */ readonly authoredBy?: SqlAuthorship /** Construct the tagged error for this rule. `upstreamStatus` is only used by the rules that carry it. */ - readonly make: (base: ClassifiedBase, upstreamStatus: number | undefined) => WarehouseSqlError + readonly make: (base: ClassifiedBase, upstreamStatus: number | undefined) => WarehouseClassifiedError } // Ordered rules — first match wins. A raw error can satisfy several patterns @@ -196,7 +206,7 @@ export const mapWarehouseError = ( pipe: string, error: unknown, authoredBy: SqlAuthorship = "caller", -): WarehouseSqlError => { +): WarehouseClassifiedError => { const { message: rawMessage, code, type } = getClickHouseErrorDetails(error) const message = cleanErrorMessage(rawMessage) const base: ClassifiedBase = { diff --git a/packages/query-engine/src/execution/executor.ts b/packages/query-engine/src/execution/executor.ts index e1cda67cc..31ad750a3 100644 --- a/packages/query-engine/src/execution/executor.ts +++ b/packages/query-engine/src/execution/executor.ts @@ -1,4 +1,4 @@ -import { Cause, Clock, Data, Duration, Effect, HashMap, Option, Ref, Schedule, Schema } from "effect" +import { Cause, Clock, Duration, Effect, HashMap, Option, Ref, Schedule, Schema } from "effect" import { MAX_RAW_SQL_RESULT_BYTES, MAX_RAW_SQL_RESULT_ROWS, @@ -62,13 +62,14 @@ const CAPABILITIES_INSPECTION_TIMEOUT = Duration.seconds(2) const WarehouseCapabilityMetadataTarget = Schema.Literals(["version", "indexes", "columns", "settings"]) type WarehouseCapabilityMetadataTarget = Schema.Schema.Type -class WarehouseCapabilityProbeError extends Data.TaggedError( +class WarehouseCapabilityProbeError extends Schema.TaggedError()( "@maple/query-engine/execution/WarehouseCapabilityProbeError", -)<{ - readonly target: WarehouseCapabilityMetadataTarget - readonly message: string - readonly cause: unknown -}> {} + { + target: WarehouseCapabilityMetadataTarget, + message: Schema.String, + cause: Schema.Defect(), + }, +) {} const CAPABILITY_AWARE_PIPES: ReadonlySet = new Set([ "list_logs", diff --git a/packages/query-engine/src/execution/ports.ts b/packages/query-engine/src/execution/ports.ts index 1390d8288..8218f85d1 100644 --- a/packages/query-engine/src/execution/ports.ts +++ b/packages/query-engine/src/execution/ports.ts @@ -12,7 +12,7 @@ import type { CompiledQuery } from "../ch" import type { WarehouseCapabilities } from "../capabilities" import type { WarehouseExecutorShape } from "../observability" import type { SqlQueryOptions } from "../profiles" -import type { WarehouseSqlError } from "./errors" +import type { WarehouseExecutionError } from "./errors" import type { WarehouseResponseLimitError } from "./response-limits" /** The minimal tenant surface the executor reads (org scope + identity for spans). */ @@ -79,7 +79,7 @@ export interface WarehouseExecutorDeps { tenant: ExecutionTenant, purpose: RoutePurpose, label: string, - ) => Effect.Effect + ) => Effect.Effect /** * Drop whatever the host caches to answer `resolveRoute` for this tenant, and * report whether that actually invalidated a per-org routing override. @@ -107,7 +107,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, payload: WarehouseQueryRequest, options?: SqlQueryOptions, - ) => Effect.Effect + ) => Effect.Effect /** * Execute a query that deliberately spans every tenant. The compiled query * must declare `.crossOrg()`, and `justification` is recorded on the span so @@ -123,19 +123,22 @@ export interface WarehouseQueryServiceShape { options: SqlQueryOptions & { readonly justification: string }, ) => Effect.Effect< ReadonlyArray, - WarehouseSqlError | WarehouseValidationError | WarehouseSchemaDriftError + WarehouseExecutionError | WarehouseValidationError | WarehouseSchemaDriftError > /** Execute validated user-authored SQL with tenant-scoped credentials and hard response limits. */ readonly rawSqlQuery: ( tenant: ExecutionTenant, sql: string, options?: Pick, - ) => Effect.Effect>, WarehouseSqlError | RawSqlValidationError> + ) => Effect.Effect< + ReadonlyArray>, + WarehouseExecutionError | RawSqlValidationError + > readonly compiledQuery: ( tenant: ExecutionTenant, compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseSqlError | WarehouseValidationError> + ) => Effect.Effect, WarehouseExecutionError | WarehouseValidationError> /** * `compiledQuery` with an explicit ceiling on how much of the response we are * willing to materialize, failing with `WarehouseResponseLimitError` past it. @@ -152,18 +155,18 @@ export interface WarehouseQueryServiceShape { }, ) => Effect.Effect< ReadonlyArray, - WarehouseSqlError | WarehouseValidationError | WarehouseResponseLimitError + WarehouseExecutionError | WarehouseValidationError | WarehouseResponseLimitError > readonly compiledQueryWithCapabilities: ( tenant: ExecutionTenant, compile: (capabilities: WarehouseCapabilities) => CompiledQuery, options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseSqlError | WarehouseValidationError> + ) => Effect.Effect, WarehouseExecutionError | WarehouseValidationError> readonly compiledQueryFirst: ( tenant: ExecutionTenant, compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseSqlError | WarehouseValidationError> + ) => Effect.Effect, WarehouseExecutionError | WarehouseValidationError> /** * Resolve this tenant's route and capabilities once, so a fan-out that * follows finds them memoized instead of each branch deriving them itself. @@ -186,7 +189,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, datasource: string, rows: ReadonlyArray, - ) => Effect.Effect + ) => Effect.Effect /** * Present this service as the package-level `WarehouseExecutor` for a given * tenant — the single managed-warehouse implementation of that interface. From e3f17901d67a8e11dbaa6ddaa79240b6c6db2595 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 13 Aug 2026 20:19:18 +0200 Subject: [PATCH 2/4] refactor(api): make v2 error contracts exact --- .../src/http/v2-worker-unavailable.test.ts | 1 + apps/api/src/http/v2-worker-unavailable.ts | 24 +- .../src/mcp/lib/map-warehouse-error.test.ts | 12 +- apps/api/src/mcp/lib/map-warehouse-error.ts | 7 +- apps/api/src/mcp/lib/run-raw-sql.ts | 4 +- apps/api/src/mcp/tools/create-alert-rule.ts | 2 +- apps/api/src/mcp/tools/delete-alert-rule.ts | 2 +- apps/api/src/mcp/tools/update-alert-rule.ts | 2 +- apps/api/src/routes/v1/anomalies.http.ts | 2 +- apps/api/src/routes/v1/errors.http.ts | 2 +- .../routes/v1/prometheus-scrape-proxy.http.ts | 17 ++ apps/api/src/routes/v1/query-engine.http.ts | 9 +- apps/api/src/routes/v1/scrape-targets.http.ts | 27 +- .../src/routes/v2/alert-destinations.http.ts | 7 +- apps/api/src/routes/v2/alert-rules.http.ts | 7 +- .../routes/v2/phase1-resources.http.test.ts | 38 ++- .../alerts/AlertDestinationsService.ts | 16 +- .../alerts/AlertReadModelsService.test.ts | 4 +- .../services/alerts/AlertReadModelsService.ts | 85 +++--- .../services/alerts/AlertRulesService.test.ts | 2 +- .../src/services/alerts/AlertRulesService.ts | 11 +- apps/api/src/services/alerts/AlertsService.ts | 128 +++++---- .../services/auth/PlanetScaleOAuthService.ts | 19 +- apps/api/src/services/auth/scrape-auth.ts | 39 +-- .../errors/ErrorIssueWorkflowService.ts | 3 +- .../errors/investigation-fanout-error.ts | 7 +- .../PlanetScaleDiscoveryService.test.ts | 23 ++ .../PlanetScaleDiscoveryService.ts | 23 +- .../integrations/ScrapeTargetsService.test.ts | 22 ++ .../integrations/ScrapeTargetsService.ts | 28 +- .../TinybirdOrgTokenService.test.ts | 3 +- .../integrations/TinybirdOrgTokenService.ts | 36 ++- .../api/src/services/org/OrgMembersService.ts | 7 +- .../api/src/services/org/SetupAuditService.ts | 6 +- .../warehouse/WarehouseQueryService.test.ts | 32 ++- .../warehouse/WarehouseQueryService.ts | 34 ++- .../warehouse/warehouse-error-handlers.ts | 23 +- apps/api/src/worker.ts | 3 +- .../components/common/error-state.test.tsx | 9 +- apps/web/src/lib/error-messages.test.ts | 18 +- apps/web/src/lib/error-messages.ts | 3 +- .../src/lib/services/common/retry-policy.ts | 3 +- docs/api-v2.md | 4 +- lib/clickhouse-builder/src/ch/compile.ts | 51 ++-- lib/clickhouse-builder/src/ch/query.ts | 51 ++-- .../alchemy-maple/src/AlertDestination.ts | 7 +- packages/alchemy-maple/src/AlertRule.ts | 7 +- packages/alchemy-maple/src/ApiKey.ts | 11 +- packages/alchemy-maple/src/Dashboard.ts | 7 +- packages/alchemy-maple/src/MapleApi.ts | 119 +++++++-- packages/alchemy-maple/src/errors.ts | 101 ++++--- packages/alchemy-maple/src/index.ts | 10 +- packages/alchemy-maple/test/contract.test.ts | 23 +- packages/alchemy-maple/test/maple-api.test.ts | 97 ++++++- packages/domain/package.json | 1 + .../domain/src/anticipated-errors.test.ts | 8 +- packages/domain/src/anticipated-errors.ts | 27 +- packages/domain/src/http/alerts.ts | 77 +++--- packages/domain/src/http/error-policy.ts | 34 ++- packages/domain/src/http/errors.ts | 15 +- packages/domain/src/http/scrape-targets.ts | 10 +- .../domain/src/http/v2/alert-destinations.ts | 4 +- .../domain/src/http/v2/alert-incidents.ts | 4 +- packages/domain/src/http/v2/alert-rules.ts | 34 ++- packages/domain/src/http/v2/errors.ts | 133 ++++------ packages/domain/src/http/v2/openapi.test.ts | 68 ++++- .../domain/src/http/v2/public-error.test.ts | 15 ++ packages/domain/src/http/v2/public-error.ts | 50 +++- packages/domain/src/http/v2/query-errors.ts | 46 +--- packages/domain/src/http/v2/scrape-targets.ts | 22 +- .../domain/src/http/v2/session-replays.ts | 4 +- packages/domain/src/http/v2/telemetry.ts | 22 +- .../domain/src/http/v2/v2-contract.test.ts | 6 +- .../domain/src/http/v2/worker-unavailable.ts | 29 ++ .../src/http/warehouse-error-meta.test.ts | 67 ----- .../domain/src/http/warehouse-error-meta.ts | 249 ------------------ packages/domain/src/http/warehouse-errors.ts | 158 ++++++++--- packages/domain/src/http/warehouse.ts | 1 - packages/query-engine/src/execution/errors.ts | 61 +++-- .../src/execution/executor.test.ts | 4 +- .../query-engine/src/execution/executor.ts | 21 +- packages/query-engine/src/execution/ports.ts | 42 +-- 82 files changed, 1324 insertions(+), 1126 deletions(-) create mode 100644 packages/domain/src/http/v2/worker-unavailable.ts delete mode 100644 packages/domain/src/http/warehouse-error-meta.test.ts delete mode 100644 packages/domain/src/http/warehouse-error-meta.ts diff --git a/apps/api/src/http/v2-worker-unavailable.test.ts b/apps/api/src/http/v2-worker-unavailable.test.ts index e7b966528..bb538f724 100644 --- a/apps/api/src/http/v2-worker-unavailable.test.ts +++ b/apps/api/src/http/v2-worker-unavailable.test.ts @@ -10,6 +10,7 @@ describe("v2 worker fallback", () => { expect(response.status).toBe(504) expect(response.headers.get("retry-after")).toBe("1") + expect(body).toEqual({ error: V2WorkerUnavailable.make().error }) expect(() => Schema.decodeUnknownSync(V2WorkerUnavailable.schema)(body)).not.toThrow() }) }) diff --git a/apps/api/src/http/v2-worker-unavailable.ts b/apps/api/src/http/v2-worker-unavailable.ts index f3cb4d6ef..b92de951a 100644 --- a/apps/api/src/http/v2-worker-unavailable.ts +++ b/apps/api/src/http/v2-worker-unavailable.ts @@ -1,16 +1,16 @@ -import type { AnyPublicHttpErrorBody } from "@maple/domain/http" +import { + v2WorkerUnavailableBody, + v2WorkerUnavailableDefinition, +} from "@maple/domain/http/v2-worker-unavailable" /** Canonical v2 fallback used when the route graph could not finish bootstrapping. */ export const v2WorkerUnavailableResponse = (): Response => { - const error = { - _tag: "@maple/http/v2/WorkerUnavailableError", - type: "api_error", - code: "worker_unavailable", - title: "Maple API is temporarily unavailable", - message: "Maple API is temporarily unavailable. Retry in a few seconds.", - retryable: true, - recovery: "retry", - retry_after_seconds: 1, - } as const satisfies AnyPublicHttpErrorBody - return Response.json({ error }, { status: 504, headers: { "retry-after": "1" } }) + const definition = v2WorkerUnavailableDefinition + return Response.json( + { error: v2WorkerUnavailableBody() }, + { + status: definition.status, + headers: { "retry-after": String(definition.retryAfterSeconds) }, + }, + ) } diff --git a/apps/api/src/mcp/lib/map-warehouse-error.test.ts b/apps/api/src/mcp/lib/map-warehouse-error.test.ts index 0c8c440f9..e63c1b7cc 100644 --- a/apps/api/src/mcp/lib/map-warehouse-error.test.ts +++ b/apps/api/src/mcp/lib/map-warehouse-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { WarehouseQueryError, WarehouseSchemaDriftError } from "@maple/domain" +import { WarehouseQueryError, WarehouseResultDecodeError, WarehouseSchemaDriftError } from "@maple/domain" import { toMcpQueryError } from "./map-warehouse-error" describe("toMcpQueryError", () => { @@ -27,4 +27,14 @@ describe("toMcpQueryError", () => { const mcp = toMcpQueryError("service_overview")(err) expect(mcp.message).toBe("boom") }) + + it("does not tell users to apply schema for a result decode failure", () => { + const err = new WarehouseResultDecodeError({ + message: "row did not decode", + pipeName: "service_overview", + }) + const mcp = toMcpQueryError("service_overview")(err) + expect(mcp.message).toBe("row did not decode") + expect(mcp.message).not.toContain("schema apply") + }) }) diff --git a/apps/api/src/mcp/lib/map-warehouse-error.ts b/apps/api/src/mcp/lib/map-warehouse-error.ts index 407c09a92..3ddc180c2 100644 --- a/apps/api/src/mcp/lib/map-warehouse-error.ts +++ b/apps/api/src/mcp/lib/map-warehouse-error.ts @@ -17,13 +17,10 @@ const SCHEMA_DRIFT_HINT = * column). Every MCP surface that renders a warehouse error should go through * this, not `error.message`. * - * `kind: "decode"` drift means the cluster answered fine but the rows failed - * Maple's own row schema — schema apply cannot fix that, so no hint. + * Row-decoding failures have their own tag and never receive this hint. */ export const warehouseErrorText = (error: WarehouseError): string => - error instanceof WarehouseSchemaDriftError && error.kind !== "decode" - ? `${error.message}${SCHEMA_DRIFT_HINT}` - : error.message + error instanceof WarehouseSchemaDriftError ? `${error.message}${SCHEMA_DRIFT_HINT}` : error.message /** * Curry the pipe label so call sites read as diff --git a/apps/api/src/mcp/lib/run-raw-sql.ts b/apps/api/src/mcp/lib/run-raw-sql.ts index c91dbd8fc..43015e999 100644 --- a/apps/api/src/mcp/lib/run-raw-sql.ts +++ b/apps/api/src/mcp/lib/run-raw-sql.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { RawSqlValidationError } from "@maple/domain/http" -import type { WarehouseSqlError } from "@maple/query-engine/execution" +import type { WarehouseExecutionError } from "@maple/query-engine/execution" import { makeExecuteRawSql } from "@maple/query-engine/runtime" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import type { TenantContext } from "@/services/auth/tenant-context" @@ -43,7 +43,7 @@ export interface RunRawSqlInput { */ export const runRawSql = Effect.fn("runRawSql")(function* (input: RunRawSqlInput) { const warehouse = yield* WarehouseQueryService - const executeRawSql = makeExecuteRawSql( + const executeRawSql = makeExecuteRawSql( warehouse, ) return yield* executeRawSql(input.tenant, { diff --git a/apps/api/src/mcp/tools/create-alert-rule.ts b/apps/api/src/mcp/tools/create-alert-rule.ts index 05d5b56c8..99295f414 100644 --- a/apps/api/src/mcp/tools/create-alert-rule.ts +++ b/apps/api/src/mcp/tools/create-alert-rule.ts @@ -357,7 +357,7 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) { cause: error, }), ), - "@maple/http/errors/AlertNotFoundError": (error) => + "@maple/http/errors/AlertRuleNotFoundError": (error) => Effect.fail( new McpQueryError({ message: `${error._tag}: ${error.message}`, diff --git a/apps/api/src/mcp/tools/delete-alert-rule.ts b/apps/api/src/mcp/tools/delete-alert-rule.ts index 09b9af798..cfecdd657 100644 --- a/apps/api/src/mcp/tools/delete-alert-rule.ts +++ b/apps/api/src/mcp/tools/delete-alert-rule.ts @@ -66,7 +66,7 @@ export function registerDeleteAlertRuleTool(server: McpToolRegistrar) { cause: error, }), ), - "@maple/http/errors/AlertNotFoundError": (error) => + "@maple/http/errors/AlertRuleNotFoundError": (error) => Effect.fail( new McpQueryError({ message: `${error._tag}: ${error.message}. Use list_alert_rules to find available rule IDs.`, diff --git a/apps/api/src/mcp/tools/update-alert-rule.ts b/apps/api/src/mcp/tools/update-alert-rule.ts index 32d60a4ab..faa99f8b1 100644 --- a/apps/api/src/mcp/tools/update-alert-rule.ts +++ b/apps/api/src/mcp/tools/update-alert-rule.ts @@ -267,7 +267,7 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) { cause: error, }), ), - "@maple/http/errors/AlertNotFoundError": (error) => + "@maple/http/errors/AlertRuleNotFoundError": (error) => Effect.fail( new McpQueryError({ message: `${error._tag}: ${error.message}`, diff --git a/apps/api/src/routes/v1/anomalies.http.ts b/apps/api/src/routes/v1/anomalies.http.ts index 73c789e39..226e04ac7 100644 --- a/apps/api/src/routes/v1/anomalies.http.ts +++ b/apps/api/src/routes/v1/anomalies.http.ts @@ -18,7 +18,7 @@ import { ErrorsService } from "@/services/errors/ErrorsService" import { requireAdmin } from "@/services/auth/auth" import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" -// v1 keeps its historical generic persistence failure; v2 exposes each warehouse tag directly. +// Preserve v1's historical persistence envelope while v2 exposes warehouse tags directly. const legacyPersistenceFailure = (error: { readonly message: string }) => Effect.fail(makeAnomalyPersistenceError(error)) diff --git a/apps/api/src/routes/v1/errors.http.ts b/apps/api/src/routes/v1/errors.http.ts index 44e10c9ea..38fb4ec7d 100644 --- a/apps/api/src/routes/v1/errors.http.ts +++ b/apps/api/src/routes/v1/errors.http.ts @@ -10,7 +10,7 @@ import { requireAdmin } from "@/services/auth/auth" import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" import { makePersistenceError } from "@/services/errors/error-persistence" -// v1 keeps its historical generic persistence failure; v2 exposes each warehouse tag directly. +// Preserve v1's historical persistence envelope while v2 exposes warehouse tags directly. const legacyPersistenceFailure = (error: { readonly message: string }) => Effect.fail(makePersistenceError(error)) diff --git a/apps/api/src/routes/v1/prometheus-scrape-proxy.http.ts b/apps/api/src/routes/v1/prometheus-scrape-proxy.http.ts index 8955b3ecf..51db1552f 100644 --- a/apps/api/src/routes/v1/prometheus-scrape-proxy.http.ts +++ b/apps/api/src/routes/v1/prometheus-scrape-proxy.http.ts @@ -92,6 +92,23 @@ export const PrometheusScrapeProxyRouter = HttpRouter.use((router) => Effect.annotateCurrentSpan({ "maple.scrape.auth_failure_reason": error.reason, }).pipe(Effect.as(errorText(`[auth:${error.reason}] ${error.message}`, 502))), + "@maple/http/errors/IntegrationsConfigurationError": (error) => + Effect.succeed(errorText(`[auth:config] ${error.message}`, 502)), + "@maple/http/errors/IntegrationsNotConnectedError": (error) => + Effect.succeed(errorText(`[auth:not_connected] ${error.message}`, 502)), + "@maple/http/errors/IntegrationsRevokedError": (error) => + Effect.succeed(errorText(`[auth:revoked] ${error.message}`, 502)), + "@maple/http/errors/IntegrationsUpstreamError": (error) => + Effect.succeed( + errorText( + `[auth:upstream] PlanetScale token refresh failed upstream: ${error.message}`, + 502, + ), + ), + "@maple/http/errors/IntegrationsValidationError": (error) => + Effect.succeed(errorText(`[auth:config] ${error.message}`, 502)), + "@maple/http/errors/IntegrationsPersistenceError": (error) => + Effect.succeed(errorText(error.message, 502)), }), ) }) diff --git a/apps/api/src/routes/v1/query-engine.http.ts b/apps/api/src/routes/v1/query-engine.http.ts index dceeab780..5c36f1f6d 100644 --- a/apps/api/src/routes/v1/query-engine.http.ts +++ b/apps/api/src/routes/v1/query-engine.http.ts @@ -94,7 +94,7 @@ import { import { Queries } from "@/routes/queries" import { makeQueryRunners } from "@/routes/query-runner" import { runQueryEngineBatch } from "@/routes/query-engine-batch" -import type { ExecutionTenant, WarehouseSqlError } from "@maple/query-engine/execution" +import type { ExecutionTenant, WarehouseExecutionError } from "@maple/query-engine/execution" import type { TenantContext } from "@/services/auth/AuthService" import * as Integrations from "@maple/query-engine-integrations" @@ -276,9 +276,10 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", const warehouse = yield* WarehouseQueryService const { runQuery, runQueryFirst } = makeQueryRunners({ warehouse, queryEngine }) - const executeRawSql = makeExecuteRawSql( - warehouse, - ) + const executeRawSql = makeExecuteRawSql< + ExecutionTenant, + WarehouseExecutionError | RawSqlValidationError + >(warehouse) return handlers .handle("execute", ({ payload }) => diff --git a/apps/api/src/routes/v1/scrape-targets.http.ts b/apps/api/src/routes/v1/scrape-targets.http.ts index 47617ef57..c7495c832 100644 --- a/apps/api/src/routes/v1/scrape-targets.http.ts +++ b/apps/api/src/routes/v1/scrape-targets.http.ts @@ -3,14 +3,37 @@ import { CurrentTenant, IsoDateTimeString, MapleApi, + ScrapeTargetAuthError, ScrapeTargetCheckResponse, ScrapeTargetChecksListResponse, + ScrapeTargetPersistenceError, } from "@maple/domain/http" import { Effect, Schema } from "effect" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" const decodeIsoDateTimeStringSync = Schema.decodeUnknownSync(IsoDateTimeString) +/** Preserve v1's collapsed scrape-auth response while v2 exposes the source tags directly. */ +const v1PlanetScaleAccessTokenErrors = { + "@maple/http/errors/IntegrationsConfigurationError": (error: { readonly message: string }) => + Effect.fail(new ScrapeTargetAuthError({ reason: "config", message: error.message })), + "@maple/http/errors/IntegrationsNotConnectedError": (error: { readonly message: string }) => + Effect.fail(new ScrapeTargetAuthError({ reason: "not_connected", message: error.message })), + "@maple/http/errors/IntegrationsRevokedError": (error: { readonly message: string }) => + Effect.fail(new ScrapeTargetAuthError({ reason: "revoked", message: error.message })), + "@maple/http/errors/IntegrationsUpstreamError": (error: { readonly message: string }) => + Effect.fail( + new ScrapeTargetAuthError({ + reason: "upstream", + message: `PlanetScale token refresh failed upstream: ${error.message}`, + }), + ), + "@maple/http/errors/IntegrationsValidationError": (error: { readonly message: string }) => + Effect.fail(new ScrapeTargetAuthError({ reason: "config", message: error.message })), + "@maple/http/errors/IntegrationsPersistenceError": (error: { readonly message: string }) => + Effect.fail(new ScrapeTargetPersistenceError({ message: error.message })), +} as const + export const HttpScrapeTargetsLive = HttpApiBuilder.group(MapleApi, "scrapeTargets", (handlers) => Effect.gen(function* () { const service = yield* ScrapeTargetsService @@ -43,7 +66,9 @@ export const HttpScrapeTargetsLive = HttpApiBuilder.group(MapleApi, "scrapeTarge .handle("probe", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* service.probe(tenant.orgId, params.targetId) + return yield* service + .probe(tenant.orgId, params.targetId) + .pipe(Effect.catchTags(v1PlanetScaleAccessTokenErrors)) }), ) .handle("listChecks", ({ params, query }) => diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index c1a4064d8..795d02f66 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -5,7 +5,7 @@ import { DiscordAlertDestinationConfig, EmailAlertDestinationConfig, HazelOAuthAlertDestinationConfig, - AlertNotFoundError, + AlertDestinationNotFoundError, PagerDutyAlertDestinationConfig, SlackBotAlertDestinationConfig, WebhookAlertDestinationConfig, @@ -176,10 +176,9 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale const destination = response.destinations.find((doc) => doc.id === params.id) if (destination === undefined) return yield* Effect.fail( - new AlertNotFoundError({ + new AlertDestinationNotFoundError({ message: "No such alert destination.", - resourceType: "destination", - resourceId: params.id, + destinationId: params.id, }), ) return toV2Destination(destination) diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index 29cf8a087..471905254 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -3,7 +3,7 @@ import type { AlertCheckDocument, AlertRuleDocument, AlertRulePreviewResponse } import { AlertRulePreviewRequest, AlertRuleUpsertRequest, - AlertNotFoundError, + AlertRuleNotFoundError, CurrentTenant, IsoDateTimeString, QueryBuilderQueryDraftSchema, @@ -294,10 +294,9 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules const rule = response.rules.find((doc) => doc.id === ruleId) if (rule === undefined) return yield* Effect.fail( - new AlertNotFoundError({ + new AlertRuleNotFoundError({ message: "No such alert rule.", - resourceType: "alert_rule", - resourceId: ruleId, + ruleId, }), ) return rule 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 d2dec61a2..d99cf90d3 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -40,6 +40,7 @@ import { SpanId, TraceId, UserId, + WarehouseConfigDecryptionError, WarehouseConfigLookupError, } from "@maple/domain/http" import { MapleApiV2, encodePublicId } from "@maple/domain/http/v2" @@ -359,7 +360,7 @@ const ORG = Schema.decodeUnknownSync(OrgId)("org_phase1_e2e") const USER = Schema.decodeUnknownSync(UserId)("user_phase1_e2e") type InvestigationStartMode = "success" | "quota" | "unavailable" | "rejected" | "restart_not_found" -type IssueReadFailure = "none" | "persistence" | "warehouse_config_lookup" +type IssueReadFailure = "none" | "persistence" | "warehouse_config_lookup" | "warehouse_config_decryption" const makeHarness = ( warehouseService: WarehouseQueryServiceShape = warehouseStub, @@ -379,15 +380,26 @@ const makeHarness = ( readonly orgId: string readonly options: Record } | null = null - const issueReadFailureEffect = () => - issueReadFailure === "warehouse_config_lookup" - ? Effect.fail( + const issueReadFailureEffect = () => { + switch (issueReadFailure) { + case "warehouse_config_lookup": + return Effect.fail( new WarehouseConfigLookupError({ pipeName: "errorIssues", message: "SECRET_CONFIG_LOOKUP_FAILURE", }), ) - : Effect.fail(new ErrorPersistenceError({ message: "database unavailable" })) + case "warehouse_config_decryption": + return Effect.fail( + new WarehouseConfigDecryptionError({ + pipeName: "errorIssues", + message: "SECRET_DECRYPTION_FAILURE", + }), + ) + default: + return Effect.fail(new ErrorPersistenceError({ message: "database unavailable" })) + } + } const startInvestigation = () => { switch (investigationStartMode) { case "quota": @@ -737,6 +749,22 @@ describe("v2 error_issues over HTTP", () => { expect(JSON.stringify(response.body)).not.toContain("SECRET_CONFIG_LOOKUP_FAILURE") await harness.dispose() }) + + it("preserves and redacts a non-retryable warehouse configuration failure", async () => { + const harness = makeHarness(warehouseStub, "warehouse_config_decryption") + const key = await harness.bootstrapKey(["error_issues:read"]) + const response = await harness.request("GET", "/v2/error_issues", { token: key.secret }) + + expect(response.status).toBe(500) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/WarehouseConfigDecryptionError", + code: "warehouse_config_decryption_failed", + retryable: false, + recovery: "contact_support", + }) + expect(JSON.stringify(response.body)).not.toContain("SECRET_DECRYPTION_FAILURE") + await harness.dispose() + }) }) describe("v2 investigations over HTTP", () => { diff --git a/apps/api/src/services/alerts/AlertDestinationsService.ts b/apps/api/src/services/alerts/AlertDestinationsService.ts index e14748cdd..d8eb82f76 100644 --- a/apps/api/src/services/alerts/AlertDestinationsService.ts +++ b/apps/api/src/services/alerts/AlertDestinationsService.ts @@ -7,7 +7,7 @@ import { AlertDestinationTestResponse, AlertDestinationsListResponse, AlertForbiddenError, - AlertNotFoundError, + AlertDestinationNotFoundError, AlertPersistenceError, AlertRuleDocument, AlertValidationError, @@ -197,7 +197,7 @@ export interface AlertDestinationsServiceShape { request: AlertDestinationUpdateRequest, ) => Effect.Effect< AlertDestinationDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertNotFoundError + AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertDestinationNotFoundError > readonly deleteDestination: ( orgId: OrgId, @@ -205,7 +205,10 @@ export interface AlertDestinationsServiceShape { destinationId: AlertDestinationDocument["id"], ) => Effect.Effect< AlertDestinationDeleteResponse, - AlertForbiddenError | AlertPersistenceError | AlertNotFoundError | AlertDestinationInUseError + | AlertForbiddenError + | AlertPersistenceError + | AlertDestinationNotFoundError + | AlertDestinationInUseError > readonly testDestination: ( orgId: OrgId, @@ -216,7 +219,7 @@ export interface AlertDestinationsServiceShape { AlertDestinationTestResponse, | AlertForbiddenError | AlertPersistenceError - | AlertNotFoundError + | AlertDestinationNotFoundError | AlertDeliveryError | AlertValidationError > @@ -272,10 +275,9 @@ export class AlertDestinationsService extends Context.Service< ) if (rows[0]) return rows[0] return yield* Effect.fail( - new AlertNotFoundError({ + new AlertDestinationNotFoundError({ message: "Alert destination not found", - resourceType: "destination", - resourceId: destinationId, + destinationId, }), ) }) diff --git a/apps/api/src/services/alerts/AlertReadModelsService.test.ts b/apps/api/src/services/alerts/AlertReadModelsService.test.ts index f772a5f2d..0c6170ee7 100644 --- a/apps/api/src/services/alerts/AlertReadModelsService.test.ts +++ b/apps/api/src/services/alerts/AlertReadModelsService.test.ts @@ -137,10 +137,10 @@ describe("AlertReadModelsService", () => { const readModels = yield* AlertReadModelsService const missingIncident = yield* Effect.flip(readModels.getIncident(OTHER_ORG, INCIDENT_NEW)) - assert.strictEqual(missingIncident._tag, "@maple/http/errors/AlertNotFoundError") + assert.strictEqual(missingIncident._tag, "@maple/http/errors/AlertIncidentNotFoundError") const missingChecks = yield* Effect.flip(readModels.listRuleChecks(OTHER_ORG, RULE, {})) - assert.strictEqual(missingChecks._tag, "@maple/http/errors/AlertNotFoundError") + assert.strictEqual(missingChecks._tag, "@maple/http/errors/AlertRuleNotFoundError") assert.deepStrictEqual(contexts, []) const checks = yield* readModels.listRuleChecks(ORG, RULE, { limit: 20 }) diff --git a/apps/api/src/services/alerts/AlertReadModelsService.ts b/apps/api/src/services/alerts/AlertReadModelsService.ts index 71a1b04c7..4425c743b 100644 --- a/apps/api/src/services/alerts/AlertReadModelsService.ts +++ b/apps/api/src/services/alerts/AlertReadModelsService.ts @@ -13,7 +13,8 @@ import { AlertIncidentsListResponse, AlertIncidentStatus, AlertIncidentTransition as AlertIncidentTransitionSchema, - AlertNotFoundError, + AlertIncidentNotFoundError, + AlertRuleNotFoundError, AlertPersistenceError, AlertRuleDocument, AlertSeverity as AlertSeveritySchema, @@ -26,7 +27,6 @@ import { type AlertIncidentId, type AlertRuleId, type ManagedWarehouseError, - type WarehouseError, } from "@maple/domain/http" import { alertDeliveryEvents, @@ -90,7 +90,7 @@ export interface AlertReadModelsServiceShape { readonly getIncident: ( orgId: OrgId, incidentId: AlertIncidentId, - ) => Effect.Effect + ) => Effect.Effect readonly listRuleChecks: ( orgId: OrgId, ruleId: AlertRuleId, @@ -105,7 +105,7 @@ export interface AlertReadModelsServiceShape { }, ) => Effect.Effect< AlertChecksListResponse, - AlertPersistenceError | AlertNotFoundError | ManagedWarehouseError + AlertPersistenceError | AlertRuleNotFoundError | ManagedWarehouseError > readonly summarizeRuleChecks: ( orgId: OrgId, @@ -116,7 +116,7 @@ export interface AlertReadModelsServiceShape { }, ) => Effect.Effect< AlertChecksSummary, - AlertPersistenceError | AlertNotFoundError | AlertValidationError | ManagedWarehouseError + AlertPersistenceError | AlertRuleNotFoundError | AlertValidationError | ManagedWarehouseError > readonly listDeliveryEvents: ( orgId: OrgId, @@ -149,14 +149,6 @@ const toIso = (value: Date | null | undefined): IsoDateTimeValue | null => const makeValidationError = (message: string) => new AlertValidationError({ message, details: [] }) -/** Assert the `.routing("ingest")` invariant and remove its impossible config-lookup branch. */ -const managedWarehouseQuery = ( - effect: Effect.Effect, -): Effect.Effect => - effect.pipe( - Effect.catchTag("@maple/http/errors/WarehouseConfigLookupError", (error) => Effect.die(error)), - ) - const rowToIncidentDocument = (row: AlertIncidentRow) => new AlertIncidentDocument({ id: decodeAlertIncidentIdSync(row.id), @@ -248,10 +240,9 @@ export class AlertReadModelsService extends Context.Service< ) const incident = rows[0] if (incident === undefined) { - return yield* new AlertNotFoundError({ + return yield* new AlertIncidentNotFoundError({ message: `No such alert incident: '${incidentId}'`, - resourceType: "alert_incident", - resourceId: incidentId, + incidentId, }) } return rowToIncidentDocument(incident) @@ -280,10 +271,9 @@ export class AlertReadModelsService extends Context.Service< .limit(1), ) if (ruleRow.length === 0) { - return yield* new AlertNotFoundError({ + return yield* new AlertRuleNotFoundError({ message: "Alert rule not found", - resourceType: "alert_rule", - resourceId: ruleId, + ruleId, }) } @@ -327,12 +317,10 @@ export class AlertReadModelsService extends Context.Service< // listRuleChecksQuery declares .routing("ingest") — alert_checks only // exists in the managed Tinybird pipeline. - const rows = yield* managedWarehouseQuery( - warehouse.compiledQuery(systemTenant(orgId), compiled, { - profile: "list", - context: "listAlertChecks", - }), - ) + const rows = yield* warehouse.compiledQuery(systemTenant(orgId), compiled, { + profile: "list", + context: "listAlertChecks", + }) const checks = yield* Effect.try({ try: () => @@ -398,10 +386,9 @@ export class AlertReadModelsService extends Context.Service< .limit(1), ) if (ruleRow.length === 0) { - return yield* new AlertNotFoundError({ + return yield* new AlertRuleNotFoundError({ message: "Alert rule not found", - resourceType: "alert_rule", - resourceId: ruleId, + ruleId, }) } @@ -428,31 +415,27 @@ export class AlertReadModelsService extends Context.Service< // stable across refreshes and matches alert evaluation granularity. const bucketSeconds = Math.max(1, Math.ceil((endMs - startMs) / 1000 / 720 / 60)) * 60 const tenant = systemTenant(orgId) - const groupRows = yield* managedWarehouseQuery( - warehouse.compiledQuery( - tenant, - CH.compile(CH.alertCheckGroupTotalsQuery({ since, until, limit: 20 }), { - orgId, - ruleId, - since, - until, - }), - { profile: "aggregation", context: "alertCheckSummaryGroups" }, - ), + const groupRows = yield* warehouse.compiledQuery( + tenant, + CH.compile(CH.alertCheckGroupTotalsQuery({ since, until, limit: 20 }), { + orgId, + ruleId, + since, + until, + }), + { profile: "aggregation", context: "alertCheckSummaryGroups" }, ) const topGroupKeys = groupRows.map((row) => String(row.groupKey ?? "")) - const rows = yield* managedWarehouseQuery( - warehouse.compiledQuery( - tenant, - CH.compile(CH.alertChecksSummaryQuery({ topGroupKeys }), { - orgId, - ruleId, - since, - until, - bucketSeconds, - }), - { profile: "aggregation", context: "alertCheckSummary" }, - ), + const rows = yield* warehouse.compiledQuery( + tenant, + CH.compile(CH.alertChecksSummaryQuery({ topGroupKeys }), { + orgId, + ruleId, + since, + until, + bucketSeconds, + }), + { profile: "aggregation", context: "alertCheckSummary" }, ) const points: AlertChecksSummaryPoint[] = rows.map((row) => ({ diff --git a/apps/api/src/services/alerts/AlertRulesService.test.ts b/apps/api/src/services/alerts/AlertRulesService.test.ts index 87c0feb76..71509b4aa 100644 --- a/apps/api/src/services/alerts/AlertRulesService.test.ts +++ b/apps/api/src/services/alerts/AlertRulesService.test.ts @@ -98,7 +98,7 @@ describe("AlertRulesService", () => { assert.deepStrictEqual((yield* rules.listRules(OTHER_ORG)).rules, []) const wrongOrg = yield* Effect.flip(rules.deleteRule(OTHER_ORG, ADMIN_ROLES, RULE)) - assert.strictEqual(wrongOrg._tag, "@maple/http/errors/AlertNotFoundError") + assert.strictEqual(wrongOrg._tag, "@maple/http/errors/AlertRuleNotFoundError") const deleted = yield* rules.deleteRule(ORG, ADMIN_ROLES, RULE) assert.strictEqual(deleted.id, RULE) diff --git a/apps/api/src/services/alerts/AlertRulesService.ts b/apps/api/src/services/alerts/AlertRulesService.ts index 6d04f40e2..60ccb06bd 100644 --- a/apps/api/src/services/alerts/AlertRulesService.ts +++ b/apps/api/src/services/alerts/AlertRulesService.ts @@ -1,6 +1,6 @@ import { AlertForbiddenError, - AlertNotFoundError, + AlertRuleNotFoundError, AlertPersistenceError, AlertRuleDeleteResponse, AlertRuleDocument, @@ -53,7 +53,7 @@ export interface AlertRulesServiceShape { request: AlertRuleUpsertRequest, ) => Effect.Effect< AlertRuleDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertNotFoundError + AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertRuleNotFoundError > readonly deleteRule: ( orgId: OrgId, @@ -61,7 +61,7 @@ export interface AlertRulesServiceShape { ruleId: AlertRuleDocument["id"], ) => Effect.Effect< AlertRuleDeleteResponse, - AlertForbiddenError | AlertPersistenceError | AlertNotFoundError + AlertForbiddenError | AlertPersistenceError | AlertRuleNotFoundError > } @@ -97,10 +97,9 @@ export const makeAlertRulePersistence = (options: { ) if (rows[0]) return rows[0] return yield* Effect.fail( - new AlertNotFoundError({ + new AlertRuleNotFoundError({ message: "Alert rule not found", - resourceType: "rule", - resourceId: ruleId, + ruleId, }), ) }) diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index 883569e2c..b5da0c717 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -10,7 +10,8 @@ import { AlertGroupBy as AlertGroupBySchema, AlertIncidentDocument, AlertIncidentStatus, - AlertNotFoundError, + type AlertDestinationNotFoundError, + type AlertRuleNotFoundError, AlertPersistenceError, AlertRuleDocument, AlertRulePreviewFiringSpan, @@ -32,11 +33,11 @@ import { type AlertRuleId, type AlertDestinationId, type AlertIncidentId, - QueryEngineExecutionError, + type QueryEngineExecutionError, type WarehouseError, type WarehouseErrorTag, - QueryEngineTimeoutError, - QueryEngineValidationError, + type QueryEngineTimeoutError, + type QueryEngineValidationError, RoleName, UserId as UserIdSchema, type UserId, @@ -122,8 +123,13 @@ const WAREHOUSE_FAILURE_CATEGORIES = { "@maple/http/errors/WarehouseAuthError": "tinybird_auth", "@maple/http/errors/WarehouseConfigError": "tinybird_config", "@maple/http/errors/WarehouseConfigLookupError": "tinybird_config_lookup", + "@maple/http/errors/WarehouseConfigDecryptionError": "warehouse_config_decryption", + "@maple/http/errors/WarehouseStoredConfigInvalidError": "warehouse_config_invalid", + "@maple/http/errors/WarehouseTokenConfigError": "warehouse_token_config", + "@maple/http/errors/WarehouseTokenMintError": "warehouse_token_mint", "@maple/http/errors/WarehouseClientError": "tinybird_client", "@maple/http/errors/WarehouseSchemaDriftError": "tinybird_schema_drift", + "@maple/http/errors/WarehouseResultDecodeError": "warehouse_result_decode", "@maple/http/errors/WarehouseMalformedQueryError": "malformed_query", "@maple/http/errors/WarehouseQuotaExceededError": "tinybird_quota", "@maple/http/errors/WarehouseValidationError": "tinybird_validation", @@ -317,7 +323,7 @@ export interface AlertsServiceShape request: AlertRuleUpsertRequest, ) => Effect.Effect< AlertRuleDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertNotFoundError + AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertRuleNotFoundError > readonly testRule: ( orgId: OrgId, @@ -330,8 +336,11 @@ export interface AlertsServiceShape | AlertForbiddenError | AlertValidationError | AlertPersistenceError - | AlertNotFoundError + | AlertDestinationNotFoundError | AlertDeliveryError + | QueryEngineValidationError + | QueryEngineExecutionError + | QueryEngineTimeoutError | WarehouseError > /** @@ -347,8 +356,10 @@ export interface AlertsServiceShape AlertRulePreviewResponse, | AlertValidationError | AlertForbiddenError - | AlertDeliveryError | AlertPersistenceError + | QueryEngineValidationError + | QueryEngineExecutionError + | QueryEngineTimeoutError | WarehouseError > readonly runSchedulerTick: () => Effect.Effect< @@ -358,7 +369,11 @@ export interface AlertsServiceShape readonly evaluationFailureCount: number readonly deliveryFailureCount: number }, - AlertPersistenceError | AlertDeliveryError | AlertValidationError | AlertNotFoundError + | AlertPersistenceError + | AlertDeliveryError + | AlertValidationError + | AlertRuleNotFoundError + | AlertDestinationNotFoundError // Note: warehouse tagged errors flow up from evaluateRule but are caught // inside the per-rule Effect.catch in the scheduler tick, so the tick // itself never surfaces them. @@ -462,33 +477,6 @@ export class AlertsService extends Context.Service( - effect: Effect.Effect< - A, - | QueryEngineValidationError - | QueryEngineExecutionError - | QueryEngineTimeoutError - | WarehouseError, - R - >, - ) => - effect.pipe( - Effect.catchTags({ - "@maple/http/errors/QueryEngineValidationError": (e) => - Effect.fail(makeValidationError(e.message, e.details)), - "@maple/http/errors/QueryEngineExecutionError": (e) => - Effect.fail(makeDeliveryError(e.message, undefined, e)), - "@maple/http/errors/QueryEngineTimeoutError": (e) => - Effect.fail( - makeDeliveryError(e.message ?? "Alert evaluation timed out", undefined, e), - ), - }), - ) - /** * Evaluate the alert rule and return one outcome per group. * @@ -506,22 +494,27 @@ export class AlertsService extends Context.Service, - AlertValidationError | AlertDeliveryError | WarehouseError + | AlertValidationError + | QueryEngineValidationError + | QueryEngineExecutionError + | QueryEngineTimeoutError + | WarehouseError > { yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.rule_id": rule.id }) const endMs = yield* now const startMs = endMs - rule.windowMinutes * 60_000 const plan = rule.compiledPlan const source = yield* planEvaluateSource(plan, rule.windowMinutes) - const observations: ReadonlyArray = yield* queryEngine - .evaluate(systemTenant(orgId), { + const observations: ReadonlyArray = yield* queryEngine.evaluate( + systemTenant(orgId), + { startTime: formatWarehouseDateTime(startMs), endTime: formatWarehouseDateTime(endMs), source, reducer: plan.reducer, sampleCountStrategy: plan.sampleCountStrategy, - }) - .pipe(catchQueryEngineErrors) + }, + ) const grouped = isGroupedPlan(plan) return observations.map((obs) => ({ @@ -685,7 +678,7 @@ export class AlertsService extends Context.Service composeLinkUrl(resolveServiceLinkName(rule, groupKey)) const toDeliveryAttemptFailure = ( - error: AlertValidationError | AlertDeliveryError | AlertNotFoundError | AlertPersistenceError, + error: AlertValidationError | AlertDeliveryError | AlertPersistenceError, ): DeliveryAttemptFailure => Match.value(error).pipe( Match.discriminatorsExhaustive("_tag")({ @@ -701,11 +694,6 @@ export class AlertsService extends Context.Service ({ - message: e.message, - kind: "destination" as const, - retryable: false, - }), "@maple/http/errors/AlertPersistenceError": (e) => ({ message: e.message, kind: "unknown" as const, @@ -984,8 +972,10 @@ export class AlertsService extends Context.Service { yield* Effect.annotateCurrentSpan("orgId", orgId) @@ -1084,15 +1074,13 @@ export class AlertsService extends Context.Service recordEvaluationFailure(row, error, "validation"), - "@maple/http/errors/AlertDeliveryError": (error) => - recordEvaluationFailure(row, error, "evaluation"), "@maple/http/errors/AlertPersistenceError": (error) => recordEvaluationFailure(row, error, "unknown"), + "@maple/http/errors/QueryEngineValidationError": (error) => + recordEvaluationFailure(row, error, "query_engine_validation"), + "@maple/http/errors/QueryEngineExecutionError": (error) => + recordEvaluationFailure(row, error, "query_engine_execution"), + "@maple/http/errors/QueryEngineTimeoutError": (error) => + recordEvaluationFailure(row, error, "query_engine_timeout"), ...warehouseHandlers((error) => recordEvaluationFailure( row, diff --git a/apps/api/src/services/auth/PlanetScaleOAuthService.ts b/apps/api/src/services/auth/PlanetScaleOAuthService.ts index cfc3de770..ea78b717a 100644 --- a/apps/api/src/services/auth/PlanetScaleOAuthService.ts +++ b/apps/api/src/services/auth/PlanetScaleOAuthService.ts @@ -88,6 +88,15 @@ export interface PlanetScaleOrganization { readonly name: string } +/** Exact failures involved in resolving a usable PlanetScale OAuth token. */ +export type PlanetScaleAccessTokenError = + | IntegrationsNotConnectedError + | IntegrationsRevokedError + | IntegrationsUpstreamError + | IntegrationsPersistenceError + | IntegrationsValidationError + | IntegrationsConfigurationError + // Lenient decoders: only the fields we consume. PlanetScale list endpoints wrap // results in a `{ data: [...] }` envelope. const OrganizationSchema = Schema.Struct({ @@ -149,15 +158,7 @@ export interface PlanetScaleOAuthServiceShape { > readonly getValidAccessToken: ( orgId: OrgId, - ) => Effect.Effect< - { readonly accessToken: string }, - | IntegrationsNotConnectedError - | IntegrationsRevokedError - | IntegrationsUpstreamError - | IntegrationsPersistenceError - | IntegrationsValidationError - | IntegrationsConfigurationError - > + ) => Effect.Effect<{ readonly accessToken: string }, PlanetScaleAccessTokenError> /** Organizations the stored grant can access — org-picker material. */ readonly listOrganizations: ( orgId: OrgId, diff --git a/apps/api/src/services/auth/scrape-auth.ts b/apps/api/src/services/auth/scrape-auth.ts index 811769212..c9c9134a3 100644 --- a/apps/api/src/services/auth/scrape-auth.ts +++ b/apps/api/src/services/auth/scrape-auth.ts @@ -1,14 +1,4 @@ -import { - ScrapeTargetAuthError, - ScrapeTargetEncryptionError, - ScrapeTargetPersistenceError, - type IntegrationsConfigurationError, - type IntegrationsNotConnectedError, - type IntegrationsPersistenceError, - type IntegrationsRevokedError, - type IntegrationsUpstreamError, - type IntegrationsValidationError, -} from "@maple/domain/http" +import { ScrapeTargetEncryptionError } from "@maple/domain/http" import { Effect, Schema } from "effect" import { decryptAes256Gcm } from "@/platform/Crypto" @@ -44,33 +34,6 @@ export interface ScrapeAuthRowLike { const toEncryptionError = (message: string) => new ScrapeTargetEncryptionError({ message }) -/** - * `Effect.catchTags` handler set mapping a PlanetScale OAuth token-resolution - * failure onto the scrape error taxonomy without losing the failure class: a - * revoked or never-connected grant must stay distinguishable from a transient - * upstream blip — a collapsed tag is how a dead grant goes invisible on the - * 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) => - Effect.fail(new ScrapeTargetAuthError({ reason: "revoked", message: error.message })), - "@maple/http/errors/IntegrationsUpstreamError": (error: IntegrationsUpstreamError) => - Effect.fail( - new ScrapeTargetAuthError({ - reason: "upstream", - message: `PlanetScale token refresh failed upstream: ${error.message}`, - }), - ), - "@maple/http/errors/IntegrationsValidationError": (error: IntegrationsValidationError) => - Effect.fail(new ScrapeTargetAuthError({ reason: "config", message: error.message })), - "@maple/http/errors/IntegrationsPersistenceError": (error: IntegrationsPersistenceError) => - Effect.fail(new ScrapeTargetPersistenceError({ message: error.message })), -} as const - const decodeCredentials = (schema: S, credentialsJson: string) => Schema.decodeEffect(Schema.fromJsonString(schema))(credentialsJson).pipe( Effect.mapError(() => toEncryptionError("Failed to decode auth credentials")), diff --git a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts index 0ae33fae1..5264ed923 100644 --- a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts +++ b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts @@ -241,8 +241,7 @@ const make: Effect.Effect()( "@maple/api/errors/FanoutStartError", { message: Schema.String, - cause: Schema.String, + cause: Schema.Defect(), }, ) { static fromCause(cause: unknown): FanoutStartError { - const detail = String(cause) return new FanoutStartError({ - message: `Investigation fanout failed to start: ${detail}`, - cause: detail, + message: "Investigation fanout failed to start", + cause, }) } } diff --git a/apps/api/src/services/integrations/PlanetScaleDiscoveryService.test.ts b/apps/api/src/services/integrations/PlanetScaleDiscoveryService.test.ts index a6af19f08..dfbb48548 100644 --- a/apps/api/src/services/integrations/PlanetScaleDiscoveryService.test.ts +++ b/apps/api/src/services/integrations/PlanetScaleDiscoveryService.test.ts @@ -355,6 +355,29 @@ describe("PlanetScaleDiscoveryService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) + it.effect("preserves a missing managed OAuth grant as IntegrationsNotConnectedError", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const targets = yield* ScrapeTargetsService + const discovery = yield* PlanetScaleDiscoveryService + const created = yield* targets.create( + asOrgId("org_1"), + new CreateScrapeTargetRequest({ + name: "Managed PlanetScale", + targetType: "planetscale", + organization: "my-org", + authType: "planetscale_oauth", + }), + ) + const rows = yield* targets.listAllEnabled() + const row = rows.find((candidate) => candidate.id === created.id) + if (!row) return yield* Effect.die("created row not found") + + const error = yield* discovery.discover(row).pipe(Effect.flip) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsNotConnectedError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + it.effect("maps a non-auth upstream failure to ScrapeTargetUpstreamError with the status", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { diff --git a/apps/api/src/services/integrations/PlanetScaleDiscoveryService.ts b/apps/api/src/services/integrations/PlanetScaleDiscoveryService.ts index 589f624f8..35e505839 100644 --- a/apps/api/src/services/integrations/PlanetScaleDiscoveryService.ts +++ b/apps/api/src/services/integrations/PlanetScaleDiscoveryService.ts @@ -1,4 +1,5 @@ import { + IntegrationsRevokedError, OrgId, ScrapeTargetAuthError, ScrapeTargetEncryptionError, @@ -24,10 +25,14 @@ import { import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { parseBase64Aes256GcmKey } from "@/platform/Crypto" import { Env } from "@/platform/Env" -import { buildScrapeAuthHeaders, catchOAuthTokenFailure } from "@/services/auth/scrape-auth" +import { buildScrapeAuthHeaders } from "@/services/auth/scrape-auth" import { validateExternalUrlSync } from "@/http/url-validator" import { decodeDiscoveryConfig } from "./planetscale/discovery-config" -import { PlanetScaleOAuthService, planetScaleBearerHeader } from "@/services/auth/PlanetScaleOAuthService" +import { + PlanetScaleOAuthService, + planetScaleBearerHeader, + type PlanetScaleAccessTokenError, +} from "@/services/auth/PlanetScaleOAuthService" type ScrapeTargetRow = typeof scrapeTargets.$inferSelect @@ -94,6 +99,7 @@ type DiscoveryError = | ScrapeTargetEncryptionError | ScrapeTargetAuthError | ScrapeTargetUpstreamError + | PlanetScaleAccessTokenError const toPersistenceError = (message: string) => new ScrapeTargetPersistenceError({ message }) @@ -220,6 +226,7 @@ export interface PlanetScaleDiscoveryServiceShape { | ScrapeTargetEncryptionError | ScrapeTargetAuthError | ScrapeTargetUpstreamError + | PlanetScaleAccessTokenError > /** Last discovery error for a target (null when the last refresh succeeded). */ readonly lastError: (targetId: string) => Effect.Effect @@ -260,9 +267,7 @@ export class PlanetScaleDiscoveryService extends Context.Service< return yield* buildScrapeAuthHeaders(row, encryptionKey) } const orgId = yield* Schema.decodeEffect(OrgId)(row.orgId).pipe(Effect.orDie) - const { accessToken } = yield* psOAuth - .getValidAccessToken(orgId) - .pipe(Effect.catchTags(catchOAuthTokenFailure)) + const { accessToken } = yield* psOAuth.getValidAccessToken(orgId) return { Authorization: planetScaleBearerHeader(accessToken) } }) @@ -286,14 +291,12 @@ export class PlanetScaleDiscoveryService extends Context.Service< }), ) - // A rejected credential is an auth failure, not a persistence one — keep - // the taxonomy so the org-picker/status surfaces can key on the reason - // instead of regex-sniffing the status out of the message. + // Preserve the OAuth grant's exact public failure. A manual service token + // has no integration grant, so it keeps the scrape-target auth tag. if (response.status === 401 || response.status === 403) { return yield* Effect.fail( row.authType === "planetscale_oauth" - ? new ScrapeTargetAuthError({ - reason: "revoked", + ? new IntegrationsRevokedError({ message: `PlanetScale discovery rejected the OAuth token (HTTP ${response.status}). Check the OAuth app's read_metrics_endpoints scope and reconnect.`, }) : new ScrapeTargetAuthError({ diff --git a/apps/api/src/services/integrations/ScrapeTargetsService.test.ts b/apps/api/src/services/integrations/ScrapeTargetsService.test.ts index d08711a55..513a27029 100644 --- a/apps/api/src/services/integrations/ScrapeTargetsService.test.ts +++ b/apps/api/src/services/integrations/ScrapeTargetsService.test.ts @@ -589,6 +589,28 @@ describe("ScrapeTargetsService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) + it.effect("classifies proxied transport failures as upstream errors", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const service = yield* ScrapeTargetsService + const target = yield* service.create( + asOrgId("org_1"), + new CreateScrapeTargetRequest({ + name: "Node Exporter", + url: "https://metrics.example.com/metrics", + scrapeIntervalSeconds: asScrapeIntervalSeconds(15), + }), + ) + globalThis.fetch = (async () => { + throw new TypeError("connection refused") + }) as typeof fetch + + const error = yield* service.scrapeForCollector(target.id).pipe(Effect.flip) + assert.strictEqual(error._tag, "@maple/http/errors/ScrapeTargetUpstreamError") + assert.include(error.message, "connection refused") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + it.effect("re-reads a target from Postgres after an update invalidates the memo", () => { const testDb = createTestDb(trackedDbs) globalThis.fetch = (async () => new Response("up 1\n", { status: 200 })) as typeof fetch diff --git a/apps/api/src/services/integrations/ScrapeTargetsService.ts b/apps/api/src/services/integrations/ScrapeTargetsService.ts index 92cd37559..ae6e7b1b1 100644 --- a/apps/api/src/services/integrations/ScrapeTargetsService.ts +++ b/apps/api/src/services/integrations/ScrapeTargetsService.ts @@ -30,13 +30,16 @@ import { BasicCredentialsSchema, BearerCredentialsSchema, buildScrapeAuthHeaders, - catchOAuthTokenFailure, TokenCredentialsSchema, } from "@/services/auth/scrape-auth" import { safeFetch, validateExternalUrl } from "@/http/url-validator" import { decodeDiscoveryConfig } from "./planetscale/discovery-config" import { PlanetScaleDiscoveryService, planetScaleDiscoveryUrl } from "./PlanetScaleDiscoveryService" -import { PlanetScaleOAuthService, planetScaleBearerHeader } from "@/services/auth/PlanetScaleOAuthService" +import { + PlanetScaleOAuthService, + planetScaleBearerHeader, + type PlanetScaleAccessTokenError, +} from "@/services/auth/PlanetScaleOAuthService" type ScrapeTargetRow = typeof scrapeTargets.$inferSelect @@ -111,6 +114,7 @@ export interface ScrapeTargetsServiceShape { | ScrapeTargetEncryptionError | ScrapeTargetAuthError | ScrapeTargetUpstreamError + | PlanetScaleAccessTokenError > readonly recordScrapeResults: ( results: ReadonlyArray<{ @@ -151,7 +155,7 @@ export interface ScrapeTargetsServiceShape { | ScrapeTargetNotFoundError | ScrapeTargetPersistenceError | ScrapeTargetEncryptionError - | ScrapeTargetAuthError + | PlanetScaleAccessTokenError > } @@ -184,6 +188,9 @@ const toPersistenceError = (error: unknown) => message: error instanceof Error ? error.message : "Scrape target persistence failed", }) +const toUpstreamError = (message: string, status?: number) => + new ScrapeTargetUpstreamError({ message, ...(status === undefined ? {} : { status }) }) + const toEncryptionError = (message: string) => new ScrapeTargetEncryptionError({ message }) const decodeTargetIdSync = Schema.decodeUnknownSync(ScrapeTargetId) @@ -488,9 +495,7 @@ export class ScrapeTargetsService extends Context.Service + toUpstreamError( + cause instanceof Error ? cause.message : "Scrape target request failed", + ), }).pipe( Effect.timeout(timeoutMs), - // A timeout surfaces as the same persistence error a fetch abort - // produced before, so callers see no new error type. Effect.catchTag("TimeoutError", () => - Effect.fail(toPersistenceError(new Error("The operation was aborted"))), + Effect.fail(toUpstreamError("Scrape target request timed out")), ), ) }) diff --git a/apps/api/src/services/integrations/TinybirdOrgTokenService.test.ts b/apps/api/src/services/integrations/TinybirdOrgTokenService.test.ts index 2dea2ccc2..6df8882cd 100644 --- a/apps/api/src/services/integrations/TinybirdOrgTokenService.test.ts +++ b/apps/api/src/services/integrations/TinybirdOrgTokenService.test.ts @@ -123,7 +123,8 @@ describe("TinybirdOrgTokenService", () => { return Effect.gen(function* () { const svc = yield* TinybirdOrgTokenService const error = yield* Effect.flip(svc.getOrgReadToken(asOrgId("org_a"))) - assert.strictEqual(error.reason, "MissingSigningKey") + assert.strictEqual(error._tag, "@maple/api/services/TinybirdOrgTokenConfigError") + assert.strictEqual(error.setting, "SigningKey") assert.notInclude(error.message, "api-token-is-not-the-signing-key") }).pipe(Effect.provide(missingLayer)) }) diff --git a/apps/api/src/services/integrations/TinybirdOrgTokenService.ts b/apps/api/src/services/integrations/TinybirdOrgTokenService.ts index 1b054a9b6..fc0ae28ed 100644 --- a/apps/api/src/services/integrations/TinybirdOrgTokenService.ts +++ b/apps/api/src/services/integrations/TinybirdOrgTokenService.ts @@ -26,17 +26,27 @@ export const JWT_CACHE_MAX_ENTRIES = 512 export interface TinybirdOrgTokenServiceShape { /** A Tinybird read JWT scoped to `orgId` across every OrgId-bearing datasource. */ - readonly getOrgReadToken: (orgId: OrgId) => Effect.Effect + readonly getOrgReadToken: ( + orgId: OrgId, + ) => Effect.Effect } -export class TinybirdOrgTokenError extends Schema.TaggedError()( - "@maple/api/services/TinybirdOrgTokenError", +export class TinybirdOrgTokenConfigError extends Schema.TaggedError()( + "@maple/api/services/TinybirdOrgTokenConfigError", { - reason: Schema.Literals(["MissingSigningKey", "MissingWorkspaceId", "MintFailed"]), + setting: Schema.Literals(["SigningKey", "WorkspaceId"]), message: Schema.String, }, ) {} +export class TinybirdOrgTokenMintError extends Schema.TaggedError()( + "@maple/api/services/TinybirdOrgTokenMintError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + export class TinybirdOrgTokenService extends Context.Service< TinybirdOrgTokenService, TinybirdOrgTokenServiceShape @@ -76,22 +86,22 @@ export class TinybirdOrgTokenService extends Context.Service< yield* Effect.annotateCurrentSpan("maple.tinybird.jwt.cache_hit", false) pruneCache(nowMs) if (Option.isNone(env.TINYBIRD_SIGNING_KEY)) { - return yield* new TinybirdOrgTokenError({ - reason: "MissingSigningKey", + return yield* new TinybirdOrgTokenConfigError({ + setting: "SigningKey", message: "TINYBIRD_SIGNING_KEY is required for Tinybird-scoped raw SQL", }) } if (Option.isNone(env.TINYBIRD_WORKSPACE_ID) || env.TINYBIRD_WORKSPACE_ID.value.trim() === "") { - return yield* new TinybirdOrgTokenError({ - reason: "MissingWorkspaceId", + return yield* new TinybirdOrgTokenConfigError({ + setting: "WorkspaceId", message: "TINYBIRD_WORKSPACE_ID is required for Tinybird-scoped raw SQL", }) } const workspaceId = env.TINYBIRD_WORKSPACE_ID.value const signingKey = Redacted.value(env.TINYBIRD_SIGNING_KEY.value) if (signingKey.trim() === "") { - return yield* new TinybirdOrgTokenError({ - reason: "MissingSigningKey", + return yield* new TinybirdOrgTokenConfigError({ + setting: "SigningKey", message: "TINYBIRD_SIGNING_KEY must not be empty", }) } @@ -106,10 +116,10 @@ export class TinybirdOrgTokenService extends Context.Service< ttlSeconds: JWT_TTL_SECONDS, rpsLimit: Option.getOrUndefined(env.TINYBIRD_RAW_SQL_JWT_RPS_LIMIT), }), - catch: () => - new TinybirdOrgTokenError({ - reason: "MintFailed", + catch: (cause) => + new TinybirdOrgTokenMintError({ message: "Failed to mint the Tinybird org-scoped read token", + cause, }), }) cache.set(orgId, { diff --git a/apps/api/src/services/org/OrgMembersService.ts b/apps/api/src/services/org/OrgMembersService.ts index 9781fcaf9..c0cf52e51 100644 --- a/apps/api/src/services/org/OrgMembersService.ts +++ b/apps/api/src/services/org/OrgMembersService.ts @@ -8,6 +8,7 @@ export class OrgMembersError extends Schema.TaggedError()( "@maple/api/services/OrgMembersError", { message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), /** User ids the caller supplied that are not members of the org. */ unknownUserIds: Schema.optionalKey(Schema.Array(Schema.String)), }, @@ -63,7 +64,11 @@ const make = Effect.gen(function* () { }), ).pipe( Effect.mapError( - () => new OrgMembersError({ message: `Failed to list workspace members for ${orgId}` }), + (cause) => + new OrgMembersError({ + message: `Failed to list workspace members for ${orgId}`, + cause, + }), ), ) for (const member of page.data) { diff --git a/apps/api/src/services/org/SetupAuditService.ts b/apps/api/src/services/org/SetupAuditService.ts index eebf05dd1..cfae18222 100644 --- a/apps/api/src/services/org/SetupAuditService.ts +++ b/apps/api/src/services/org/SetupAuditService.ts @@ -411,10 +411,8 @@ const make: Effect.Effect( - compiled: Parameters>[1], - profile: "discovery" | "list", - ) => warehouse.compiledQuery(tenant, compiled, { profile, context: "setupAudit" }) + const run = (compiled: CH.CompiledQuery, profile: "discovery" | "list") => + warehouse.compiledQuery(tenant, compiled, { profile, context: "setupAudit" }) // Same reason as `fetchTraceCompleteness`. Both are independent entry // points, so each warms; whichever runs second finds the memo warm and diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts index 1d32e8311..37e6430a0 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts @@ -3,9 +3,12 @@ import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Schema } from "effe import { WarehouseQueryError, WarehouseConfigError, + WarehouseConfigDecryptionError, WarehouseConfigLookupError, + WarehouseStoredConfigInvalidError, + WarehouseTokenConfigError, MAX_RAW_SQL_RESULT_BYTES, - WarehouseSchemaDriftError, + WarehouseResultDecodeError, WarehouseUpstreamError, OrgClickHouseSettingsEncryptionError, OrgClickHouseSettingsPersistenceError, @@ -258,11 +261,11 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { }, { source: new OrgClickHouseSettingsEncryptionError({ message: "decrypt failed" }), - expected: WarehouseConfigError, + expected: WarehouseConfigDecryptionError, }, { source: new OrgClickHouseSettingsValidationError({ message: "invalid stored URL" }), - expected: WarehouseConfigError, + expected: WarehouseStoredConfigInvalidError, }, ] as const @@ -287,7 +290,12 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { const mapped = getError(exit) assert.instanceOf(mapped, expected) assert.strictEqual( - (mapped as WarehouseConfigError | WarehouseConfigLookupError).cause, + ( + mapped as + | WarehouseConfigDecryptionError + | WarehouseConfigLookupError + | WarehouseStoredConfigInvalidError + ).cause, source, ) }).pipe(Effect.provide(layer)) @@ -296,7 +304,7 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { ) }) - it.effect("maps missing Tinybird signing configuration to WarehouseConfigError", () => { + it.effect("preserves missing Tinybird signing configuration as its own tag", () => { __testables.setClientFactory(() => ({ sql: async () => ({ data: [] }), insert: async () => {}, @@ -308,9 +316,9 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { service.rawSqlQuery(makeTenant(), "SELECT 1 WHERE OrgId = 'org_test'"), ).pipe(Effect.exit) const failure = getError(exit) - assert.instanceOf(failure, WarehouseConfigError) - assert.include((failure as WarehouseConfigError).message, "TINYBIRD_SIGNING_KEY") - assert.notInclude((failure as WarehouseConfigError).message, "managed-token") + assert.instanceOf(failure, WarehouseTokenConfigError) + assert.include((failure as WarehouseTokenConfigError).message, "TINYBIRD_SIGNING_KEY") + assert.notInclude((failure as WarehouseTokenConfigError).message, "managed-token") }).pipe(Effect.provide(layer)) }) @@ -469,7 +477,7 @@ describe("WarehouseQueryService.compiledQuery", () => { }).pipe(Effect.provide(layer)) }) - it.effect("maps row decode failures to WarehouseSchemaDriftError", () => { + it.effect("maps row decode failures to WarehouseResultDecodeError", () => { __testables.setClientFactory(() => ({ sql: async () => ({ data: [{ count: "not-a-number" }] }), insert: async () => {}, @@ -492,7 +500,7 @@ describe("WarehouseQueryService.compiledQuery", () => { assert.isTrue(Exit.isFailure(exit)) const failure = getError(exit) - assert.instanceOf(failure, WarehouseSchemaDriftError) + assert.instanceOf(failure, WarehouseResultDecodeError) }).pipe(Effect.provide(layer)) }) @@ -592,7 +600,7 @@ describe("WarehouseQueryService.compiledQueryFirst", () => { }).pipe(Effect.provide(layer)) }) - it.effect("maps first-row decode failures to WarehouseSchemaDriftError", () => { + it.effect("maps first-row decode failures to WarehouseResultDecodeError", () => { __testables.setClientFactory(() => ({ sql: async () => ({ data: [{ count: "not-a-number" }] }), insert: async () => {}, @@ -615,7 +623,7 @@ describe("WarehouseQueryService.compiledQueryFirst", () => { assert.isTrue(Exit.isFailure(exit)) const failure = getError(exit) - assert.instanceOf(failure, WarehouseSchemaDriftError) + assert.instanceOf(failure, WarehouseResultDecodeError) }).pipe(Effect.provide(layer)) }) }) diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.ts b/apps/api/src/services/warehouse/WarehouseQueryService.ts index fd2536a9c..f853d374a 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.ts @@ -3,7 +3,11 @@ import { Tinybird } from "@tinybirdco/sdk" import { Context, Effect, Layer, Option, Redacted } from "effect" import { WarehouseConfigError, + WarehouseConfigDecryptionError, WarehouseConfigLookupError, + WarehouseStoredConfigInvalidError, + WarehouseTokenConfigError, + WarehouseTokenMintError, type WarehouseQueryRequest, } from "@maple/domain/http" import { @@ -363,7 +367,7 @@ export class WarehouseQueryService extends Context.Service< ), "@maple/http/errors/OrgClickHouseSettingsEncryptionError": (error) => Effect.fail( - new WarehouseConfigError({ + new WarehouseConfigDecryptionError({ pipeName: label, message: error.message, cause: error, @@ -371,7 +375,7 @@ export class WarehouseQueryService extends Context.Service< ), "@maple/http/errors/OrgClickHouseSettingsValidationError": (error) => Effect.fail( - new WarehouseConfigError({ + new WarehouseStoredConfigInvalidError({ pipeName: label, message: error.message, cause: error, @@ -405,14 +409,24 @@ export class WarehouseQueryService extends Context.Service< const clientCacheKey = `raw:${tenant.orgId}` if (managed.config.kind === "tinybird" || managed.config.kind === "tinybird-gateway") { const jwt = yield* orgTokens.getOrgReadToken(tenant.orgId).pipe( - Effect.mapError( - (error) => - new WarehouseConfigError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), + Effect.catchTags({ + "@maple/api/services/TinybirdOrgTokenConfigError": (error) => + Effect.fail( + new WarehouseTokenConfigError({ + pipeName: label, + message: error.message, + cause: error, + }), + ), + "@maple/api/services/TinybirdOrgTokenMintError": (error) => + Effect.fail( + new WarehouseTokenMintError({ + pipeName: label, + message: error.message, + cause: error, + }), + ), + }), ) yield* Effect.annotateCurrentSpan("maple.tinybird.token.scope", "org_jwt") return { diff --git a/apps/api/src/services/warehouse/warehouse-error-handlers.ts b/apps/api/src/services/warehouse/warehouse-error-handlers.ts index 24c0d9509..46f922c3e 100644 --- a/apps/api/src/services/warehouse/warehouse-error-handlers.ts +++ b/apps/api/src/services/warehouse/warehouse-error-handlers.ts @@ -1,24 +1,9 @@ import type { Effect } from "effect" -import type { WarehouseError } from "@maple/domain" +import { warehouseErrorTags, type WarehouseError } from "@maple/domain" /** - * The one place the warehouse error union is enumerated for `Effect.catchTags`. - * Each consumer supplies its own conversion; adding a tag to `WarehouseError` - * means updating this table (and the meta Record in `@maple/domain`) instead of - * one hand-written 9-arm table per surface. The handler receives the union - * (every member is assignable), so the residual error channel still infers - * correctly at the call site. + * Derive an exhaustive `Effect.catchTags` table from the domain's canonical + * class tuple. Adding a warehouse error automatically updates every consumer. */ export const warehouseHandlers = (f: (error: WarehouseError) => Effect.Effect) => - ({ - "@maple/http/errors/WarehouseQueryError": f, - "@maple/http/errors/WarehouseUpstreamError": f, - "@maple/http/errors/WarehouseAuthError": f, - "@maple/http/errors/WarehouseConfigError": f, - "@maple/http/errors/WarehouseConfigLookupError": f, - "@maple/http/errors/WarehouseClientError": f, - "@maple/http/errors/WarehouseSchemaDriftError": f, - "@maple/http/errors/WarehouseMalformedQueryError": f, - "@maple/http/errors/WarehouseQuotaExceededError": f, - "@maple/http/errors/WarehouseValidationError": f, - }) as const + Object.fromEntries(warehouseErrorTags.map((tag) => [tag, f])) as Record diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 973c43004..04edcc690 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -227,7 +227,8 @@ const isMcpPost = (request: Request): boolean => { const isV2Request = (request: Request): boolean => { try { - return new URL(request.url).pathname.startsWith("/v2/") + const pathname = new URL(request.url).pathname + return pathname === "/v2" || pathname.startsWith("/v2/") } catch { return false } diff --git a/apps/web/src/components/common/error-state.test.tsx b/apps/web/src/components/common/error-state.test.tsx index 1e3547f53..a2db4a6ce 100644 --- a/apps/web/src/components/common/error-state.test.tsx +++ b/apps/web/src/components/common/error-state.test.tsx @@ -56,15 +56,20 @@ describe("ErrorState recovery actions", () => { expect(screen.getByRole("alert").textContent).not.toContain("Retrying automatically") }) - it("keeps timeouts manual instead of treating them as offline", () => { + it("automatically retries timeouts when their error contract opts in", () => { + const retry = vi.fn() render( , ) expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy() + expect(screen.getByRole("alert").textContent).toContain("Retrying automatically") + + act(() => vi.runAllTimers()) + expect(retry).toHaveBeenCalledTimes(6) expect(screen.getByRole("alert").textContent).not.toContain("Retrying automatically") }) }) diff --git a/apps/web/src/lib/error-messages.test.ts b/apps/web/src/lib/error-messages.test.ts index e7f7922c0..6252c2e9c 100644 --- a/apps/web/src/lib/error-messages.test.ts +++ b/apps/web/src/lib/error-messages.test.ts @@ -38,7 +38,6 @@ const errorEnvelope = ( retry_after_seconds: number retry_at: string param: string - doc_url: string }> = {}, ) => ({ error: { @@ -63,7 +62,6 @@ describe("publicError", () => { 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", }) @@ -162,7 +160,7 @@ describe("displayError", () => { expect(isAutomaticRetryError(displayed)).toBe(true) }) - it("keeps typed transport timeouts manual", () => { + it("automatically retries typed transport timeouts declared retryable", () => { const error = new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request: HttpClientRequest.get("https://api.maple.dev/v2/services"), @@ -176,7 +174,19 @@ describe("displayError", () => { message: "The API did not respond in time. Try again when you're ready.", recovery: "retry", }) - expect(isAutomaticRetryError(displayed)).toBe(false) + expect(isAutomaticRetryError(displayed)).toBe(true) + }) + + it("automatically retries decoded v2 failures only when the body opts in", () => { + const retryable = displayError( + errorEnvelope({ + _tag: "@maple/http/errors/WarehouseUpstreamError", + retryable: true, + recovery: "retry", + }), + ) + expect(isAutomaticRetryError(retryable)).toBe(true) + expect(isAutomaticRetryError(displayError(errorEnvelope()))).toBe(false) }) it("does not interpret raw tags or human-readable messages", () => { diff --git a/apps/web/src/lib/error-messages.ts b/apps/web/src/lib/error-messages.ts index dd335e0ac..8607e1696 100644 --- a/apps/web/src/lib/error-messages.ts +++ b/apps/web/src/lib/error-messages.ts @@ -129,7 +129,6 @@ const displayErrorInternal = (input: unknown, depth: number): AnyPublicHttpError /** Resolve any application failure to the single safe public error contract. */ export const displayError = (input: unknown): AnyPublicHttpErrorBody => displayErrorInternal(input, 0) -export const isAutomaticRetryError = (error: AnyPublicHttpErrorBody): boolean => - error._tag === NetworkErrorTag +export const isAutomaticRetryError = (error: AnyPublicHttpErrorBody): boolean => error.retryable export const isUnexpectedError = (error: AnyPublicHttpErrorBody): boolean => error._tag === UnexpectedErrorTag diff --git a/apps/web/src/lib/services/common/retry-policy.ts b/apps/web/src/lib/services/common/retry-policy.ts index 0d7d729b2..5ea2d2048 100644 --- a/apps/web/src/lib/services/common/retry-policy.ts +++ b/apps/web/src/lib/services/common/retry-policy.ts @@ -24,7 +24,8 @@ export const isRetryableTransportError = (error: unknown): boolean => { const isV2Request = (request: HttpClientRequest.HttpClientRequest): boolean => { try { - return new URL(request.url).pathname.startsWith("/v2/") + const pathname = new URL(request.url).pathname + return pathname === "/v2" || pathname.startsWith("/v2/") } catch { return false } diff --git a/docs/api-v2.md b/docs/api-v2.md index b18d2e96e..5ca018520 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -104,13 +104,13 @@ Every error response body uses this envelope: - `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`. +- `param` names the offending parameter when applicable. 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 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/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. +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. Static code, title, safe message, retryability, and recovery values are literals in OpenAPI as well as at runtime. Handlers fail with the original tagged error; there is no generic route-level serializer, remapper, or second per-domain presentation table at the HTTP boundary. A route may still deliberately translate a parsing failure or a response-size limit into the endpoint-specific error that describes it. `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 diff --git a/lib/clickhouse-builder/src/ch/compile.ts b/lib/clickhouse-builder/src/ch/compile.ts index d5e989f75..a7263bb0f 100644 --- a/lib/clickhouse-builder/src/ch/compile.ts +++ b/lib/clickhouse-builder/src/ch/compile.ts @@ -71,7 +71,7 @@ const orderByClause = (specs: ReadonlyArray<[string, "asc" | "desc"]>): Array { +interface CompiledQueryBase { readonly sql: string readonly tenantScope: TenantScope /** Whether a `rowSchema` was supplied. Lets a catalog sweep see the queries @@ -82,7 +82,6 @@ export interface CompiledQuery { * exists in the managed ingest pipeline (declared via `.routing("ingest")` at * the query definition), so executors read it there instead of a per-org * warehouse override. */ - readonly routing?: "ingest" /** Runtime decode of raw query results. Queries built from handwritten SQL * should provide a row schema so schema drift is caught before consumers * read fields from `Record`. Without a schema this is an @@ -98,19 +97,30 @@ export interface CompiledQuery { ) => Effect.Effect, CompiledQueryDecodeError> } +/** + * Routing is a type-level fact as well as runtime metadata. An ingest-routed + * query therefore cannot be passed accidentally to an API that may consult a + * per-org read configuration. + */ +export type CompiledQuery< + Output, + Routing extends "ingest" | undefined = "ingest" | undefined, +> = CompiledQueryBase & + (Routing extends "ingest" ? { readonly routing: "ingest" } : { readonly routing?: undefined }) + export type CompiledQueryRowSchema = Schema.Schema -const makeCompiledQuery = ( +const makeCompiledQuery = ( sql: string, tenantScope: TenantScope, rowSchema?: CompiledQueryRowSchema, - routing?: "ingest", -): CompiledQuery => { + routing?: Routing, +): CompiledQuery => { const decodeRow = rowSchema ? (Schema.decodeUnknownEffect(rowSchema) as (row: unknown) => Effect.Effect) : undefined - const decodeRows: CompiledQuery["decodeRows"] = (rows) => { + const decodeRows: CompiledQueryBase["decodeRows"] = (rows) => { if (!rowSchema) return Effect.succeed(rows as unknown as ReadonlyArray) if (!decodeRow) return Effect.succeed(rows as unknown as ReadonlyArray) @@ -151,7 +161,7 @@ const makeCompiledQuery = ( ), ) }, - } + } as CompiledQuery } /** @@ -204,20 +214,22 @@ export type RawSqlReason = * DDL, migrations, and another engine's file formats don't reach this function * at all; they never produce a `CompiledQuery`. */ -export const unsafeCompiledQuery = (args: { +export const unsafeCompiledQuery = (args: { readonly sql: string readonly tenantScope: TenantScope readonly reason: RawSqlReason /** One sentence, at the call site, on why this instance qualifies. */ readonly note: string readonly rowSchema?: CompiledQueryRowSchema - readonly routing?: "ingest" -}): CompiledQuery => makeCompiledQuery(args.sql, args.tenantScope, args.rowSchema, args.routing) + readonly routing?: Routing +}): CompiledQuery => + makeCompiledQuery(args.sql, args.tenantScope, args.rowSchema, args.routing) export function compileCH< Cols extends ColumnDefs, Output extends Record, Joins extends Record, + Routing extends "ingest" | undefined, Params extends Record, // The row schema, not the SELECT inference, is what actually produces values // at runtime, so it decides the compiled query's output type. `extends Output` @@ -225,10 +237,10 @@ export function compileCH< // column decoded as a literal union) but never contradict it. Decoded extends Output = Output, >( - query: CHQuery, + query: CHQuery, params: Params, options?: { skipFormat?: boolean; rowSchema?: CompiledQueryRowSchema }, -): CompiledQuery { +): CompiledQuery { const state = query._state // Build column accessor — joined or simple depending on joins @@ -377,9 +389,12 @@ export function compileCH< ? "org" : "cross-org" - return { - ...makeCompiledQuery(sql, tenantScope, options?.rowSchema, state.routingValue), - } + return makeCompiledQuery( + sql, + tenantScope, + options?.rowSchema, + state.routingValue as Routing, + ) } // UNION ALL compilation @@ -388,7 +403,7 @@ export function compileUnion, Params extends union: CHUnionQuery, params: Params, options?: { rowSchema?: CompiledQueryRowSchema }, -): CompiledQuery { +): CompiledQuery { const state = union._state // Compile each sub-query without FORMAT @@ -422,9 +437,7 @@ export function compileUnion, Params extends sql += `\nFORMAT ${state.formatValue}` } - return { - ...makeCompiledQuery(sql, tenantScope, options?.rowSchema), - } + return makeCompiledQuery(sql, tenantScope, options?.rowSchema) } function resolveParam(value: unknown): string { diff --git a/lib/clickhouse-builder/src/ch/query.ts b/lib/clickhouse-builder/src/ch/query.ts index f0492b639..67791cd64 100644 --- a/lib/clickhouse-builder/src/ch/query.ts +++ b/lib/clickhouse-builder/src/ch/query.ts @@ -113,27 +113,28 @@ export interface CHQuery< Cols extends ColumnDefs = ColumnDefs, Output extends Record = {}, Joins extends Record = {}, + Routing extends "ingest" | undefined = "ingest" | undefined, > { /** @internal — runtime query state */ readonly _state: CHQueryState /** phantom */ - readonly _phantom?: { cols: Cols; output: Output; joins: Joins } + readonly _phantom?: { cols: Cols; output: Output; joins: Joins; routing: Routing } /** Select specific columns by name. Output keys match column names. */ select( ...columns: K[] - ): CHQuery }, Joins> + ): CHQuery }, Joins, Routing> /** Select computed expressions via callback. */ select( fn: ($: JoinedColumnAccessor) => S, - ): CHQuery, Joins> + ): CHQuery, Joins, Routing> where( fn: ($: JoinedColumnAccessor) => Array, - ): CHQuery + ): CHQuery - groupBy(...keys: Array): CHQuery + groupBy(...keys: Array): CHQuery /** * Post-aggregation filter, applied after `GROUP BY`. @@ -148,15 +149,15 @@ export interface CHQuery< */ having( fn: ($: JoinedColumnAccessor) => Array, - ): CHQuery + ): CHQuery - orderBy(...specs: Array>): CHQuery + orderBy(...specs: Array>): CHQuery - limit(n: number): CHQuery + limit(n: number): CHQuery - offset(n: number): CHQuery + offset(n: number): CHQuery - format(fmt: "JSON" | "JSONEachRow"): CHQuery + format(fmt: "JSON" | "JSONEachRow"): CHQuery /** * Declare that this query reads a datasource that only exists in the managed @@ -164,7 +165,7 @@ export interface CHQuery< * a deliberately cross-org managed scan). The executor routes it to the * ingest backend instead of a per-org read override. */ - routing(route: "ingest"): CHQuery + routing(route: "ingest"): CHQuery /** * Declare that this query deliberately reads across every tenant, forcing @@ -175,7 +176,7 @@ export interface CHQuery< * from "someone forgot the `OrgId` filter" until an author says which. * Executors are expected to refuse these on the ordinary read path. */ - crossOrg(): CHQuery + crossOrg(): CHQuery // Type-safe joins with Table @@ -183,18 +184,18 @@ export interface CHQuery< table: Table, alias: Alias, on: JoinOnCallback, - ): CHQuery + ): CHQuery leftJoin( table: Table, alias: Alias, on: JoinOnCallback, - ): CHQuery }> + ): CHQuery }, Routing> crossJoin( table: Table, alias: Alias, - ): CHQuery + ): CHQuery // Type-safe joins with subquery (CHQuery) @@ -207,7 +208,7 @@ export interface CHQuery< query: CHQuery, alias: Alias, on: JoinOnCallback>, - ): CHQuery }> + ): CHQuery }, Routing> leftJoinQuery< JCols extends ColumnDefs, @@ -221,7 +222,8 @@ export interface CHQuery< ): CHQuery< Cols, Output, - Joins & { readonly [K in Alias]: NullableColumnDefs> } + Joins & { readonly [K in Alias]: NullableColumnDefs> }, + Routing > crossJoinQuery< @@ -232,7 +234,7 @@ export interface CHQuery< >( query: CHQuery, alias: Alias, - ): CHQuery }> + ): CHQuery }, Routing> /** * Add a CTE (WITH clause). The CTE is prepended to the compiled query, and @@ -243,7 +245,7 @@ export interface CHQuery< * scope is *derived*, so a query whose only row source is a scoped CTE is * itself scoped without anyone asserting it. */ - withCTE(name: string, query: CHQuery): CHQuery + withCTE(name: string, query: CHQuery): CHQuery /** * Attach a CTE from pre-compiled SQL. @@ -258,7 +260,7 @@ export interface CHQuery< name: string, sql: string, options?: { readonly tenantScope?: TenantScope }, - ): CHQuery + ): CHQuery } // Type utilities for extracting output types from queries @@ -340,7 +342,8 @@ function makeQuery< Cols extends ColumnDefs, Output extends Record, Joins extends Record, ->(state: CHQueryState): CHQuery { + Routing extends "ingest" | undefined, +>(state: CHQueryState): CHQuery { return { _state: state, @@ -490,7 +493,7 @@ function makeQuery< export function from( table: Table, alias?: string, -): CHQuery { +): CHQuery { return makeQuery({ tableName: table.name, tableAlias: alias, @@ -522,7 +525,7 @@ export function fromQuery< >( query: CHQuery, alias: Alias, -): CHQuery, {}, {}> { +): CHQuery, {}, {}, undefined> { return makeQuery({ tableName: alias, columns: {}, @@ -553,7 +556,7 @@ export function fromQuery< export function fromUnion, Alias extends string>( union: import("./union").CHUnionQuery, alias: Alias, -): CHQuery, {}, {}> { +): CHQuery, {}, {}, undefined> { return makeQuery({ tableName: alias, columns: {}, diff --git a/packages/alchemy-maple/src/AlertDestination.ts b/packages/alchemy-maple/src/AlertDestination.ts index 4b8d1c544..14cbfd11c 100644 --- a/packages/alchemy-maple/src/AlertDestination.ts +++ b/packages/alchemy-maple/src/AlertDestination.ts @@ -5,6 +5,7 @@ import { deepEqual, isResolved } from "alchemy/Diff" import * as Provider from "alchemy/Provider" import { Resource } from "alchemy/Resource" import { listAll, MapleApi } from "./MapleApi" +import { MapleErrorTags } from "./errors" import type { Providers } from "./Providers" /** A write-only channel secret: plain string or `Redacted` (recommended). */ @@ -148,7 +149,7 @@ export const AlertDestinationProvider = () => const fetched = yield* api .get(`/v2/alerts/destinations/${output.destinationId}`) .pipe( - Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.catchTag(MapleErrorTags.alertDestinationNotFound, () => Effect.succeed(undefined), ), ) @@ -176,14 +177,14 @@ export const AlertDestinationProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/alerts/destinations/${output.destinationId}`) - .pipe(Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => Effect.void)) + .pipe(Effect.catchTag(MapleErrorTags.alertDestinationNotFound, () => Effect.void)) }), read: Effect.fn(function* ({ output }) { if (!output?.destinationId) return undefined const fetched = yield* api .get(`/v2/alerts/destinations/${output.destinationId}`) .pipe( - Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.catchTag(MapleErrorTags.alertDestinationNotFound, () => Effect.succeed(undefined), ), ) diff --git a/packages/alchemy-maple/src/AlertRule.ts b/packages/alchemy-maple/src/AlertRule.ts index 999a3a595..fa935c5ce 100644 --- a/packages/alchemy-maple/src/AlertRule.ts +++ b/packages/alchemy-maple/src/AlertRule.ts @@ -4,6 +4,7 @@ import { deepEqual, isResolved } from "alchemy/Diff" import * as Provider from "alchemy/Provider" import { Resource } from "alchemy/Resource" import { listAll, MapleApi } from "./MapleApi" +import { MapleErrorTags } from "./errors" import type { Providers } from "./Providers" export type AlertSignalType = @@ -151,7 +152,7 @@ export const AlertRuleProvider = () => observedRaw = yield* api .get(`/v2/alerts/rules/${output.ruleId}`) .pipe( - Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.catchTag(MapleErrorTags.alertRuleNotFound, () => Effect.succeed(undefined), ), ) @@ -175,14 +176,14 @@ export const AlertRuleProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/alerts/rules/${output.ruleId}`) - .pipe(Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => Effect.void)) + .pipe(Effect.catchTag(MapleErrorTags.alertRuleNotFound, () => Effect.void)) }), read: Effect.fn(function* ({ olds, output }) { if (output?.ruleId) { const fetched = yield* api .get(`/v2/alerts/rules/${output.ruleId}`) .pipe( - Effect.catchTag("@maple/http/errors/AlertNotFoundError", () => + Effect.catchTag(MapleErrorTags.alertRuleNotFound, () => Effect.succeed(undefined), ), ) diff --git a/packages/alchemy-maple/src/ApiKey.ts b/packages/alchemy-maple/src/ApiKey.ts index 9acf75920..206ea7f37 100644 --- a/packages/alchemy-maple/src/ApiKey.ts +++ b/packages/alchemy-maple/src/ApiKey.ts @@ -5,6 +5,7 @@ import { deepEqual, isResolved } from "alchemy/Diff" import * as Provider from "alchemy/Provider" import { Resource } from "alchemy/Resource" import { listAll, MapleApi } from "./MapleApi" +import { MapleErrorTags } from "./errors" import type { Providers } from "./Providers" /** @@ -115,7 +116,7 @@ export const ApiKeyProvider = () => const fetched = yield* api .get(`/v2/api_keys/${output.keyId}`) .pipe( - Effect.catchTag("@maple/http/errors/ApiKeyNotFoundError", () => + Effect.catchTag(MapleErrorTags.apiKeyNotFound, () => Effect.succeed(undefined), ), ) @@ -147,17 +148,13 @@ export const ApiKeyProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/api_keys/${output.keyId}`) - .pipe(Effect.catchTag("@maple/http/errors/ApiKeyNotFoundError", () => Effect.void)) + .pipe(Effect.catchTag(MapleErrorTags.apiKeyNotFound, () => Effect.void)) }), read: Effect.fn(function* ({ output }) { if (!output?.keyId) return undefined const fetched = yield* api .get(`/v2/api_keys/${output.keyId}`) - .pipe( - Effect.catchTag("@maple/http/errors/ApiKeyNotFoundError", () => - Effect.succeed(undefined), - ), - ) + .pipe(Effect.catchTag(MapleErrorTags.apiKeyNotFound, () => Effect.succeed(undefined))) if (fetched === undefined) return undefined const wire = yield* decodeWireApiKey(fetched) if (wire.revoked) return undefined diff --git a/packages/alchemy-maple/src/Dashboard.ts b/packages/alchemy-maple/src/Dashboard.ts index 89a52554e..26d88c08b 100644 --- a/packages/alchemy-maple/src/Dashboard.ts +++ b/packages/alchemy-maple/src/Dashboard.ts @@ -4,6 +4,7 @@ import { deepEqual, isResolved } from "alchemy/Diff" import * as Provider from "alchemy/Provider" import { Resource } from "alchemy/Resource" import { listAll, MapleApi } from "./MapleApi" +import { MapleErrorTags } from "./errors" import type { Providers } from "./Providers" /** @@ -118,7 +119,7 @@ export const DashboardProvider = () => const fetched = yield* api .get(`/v2/dashboards/${output.dashboardId}`) .pipe( - Effect.catchTag("@maple/http/errors/DashboardNotFoundError", () => + Effect.catchTag(MapleErrorTags.dashboardNotFound, () => Effect.succeed(undefined), ), ) @@ -138,14 +139,14 @@ export const DashboardProvider = () => delete: Effect.fn(function* ({ output }) { yield* api .delete(`/v2/dashboards/${output.dashboardId}`) - .pipe(Effect.catchTag("@maple/http/errors/DashboardNotFoundError", () => Effect.void)) + .pipe(Effect.catchTag(MapleErrorTags.dashboardNotFound, () => Effect.void)) }), read: Effect.fn(function* ({ output }) { if (!output?.dashboardId) return undefined const fetched = yield* api .get(`/v2/dashboards/${output.dashboardId}`) .pipe( - Effect.catchTag("@maple/http/errors/DashboardNotFoundError", () => + Effect.catchTag(MapleErrorTags.dashboardNotFound, () => Effect.succeed(undefined), ), ) diff --git a/packages/alchemy-maple/src/MapleApi.ts b/packages/alchemy-maple/src/MapleApi.ts index 30db0a6be..186ec3519 100644 --- a/packages/alchemy-maple/src/MapleApi.ts +++ b/packages/alchemy-maple/src/MapleApi.ts @@ -7,12 +7,18 @@ import * as Redacted from "effect/Redacted" import * as Schema from "effect/Schema" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { - MapleApiClientError, + MapleApiProtocolError, + MapleApiRequestEncodingError, + MapleApiResponseDecodeError, + MapleApiResponseReadError, + MapleApiTransportError, + MapleErrorTags, MaplePublicErrorBodySchema, isMapleApiResponseError, makeMapleApiResponseError, type MapleApiResponseError, type MapleError, + type MaplePublicErrorType, } from "./errors" import { MapleEnvironment } from "./MapleEnvironment" @@ -36,19 +42,80 @@ export interface MapleApiShape { export class MapleApi extends Context.Service()("Maple::Api") {} const ErrorEnvelope = Schema.Struct({ error: MaplePublicErrorBodySchema }) -const decodeErrorEnvelope = Schema.decodeUnknownSync(Schema.fromJsonString(ErrorEnvelope)) +const decodeErrorEnvelope = Schema.decodeUnknownEffect(Schema.fromJsonString(ErrorEnvelope)) +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)) -const errorFromResponse = (status: number, bodyText: string): MapleError => { - try { - return makeMapleApiResponseError(status, decodeErrorEnvelope(bodyText).error) - } catch (cause) { - return new MapleApiClientError({ +const errorTypeForStatus = (status: number): MaplePublicErrorType | undefined => { + switch (status) { + case 400: + case 413: + return "invalid_request_error" + case 401: + return "authentication_error" + case 403: + return "permission_error" + case 404: + return "not_found_error" + case 409: + return "conflict_error" + case 429: + return "rate_limit_error" + case 500: + case 502: + case 503: + case 504: + return "api_error" + default: + return undefined + } +} + +const notFoundTagForPath = (path: string): string | undefined => { + const pathname = path.split("?", 1)[0] ?? path + if (pathname.startsWith("/v2/api_keys/")) return MapleErrorTags.apiKeyNotFound + if (pathname.startsWith("/v2/dashboards/")) return MapleErrorTags.dashboardNotFound + if (pathname.startsWith("/v2/alerts/rules/") || pathname.startsWith("/v2/alerts/destinations/")) { + return pathname.startsWith("/v2/alerts/rules/") + ? MapleErrorTags.alertRuleNotFound + : MapleErrorTags.alertDestinationNotFound + } + return undefined +} + +const errorFromResponse = Effect.fn("MapleApi.errorFromResponse")(function* ( + status: number, + path: string, + bodyText: string, +) { + const envelope = yield* decodeErrorEnvelope(bodyText).pipe( + Effect.mapError( + () => + new MapleApiProtocolError({ + status, + message: `Maple API returned an invalid error envelope with status ${status}`, + }), + ), + ) + const expectedType = errorTypeForStatus(status) + if (expectedType === undefined || envelope.error.type !== expectedType) { + return yield* new MapleApiProtocolError({ status, - message: `Maple API returned an invalid error response with status ${status}`, - cause, + message: `Maple API error type ${envelope.error.type} does not match status ${status}`, }) } -} + const expectedNotFoundTag = notFoundTagForPath(path) + if ( + envelope.error.type === "not_found_error" && + expectedNotFoundTag !== undefined && + envelope.error._tag !== expectedNotFoundTag + ) { + return yield* new MapleApiProtocolError({ + status, + message: `Maple API returned ${envelope.error._tag} for ${path}; expected ${expectedNotFoundTag}`, + }) + } + return makeMapleApiResponseError(status, envelope.error) +}) const retryDelay = Effect.fn("MapleApi.retryDelay")(function* ( error: MapleApiResponseError, @@ -87,9 +154,8 @@ export const make = Effect.gen(function* () { req = yield* HttpClientRequest.bodyJson(req, body).pipe( Effect.mapError( (error) => - new MapleApiClientError({ - status: 0, - message: `Failed to encode request body: ${String(error)}`, + new MapleApiRequestEncodingError({ + message: "Failed to encode Maple API request body", cause: error, }), ), @@ -98,8 +164,7 @@ export const make = Effect.gen(function* () { const response = yield* httpClient.execute(req).pipe( Effect.mapError( (error) => - new MapleApiClientError({ - status: 0, + new MapleApiTransportError({ message: `Maple API request failed: ${error.message}`, cause: error, }), @@ -109,7 +174,7 @@ export const make = Effect.gen(function* () { const text = yield* response.text.pipe( Effect.mapError( (error) => - new MapleApiClientError({ + new MapleApiResponseReadError({ status: response.status, message: `Failed to read response: ${error.message}`, cause: error, @@ -117,18 +182,18 @@ export const make = Effect.gen(function* () { ), ) if (response.status >= 200 && response.status < 300) { - if (text.length === 0) return undefined as unknown - return yield* Effect.try({ - try: () => JSON.parse(text) as unknown, - catch: (cause) => - new MapleApiClientError({ - status: response.status, - message: `Maple API returned invalid JSON (status ${response.status})`, - cause, - }), - }) + if (text.length === 0) return undefined + return yield* decodeJson(text).pipe( + Effect.mapError( + () => + new MapleApiResponseDecodeError({ + status: response.status, + message: `Maple API returned invalid JSON (status ${response.status})`, + }), + ), + ) } - return yield* Effect.fail(errorFromResponse(response.status, text)) + return yield* Effect.fail(yield* errorFromResponse(response.status, path, text)) }).pipe( Effect.catchIf(isMapleApiResponseError, (error) => canAutomaticallyRetry && error.error.retryable && attempt < 6 diff --git a/packages/alchemy-maple/src/errors.ts b/packages/alchemy-maple/src/errors.ts index 48170bb19..f721c32d6 100644 --- a/packages/alchemy-maple/src/errors.ts +++ b/packages/alchemy-maple/src/errors.ts @@ -9,6 +9,7 @@ export const MaplePublicErrorType = Schema.Literals([ "rate_limit_error", "api_error", ]) +export type MaplePublicErrorType = Schema.Schema.Type export const MapleErrorRecovery = Schema.Literals([ "none", @@ -21,9 +22,21 @@ export const MapleErrorRecovery = Schema.Literals([ "contact_support", ]) -/** Published mirror of Maple's canonical v2 error body. Kept honest by the domain contract test. */ +/** Public HTTP tags are disjoint from this package's client-side error tags. */ +export const MapleHttpErrorTagSchema = Schema.TemplateLiteral(["@maple/http/", Schema.String]) +export type MapleHttpErrorTag = Schema.Schema.Type + +/** Stable tags used for provider lifecycle decisions. */ +export const MapleErrorTags = { + apiKeyNotFound: "@maple/http/errors/ApiKeyNotFoundError", + dashboardNotFound: "@maple/http/errors/DashboardNotFoundError", + alertRuleNotFound: "@maple/http/errors/AlertRuleNotFoundError", + alertDestinationNotFound: "@maple/http/errors/AlertDestinationNotFoundError", +} as const satisfies Record + +/** Published mirror of Maple's canonical v2 error body. Kept honest by contract tests. */ export const MaplePublicErrorBodySchema = Schema.Struct({ - _tag: Schema.String.check(Schema.isPattern(/^@maple\//)), + _tag: MapleHttpErrorTagSchema, type: MaplePublicErrorType, code: Schema.String, title: Schema.String, @@ -39,56 +52,70 @@ export const MaplePublicErrorBodySchema = Schema.Struct({ ), ), param: Schema.optionalKey(Schema.String), - doc_url: Schema.optionalKey(Schema.String), }) export type MaplePublicErrorBody = Schema.Schema.Type +const MapleApiResponseErrorBrand: unique symbol = Symbol("MapleApiResponseError") + /** A declared v2 API failure whose Effect tag is the exact server tag. */ -export interface MapleApiResponseError extends Error { +export interface MapleApiResponseError extends Error { readonly _tag: Tag readonly status: number readonly error: MaplePublicErrorBody & { readonly _tag: Tag } + readonly [MapleApiResponseErrorBrand]: true } -type MapleApiResponseErrorConstructor = new (fields: { - readonly status: number - readonly error: MaplePublicErrorBody -}) => MapleApiResponseError - -const responseErrorClasses = new Map() - -/** Build a real Schema.TaggedError class per public server tag, cached for reuse. */ -export const makeMapleApiResponseError = ( +/** Construct an exact tagged failure without caching classes from untrusted input. */ +export const makeMapleApiResponseError = ( status: number, - error: MaplePublicErrorBody, -): MapleApiResponseError => { - let ErrorClass = responseErrorClasses.get(error._tag) - if (ErrorClass === undefined) { - class TaggedResponseError extends Schema.TaggedError()(error._tag, { - status: Schema.Number, - error: MaplePublicErrorBodySchema, - }) { - override get message(): string { - return this.error.message - } + error: MaplePublicErrorBody & { readonly _tag: Tag }, +): MapleApiResponseError => { + class TaggedResponseError extends Schema.TaggedError()(error._tag, { + status: Schema.Number, + error: MaplePublicErrorBodySchema, + }) { + readonly [MapleApiResponseErrorBrand] = true as const + + override get message(): string { + return this.error.message } - ErrorClass = TaggedResponseError - responseErrorClasses.set(error._tag, ErrorClass) } - return new ErrorClass({ status, error }) + return new TaggedResponseError({ status, error }) as unknown as MapleApiResponseError } -/** A client-side transport, encoding, body-read, or protocol failure. */ -export class MapleApiClientError extends Schema.TaggedError()( - "@maple/alchemy/errors/ApiClientError", - { - status: Schema.Number, - message: Schema.String, - cause: Schema.optionalKey(Schema.Defect()), - }, +export class MapleApiRequestEncodingError extends Schema.TaggedError()( + "@maple/alchemy/errors/RequestEncodingError", + { message: Schema.String, cause: Schema.Defect() }, ) {} -export type MapleError = MapleApiResponseError | MapleApiClientError +export class MapleApiTransportError extends Schema.TaggedError()( + "@maple/alchemy/errors/TransportError", + { message: Schema.String, cause: Schema.Defect() }, +) {} + +export class MapleApiResponseReadError extends Schema.TaggedError()( + "@maple/alchemy/errors/ResponseReadError", + { status: Schema.Number, message: Schema.String, cause: Schema.Defect() }, +) {} + +export class MapleApiResponseDecodeError extends Schema.TaggedError()( + "@maple/alchemy/errors/ResponseDecodeError", + { status: Schema.Number, message: Schema.String }, +) {} + +export class MapleApiProtocolError extends Schema.TaggedError()( + "@maple/alchemy/errors/ProtocolError", + { status: Schema.Number, message: Schema.String }, +) {} + +export type MapleClientError = + | MapleApiRequestEncodingError + | MapleApiTransportError + | MapleApiResponseReadError + | MapleApiResponseDecodeError + | MapleApiProtocolError + +export type MapleError = MapleApiResponseError | MapleClientError export const isMapleApiResponseError = (error: MapleError): error is MapleApiResponseError => - error._tag !== "@maple/alchemy/errors/ApiClientError" + MapleApiResponseErrorBrand in error && error[MapleApiResponseErrorBrand] === true diff --git a/packages/alchemy-maple/src/index.ts b/packages/alchemy-maple/src/index.ts index 55166d92f..be0f99deb 100644 --- a/packages/alchemy-maple/src/index.ts +++ b/packages/alchemy-maple/src/index.ts @@ -45,12 +45,20 @@ export { Dashboard, DashboardProvider, type DashboardProps } from "./Dashboard" export { isMapleApiResponseError, makeMapleApiResponseError, - MapleApiClientError, + MapleApiProtocolError, + MapleApiRequestEncodingError, + MapleApiResponseDecodeError, + MapleApiResponseReadError, + MapleApiTransportError, + MapleErrorTags, MapleErrorRecovery, + MapleHttpErrorTagSchema, MaplePublicErrorBodySchema, MaplePublicErrorType, type MapleError, type MapleApiResponseError, + type MapleClientError, + type MapleHttpErrorTag, type MaplePublicErrorBody, } from "./errors" export { IngestKeys, IngestKeysProvider, type IngestKeysProps } from "./IngestKeys" diff --git a/packages/alchemy-maple/test/contract.test.ts b/packages/alchemy-maple/test/contract.test.ts index 82cc2e78b..d28fe99bc 100644 --- a/packages/alchemy-maple/test/contract.test.ts +++ b/packages/alchemy-maple/test/contract.test.ts @@ -5,7 +5,14 @@ */ import { describe, expect, it } from "vitest" import { Effect, Schema } from "effect" -import { PublicHttpErrorBodySchema, type AnyPublicHttpErrorBody } from "@maple/domain/http" +import { + ApiKeyNotFoundError, + AlertDestinationNotFoundError, + AlertRuleNotFoundError, + DashboardNotFoundError, + PublicHttpErrorBodySchema, + type AnyPublicHttpErrorBody, +} from "@maple/domain/http" import { V2AlertDestinationCreateParams, V2AlertRuleCreateParams, @@ -17,12 +24,20 @@ import { _alertDestinationCreateBody } from "../src/AlertDestination" import { _alertRuleCreateBody } from "../src/AlertRule" import { _apiKeyCreateBody } from "../src/ApiKey" import { _dashboardCreateBody } from "../src/Dashboard" -import { MaplePublicErrorBodySchema, type MaplePublicErrorBody } from "../src/errors" +import { MapleErrorTags, MaplePublicErrorBodySchema, type MaplePublicErrorBody } from "../src/errors" const _clientErrorBodySatisfiesDomain = (body: MaplePublicErrorBody): AnyPublicHttpErrorBody => body -const _domainErrorBodySatisfiesClient = (body: AnyPublicHttpErrorBody): MaplePublicErrorBody => body void _clientErrorBodySatisfiesDomain -void _domainErrorBodySatisfiesClient + +const _apiKeyNotFoundTag: ApiKeyNotFoundError["_tag"] = MapleErrorTags.apiKeyNotFound +const _dashboardNotFoundTag: DashboardNotFoundError["_tag"] = MapleErrorTags.dashboardNotFound +const _alertRuleNotFoundTag: AlertRuleNotFoundError["_tag"] = MapleErrorTags.alertRuleNotFound +const _alertDestinationNotFoundTag: AlertDestinationNotFoundError["_tag"] = + MapleErrorTags.alertDestinationNotFound +void _apiKeyNotFoundTag +void _dashboardNotFoundTag +void _alertRuleNotFoundTag +void _alertDestinationNotFoundTag const decodes = >(schema: S, wire: unknown) => Effect.runSync(Schema.decodeUnknownEffect(schema)(wire).pipe(Effect.asVoid)) diff --git a/packages/alchemy-maple/test/maple-api.test.ts b/packages/alchemy-maple/test/maple-api.test.ts index 3d43e17f0..7d4f72d30 100644 --- a/packages/alchemy-maple/test/maple-api.test.ts +++ b/packages/alchemy-maple/test/maple-api.test.ts @@ -27,6 +27,22 @@ const errorEnvelope = (overrides: { }, }) +const retryableErrorEnvelope = (overrides: { + readonly retry_after_seconds?: number + readonly retry_at?: string +}) => ({ + error: { + _tag: "@maple/http/errors/WarehouseUpstreamError", + type: "api_error", + code: "warehouse_unavailable", + title: "Database is temporarily unavailable", + message: "Retry in a few seconds.", + retryable: true, + recovery: "retry", + ...overrides, + }, +}) + const clientLayer = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => Layer.succeed( HttpClient.HttpClient, @@ -60,9 +76,7 @@ describe("MapleApi errors", () => { attempts += 1 return attempts === 1 ? new Response( - JSON.stringify( - errorEnvelope({ retryable: true, retry_at: "1970-01-01T00:00:00.000Z" }), - ), + JSON.stringify(retryableErrorEnvelope({ retry_at: "1970-01-01T00:00:00.000Z" })), { status: 503, headers: { "content-type": "application/json" } }, ) : new Response(JSON.stringify({ ok: true }), { @@ -83,7 +97,7 @@ describe("MapleApi errors", () => { let attempts = 0 const http = clientLayer(() => { attempts += 1 - return new Response(JSON.stringify(errorEnvelope({ retryable: true })), { + return new Response(JSON.stringify(retryableErrorEnvelope({})), { status: 503, headers: { "content-type": "application/json" }, }) @@ -105,8 +119,7 @@ describe("MapleApi errors", () => { return attempts === 1 ? new Response( JSON.stringify( - errorEnvelope({ - retryable: true, + retryableErrorEnvelope({ retry_after_seconds: 5, retry_at: "1970-01-01T00:00:02.000Z", }), @@ -130,12 +143,38 @@ describe("MapleApi errors", () => { ) }) + it.effect("waits until a future retry_at before retrying", () => { + let attempts = 0 + const http = clientLayer(() => { + attempts += 1 + return attempts === 1 + ? new Response( + JSON.stringify(retryableErrorEnvelope({ retry_at: "1970-01-01T00:00:05.000Z" })), + { status: 503 }, + ) + : new Response(JSON.stringify({ ok: true }), { status: 200 }) + }) + return Effect.gen(function* () { + const api = yield* MapleApi + const fiber = yield* Effect.forkChild(api.get("/v2/api_keys/key_retry")) + yield* TestClock.adjust(Duration.zero) + expect(attempts).toBe(1) + yield* TestClock.adjust(Duration.seconds(4)) + expect(attempts).toBe(1) + yield* TestClock.adjust(Duration.seconds(1)) + expect(yield* Fiber.join(fiber)).toEqual({ ok: true }) + expect(attempts).toBe(2) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + it.effect("stops after six retries", () => { let attempts = 0 const http = clientLayer(() => { attempts += 1 return new Response( - JSON.stringify(errorEnvelope({ retryable: true, retry_at: "1970-01-01T00:00:00.000Z" })), + JSON.stringify(retryableErrorEnvelope({ retry_at: "1970-01-01T00:00:00.000Z" })), { status: 503, headers: { "content-type": "application/json" } }, ) }) @@ -148,4 +187,48 @@ describe("MapleApi errors", () => { Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), ) }) + + it.effect("rejects a body whose category does not match its HTTP status", () => { + const http = clientLayer( + () => + new Response(JSON.stringify(errorEnvelope({ retryable: false })), { + status: 503, + }), + ) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.get("/v2/api_keys/key_missing")) + expect(error._tag).toBe("@maple/alchemy/errors/ProtocolError") + expect(isMapleApiResponseError(error)).toBe(false) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("rejects a not-found tag for the wrong endpoint", () => { + const wrong = errorEnvelope({ retryable: false }) + wrong.error._tag = "@maple/http/errors/DashboardNotFoundError" + const http = clientLayer(() => new Response(JSON.stringify(wrong), { status: 404 })) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.get("/v2/api_keys/key_missing")) + expect(error._tag).toBe("@maple/alchemy/errors/ProtocolError") + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("rejects a client-side tag masquerading as a server failure", () => { + const wrong = errorEnvelope({ retryable: false }) + wrong.error._tag = "@maple/alchemy/errors/ProtocolError" + const http = clientLayer(() => new Response(JSON.stringify(wrong), { status: 404 })) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.get("/v2/api_keys/key_missing")) + expect(error._tag).toBe("@maple/alchemy/errors/ProtocolError") + expect(isMapleApiResponseError(error)).toBe(false) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) }) diff --git a/packages/domain/package.json b/packages/domain/package.json index e3e422622..0cae7e456 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -10,6 +10,7 @@ "./glob": "./src/glob.ts", "./http": "./src/http/index.ts", "./http/v2": "./src/http/v2/index.ts", + "./http/v2-worker-unavailable": "./src/http/v2/worker-unavailable.ts", "./internal-rpc": "./src/internal-rpc.ts", "./llm": "./src/llm.ts", "./permission": "./src/permission.ts", diff --git a/packages/domain/src/anticipated-errors.test.ts b/packages/domain/src/anticipated-errors.test.ts index b68d4f466..ae471941f 100644 --- a/packages/domain/src/anticipated-errors.test.ts +++ b/packages/domain/src/anticipated-errors.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest" import { ANTICIPATED_ERROR_IDENTIFIERS, isAnticipatedErrorIdentifier } from "./anticipated-errors" describe("ANTICIPATED_ERROR_IDENTIFIERS", () => { - it("includes legacy tags and v2 Schema.Error names for 4xx business errors", () => { + it("includes exact tagged-error identifiers for 4xx business errors", () => { for (const identifier of [ "@maple/http/errors/UnauthorizedError", "@maple/http/errors/RawSqlValidationError", "@maple/http/errors/IntegrationsNotConnectedError", "@maple/http/v2/InvalidRequestError", - "@maple/http/v2/AuthenticationError", + "@maple/http/v2/InvalidCredentialsError", "@maple/http/v2/RateLimitError", ]) { expect(isAnticipatedErrorIdentifier(identifier), identifier).toBe(true) @@ -19,8 +19,8 @@ describe("ANTICIPATED_ERROR_IDENTIFIERS", () => { for (const identifier of [ "@maple/http/errors/WarehouseQueryError", "@maple/http/errors/QueryEngineTimeoutError", - "@maple/http/v2/ApiError", - "@maple/http/v2/ServiceUnavailableError", + "@maple/http/v2/UnexpectedError", + "@maple/http/v2/WorkerUnavailableError", ]) { expect(isAnticipatedErrorIdentifier(identifier), identifier).toBe(false) } diff --git a/packages/domain/src/anticipated-errors.ts b/packages/domain/src/anticipated-errors.ts index 4c7be0dc3..815eed9d3 100644 --- a/packages/domain/src/anticipated-errors.ts +++ b/packages/domain/src/anticipated-errors.ts @@ -2,8 +2,8 @@ // // The set of stable domain HTTP error identifiers that represent *expected* // client-facing outcomes (4xx): validation, not-found, unauthorized, forbidden, -// conflict, … Tagged errors contribute `_tag`; v2 Error values contribute -// their class identifier / `Error.name`. +// conflict, … Tagged errors contribute `_tag`; v2 definitions contribute their +// exact public `tag`. // // These are not bugs — they're normal business results. The telemetry SDK uses // this set to record spans that fail *entirely* with one of these errors as @@ -12,11 +12,11 @@ // `StatusCode='Error'`). Mirrors the ingest gateway's `otel_status_for_rejection` // rule (4xx → Ok, 5xx → Error). // -// Derived (not hand-maintained) from the error classes themselves: every -// Both `Schema.TaggedError` and `Schema.Error` carry a stable -// identifier plus an `httpApiStatus` annotation, so a new 4xx error is picked -// up automatically. A 5xx error (persistence/upstream failures) is intentionally -// excluded and keeps tracing. +// Derived (not hand-maintained) from the exported error classes and v2 +// definitions. Every class has a schema identifier plus an `httpApiStatus` +// annotation; every v2 definition exposes the same tag/status pair directly. +// A 5xx error (persistence/upstream failures) is intentionally excluded and +// keeps tracing. import * as Http from "./http/index" import * as HttpV2 from "./http/v2/index" @@ -26,8 +26,10 @@ const prop = (obj: unknown, key: string): unknown => ? (obj as Record)[key] : undefined -/** Stable runtime identifier: tagged errors use `_tag`; Schema.Error uses its class identifier/name. */ +/** Stable runtime identifier from a v2 definition or tagged-error class. */ const readIdentifier = (value: unknown): string | undefined => { + const tag = prop(value, "tag") + if (typeof tag === "string") return tag const literal = prop(prop(prop(prop(value, "fields"), "_tag"), "schema"), "literal") if (typeof literal === "string") return literal const identifier = prop(value, "identifier") @@ -36,8 +38,10 @@ const readIdentifier = (value: unknown): string | undefined => { /** The `httpApiStatus` annotation on a schema's AST, when present. */ const readHttpStatus = (value: unknown): number | undefined => { - const status = prop(prop(prop(value, "ast"), "annotations"), "httpApiStatus") - return typeof status === "number" ? status : undefined + const status = prop(value, "status") + if (typeof status === "number") return status + const annotation = prop(prop(prop(value, "ast"), "annotations"), "httpApiStatus") + return typeof annotation === "number" ? annotation : undefined } /** @@ -53,7 +57,6 @@ const exportedValues = (namespace: object): ReadonlyArray => Object.val const deriveAnticipatedIdentifiers = (): ReadonlySet => { const identifiers = new Set(EXTERNAL_ANTICIPATED_IDENTIFIERS) for (const value of [...exportedValues(Http), ...exportedValues(HttpV2)]) { - if (typeof value !== "function") continue const identifier = readIdentifier(value) if (identifier === undefined) continue const status = readHttpStatus(value) @@ -65,7 +68,7 @@ const deriveAnticipatedIdentifiers = (): ReadonlySet => { /** * Stable identifiers of all domain HTTP errors annotated with a 4xx `httpApiStatus`. - * Tagged errors contribute `_tag`; v2 Schema.Error values contribute `Error.name`. + * Tagged errors and v2 definitions both contribute their exact public `_tag`. */ export const ANTICIPATED_ERROR_IDENTIFIERS: ReadonlySet = deriveAnticipatedIdentifiers() diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 2692cc8a9..533f7a027 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -695,48 +695,44 @@ export class AlertPersistenceError extends HttpTaggedError { - 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()( - "@maple/http/errors/AlertNotFoundError", +export class AlertRuleNotFoundError extends HttpTaggedError()( + "@maple/http/errors/AlertRuleNotFoundError", + { message: Schema.String, ruleId: AlertRuleId }, { - message: Schema.String, - resourceType: Schema.String, - resourceId: Schema.String, + status: 404, + code: "alert_rule_not_found", + title: "Alert rule not found", + message: "No such alert rule.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, +) {} + +export class AlertDestinationNotFoundError extends HttpTaggedError()( + "@maple/http/errors/AlertDestinationNotFoundError", + { message: Schema.String, destinationId: AlertDestinationId }, + { + status: 404, + code: "alert_destination_not_found", + title: "Alert destination not found", + message: "No such alert destination.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", }, +) {} + +export class AlertIncidentNotFoundError extends HttpTaggedError()( + "@maple/http/errors/AlertIncidentNotFoundError", + { message: Schema.String, incidentId: AlertIncidentId }, { status: 404, - code: (error) => alertResource(error.resourceType).code, - title: (error) => alertResource(error.resourceType).title, - message: (error) => alertResource(error.resourceType).message, + code: "alert_incident_not_found", + title: "Alert incident not found", + message: "No such alert incident.", param: "id", retry: "never", recovery: "none", @@ -744,6 +740,11 @@ export class AlertNotFoundError extends HttpTaggedError()( }, ) {} +export type AlertNotFoundError = + | AlertRuleNotFoundError + | AlertDestinationNotFoundError + | AlertIncidentNotFoundError + export class AlertDeliveryError extends HttpTaggedError()( "@maple/http/errors/AlertDeliveryError", { diff --git a/packages/domain/src/http/error-policy.ts b/packages/domain/src/http/error-policy.ts index f0fde7ce1..3af4a008d 100644 --- a/packages/domain/src/http/error-policy.ts +++ b/packages/domain/src/http/error-policy.ts @@ -54,7 +54,6 @@ export interface PublicHttpErrorBody( + tag: Schema.Codec, + type: Schema.Codec, + policy: PublicHttpErrorPolicy, +) => + Schema.Struct({ + _tag: tag, + type, + ...publicHttpErrorBodyFields, + code: typeof policy.code === "string" ? Schema.Literal(policy.code) : publicHttpErrorBodyFields.code, + title: + typeof policy.title === "string" ? Schema.Literal(policy.title) : publicHttpErrorBodyFields.title, + message: + policy.exposure === "redacted" && typeof policy.message === "string" + ? Schema.Literal(policy.message) + : publicHttpErrorBodyFields.message, + retryable: Schema.Literal(policy.retry !== "never"), + recovery: Schema.Literal(policy.recovery), + }) + /** Runtime contract for a public error body when its exact tag/status are not known statically. */ export const PublicHttpErrorBodySchema = makePublicHttpErrorBodySchema( Schema.String.check(Schema.isPattern(/^@maple\//)), diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index b5cf5085a..184af3959 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -572,17 +572,13 @@ export class ErrorIssueNotFoundError extends HttpTaggedError - 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.", + code: "error_issue_not_found", + title: "Error issue not found", + message: "No such error issue.", param: "id", retry: "never", recovery: "none", @@ -592,8 +588,7 @@ export class ErrorIssueNotFoundError extends HttpTaggedError()( "@maple/http/errors/ScrapeTargetAuthError", diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts index 7412985ed..c2a248457 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -3,10 +3,10 @@ import { Schema } from "effect" import { HazelChannelId, HazelOrganizationId, PostgresTransactionId, UserId } from "../../primitives" import { AlertDeliveryError, + AlertDestinationNotFoundError, AlertDestinationInUseError, AlertDestinationType, AlertForbiddenError, - AlertNotFoundError, AlertPersistenceError, AlertValidationError, MAX_EMAIL_RECIPIENTS, @@ -345,7 +345,7 @@ const [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDe AlertForbiddenError, AlertValidationError, AlertPersistenceError, - AlertNotFoundError, + AlertDestinationNotFoundError, AlertDeliveryError, ) diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index 3b80d3042..31b0b74d4 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -1,6 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" -import { AlertNotFoundError, AlertPersistenceError } from "../alerts" +import { AlertIncidentNotFoundError, AlertPersistenceError } from "../alerts" import { AlertComparator, AlertEventType, @@ -158,7 +158,7 @@ export class V2AlertIncidentsApiGroup extends HttpApiGroup.make("alertIncidents" HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertIncidentPublicId }, success: V2AlertIncident, - error: [publicError(AlertNotFoundError), alertPersistence], + error: [publicError(AlertIncidentNotFoundError), alertPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertIncident", diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts index b64992bae..95804535b 100644 --- a/packages/domain/src/http/v2/alert-rules.ts +++ b/packages/domain/src/http/v2/alert-rules.ts @@ -9,9 +9,10 @@ import { AlertIncidentTransition, AlertNotificationTemplate, AlertDeliveryError, + AlertDestinationNotFoundError, AlertForbiddenError, - AlertNotFoundError, AlertPersistenceError, + AlertRuleNotFoundError, AlertSeverity, AlertSignalType, AlertValidationError, @@ -21,8 +22,8 @@ import { AlertDestinationPublicId } from "./alert-destinations" import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" -import { publicErrors } from "./public-error" -import { V2ManagedWarehouseErrors, V2WarehouseErrors } from "./query-errors" +import { publicError, publicErrors } from "./public-error" +import { V2ManagedWarehouseErrors, V2QueryEngineRouteErrors, V2WarehouseErrors } from "./query-errors" import { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" export { AlertIncidentPublicId, AlertRulePublicId } from "./resource-ids" @@ -626,13 +627,14 @@ const ChecksQuery = Schema.Struct({ description: "Pagination plus optional group/time filters for a rule's check history.", }) -const [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDelivery] = publicErrors( +const [alertForbidden, alertValidation, alertPersistence, alertRuleNotFound, alertDelivery] = publicErrors( AlertForbiddenError, AlertValidationError, AlertPersistenceError, - AlertNotFoundError, + AlertRuleNotFoundError, AlertDeliveryError, ) +const alertDestinationNotFound = publicError(AlertDestinationNotFoundError) const AlertRuleList = ListOf(V2AlertRule).annotate({ identifier: "AlertRuleList", @@ -717,7 +719,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertForbidden, alertValidation, alertPersistence, - alertNotFound, + alertRuleNotFound, ], }).annotateMerge( OpenApi.annotations({ @@ -732,7 +734,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertRulePublicId }, success: V2AlertRule, - error: [alertPersistence, alertNotFound], + error: [alertPersistence, alertRuleNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertRule", @@ -752,7 +754,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertForbidden, alertValidation, alertPersistence, - alertNotFound, + alertRuleNotFound, ], }).annotateMerge( OpenApi.annotations({ @@ -767,7 +769,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.delete("delete", "/:id", { params: { id: AlertRulePublicId }, success: V2AlertRuleDeleteResponse, - error: [alertForbidden, alertPersistence, alertNotFound], + error: [alertForbidden, alertPersistence, alertRuleNotFound], }).annotateMerge( OpenApi.annotations({ identifier: "deleteAlertRule", @@ -786,9 +788,10 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertForbidden, alertValidation, alertPersistence, - alertNotFound, + alertDestinationNotFound, alertDelivery, ...V2WarehouseErrors, + ...V2QueryEngineRouteErrors, ], }).annotateMerge( OpenApi.annotations({ @@ -808,8 +811,8 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertForbidden, alertValidation, alertPersistence, - alertDelivery, ...V2WarehouseErrors, + ...V2QueryEngineRouteErrors, ], }).annotateMerge( OpenApi.annotations({ @@ -825,7 +828,12 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, query: ChecksQuery, success: AlertCheckList, - error: [V2ParameterInvalid.schema, alertPersistence, alertNotFound, ...V2ManagedWarehouseErrors], + error: [ + V2ParameterInvalid.schema, + alertPersistence, + alertRuleNotFound, + ...V2ManagedWarehouseErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertRuleChecks", @@ -840,7 +848,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") params: { id: AlertRulePublicId }, query: AlertCheckSummaryQuery, success: AlertCheckSummary, - error: [alertValidation, alertPersistence, alertNotFound, ...V2ManagedWarehouseErrors], + error: [alertValidation, alertPersistence, alertRuleNotFound, ...V2ManagedWarehouseErrors], }).annotateMerge( OpenApi.annotations({ identifier: "summarizeAlertRuleChecks", diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts index e2b607d72..23b28dbd2 100644 --- a/packages/domain/src/http/v2/errors.ts +++ b/packages/domain/src/http/v2/errors.ts @@ -1,12 +1,14 @@ import { Schema } from "effect" import { HttpErrorRecovery, + HttpTaggedError, PublicHttpErrorType, - makePublicHttpErrorBodySchema, publicHttpErrorTypeForStatus, - type PublicHttpErrorBody, + type HttpErrorRetry, type PublicHttpErrorStatus, } from "../error-policy" +import { publicError } from "./public-error" +import { v2WorkerUnavailableDefinition } from "./worker-unavailable" /** * Every v2 failure uses the same public body. Endpoint schemas narrow `_tag` @@ -18,10 +20,6 @@ export type V2ErrorType = Schema.Schema.Type export const V2ErrorRecovery = HttpErrorRecovery export type V2ErrorRecovery = Schema.Schema.Type -export interface V2PublicError { - readonly error: PublicHttpErrorBody -} - export const errorTypeForStatus = publicHttpErrorTypeForStatus export interface V2ErrorDefinitionOptions< @@ -34,9 +32,10 @@ export interface V2ErrorDefinitionOptions< readonly code: Code readonly title: string readonly message: string - readonly retryable: boolean + readonly retry: HttpErrorRetry readonly recovery: V2ErrorRecovery readonly identifier: string + readonly retryAfterSeconds?: number } export interface V2ErrorMakeOptions { @@ -45,36 +44,6 @@ export interface V2ErrorMakeOptions { readonly retryAt?: string } -export interface V2ErrorSchemaOptions { - readonly tag: Tag - readonly status: Status - readonly identifier: string - readonly title: string - readonly description?: string -} - -/** Build one exact OpenAPI branch for a single semantic error tag. */ -export const makeV2ErrorSchema = ( - options: V2ErrorSchemaOptions, -) => { - const type = errorTypeForStatus(options.status) - return Schema.Struct({ - error: makePublicHttpErrorBodySchema( - Schema.Literal(options.tag).annotate({ - description: "Stable semantic error tag. Branch on this exact value.", - }), - Schema.Literal(type).annotate({ - description: "Broad error category shared by related semantic tags.", - }), - ), - }).annotate({ - httpApiStatus: options.status, - identifier: options.identifier, - title: options.title, - description: options.description ?? `The ${options.tag} failure. HTTP ${options.status}.`, - }) -} - /** * Define a boundary-born v2 error. Its value, exact schema, status, tag, and * recovery metadata come from this one definition. @@ -87,40 +56,41 @@ export const defineV2Error = < definition: V2ErrorDefinitionOptions, ) => { const type = errorTypeForStatus(definition.status) - const schema = makeV2ErrorSchema({ - tag: definition.tag, - status: definition.status, + class BoundaryError extends HttpTaggedError()( + definition.tag, + { + message: Schema.String, + param: Schema.optionalKey(Schema.String), + retryAfterSeconds: Schema.optionalKey( + Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)), + ), + retryAt: Schema.optionalKey(Schema.String), + }, + { + status: definition.status, + code: definition.code, + title: definition.title, + retry: definition.retry, + recovery: definition.recovery, + exposure: "public_message", + param: (error) => error.param, + retryAfterSeconds: (error) => error.retryAfterSeconds, + retryAt: (error) => error.retryAt, + }, + ) {} + const schema = publicError(BoundaryError, { identifier: definition.identifier, title: definition.title, }) - const errorBodySchema = makePublicHttpErrorBodySchema( - Schema.Literal(definition.tag), - Schema.Literal(type), - ) - class BoundaryError extends Schema.TaggedError(definition.identifier)(definition.tag, { - error: errorBodySchema, - }) { - override get message(): string { - return this.error.message - } - } const make = (message: string = definition.message, options: V2ErrorMakeOptions = {}) => { - const error = { - _tag: definition.tag, - type, - code: definition.code, - title: definition.title, + const retryAfterSeconds = options.retryAfterSeconds ?? definition.retryAfterSeconds + return new BoundaryError({ message, - retryable: definition.retryable, - recovery: definition.recovery, - ...(options.retryAfterSeconds === undefined - ? {} - : { retry_after_seconds: options.retryAfterSeconds }), - ...(options.retryAt === undefined ? {} : { retry_at: options.retryAt }), + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), + ...(options.retryAt === undefined ? {} : { retryAt: options.retryAt }), ...(options.param === undefined ? {} : { param: options.param }), - } satisfies PublicHttpErrorBody - return new BoundaryError({ error }) + }) } return { ...definition, type, schema, make } as const @@ -132,7 +102,7 @@ export const V2InvalidRequest = defineV2Error({ code: "parameter_invalid", title: "Invalid request", message: "The request did not match the endpoint schema.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "InvalidRequestError", }) @@ -143,7 +113,7 @@ export const V2InvalidCredentials = defineV2Error({ code: "invalid_credentials", title: "Sign in required", message: "Invalid or missing credentials.", - retryable: false, + retry: "never", recovery: "reauthenticate", identifier: "InvalidCredentialsError", }) @@ -154,7 +124,7 @@ export const V2InsufficientScope = defineV2Error({ code: "insufficient_scope", title: "Permission required", message: "The API key does not have the scope required for this request.", - retryable: false, + retry: "never", recovery: "request_access", identifier: "InsufficientScopeError", }) @@ -165,7 +135,7 @@ export const V2InsufficientPermissions = defineV2Error({ code: "insufficient_permissions", title: "Permission required", message: "Only organization administrators can perform this operation.", - retryable: false, + retry: "never", recovery: "request_access", identifier: "InsufficientPermissionsError", }) @@ -176,7 +146,7 @@ export const V2ParameterInvalid = defineV2Error({ code: "parameter_invalid", title: "Invalid request", message: "A request parameter is invalid.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "ParameterInvalidError", }) @@ -187,7 +157,7 @@ export const V2ParameterMissing = defineV2Error({ code: "parameter_missing", title: "Missing request parameter", message: "A required request parameter is missing.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "ParameterMissingError", }) @@ -198,7 +168,7 @@ export const V2TimeRangeInvalid = defineV2Error({ code: "invalid_time_range", title: "Invalid time range", message: "end_time must be after start_time.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "TimeRangeInvalidError", }) @@ -209,7 +179,7 @@ export const V2CursorInvalid = defineV2Error({ code: "cursor_invalid", title: "Invalid pagination cursor", message: "Invalid pagination cursor.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "CursorInvalidError", }) @@ -220,7 +190,7 @@ export const V2CursorSortMismatch = defineV2Error({ code: "cursor_sort_mismatch", title: "Cursor does not match sort", message: "Cursor does not match the selected sort.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "CursorSortMismatchError", }) @@ -231,7 +201,7 @@ export const V2CallbackHostUnavailable = defineV2Error({ code: "callback_host_unavailable", title: "Integration setup unavailable", message: "Integration setup is not available from this host.", - retryable: false, + retry: "never", recovery: "contact_support", identifier: "CallbackHostUnavailableError", }) @@ -242,7 +212,7 @@ export const V2RateLimited = defineV2Error({ code: "rate_limited", title: "Too many requests", message: "Too many requests. Retry after the interval in the Retry-After header.", - retryable: true, + retry: "after", recovery: "retry", identifier: "RateLimitError", }) @@ -253,7 +223,7 @@ export const V2ResponseSchemaFailure = defineV2Error({ code: "internal_error", title: "Something went wrong", message: "An unexpected error occurred on our end.", - retryable: false, + retry: "never", recovery: "contact_support", identifier: "ResponseSchemaError", }) @@ -264,19 +234,12 @@ export const V2UnexpectedFailure = defineV2Error({ code: "internal_error", title: "Something went wrong", message: "An unexpected error occurred on our end.", - retryable: false, + retry: "never", recovery: "contact_support", identifier: "UnexpectedError", }) /** App bootstrap failed before the v2 HttpApi graph could handle the request. */ export const V2WorkerUnavailable = defineV2Error({ - tag: "@maple/http/v2/WorkerUnavailableError", - status: 504, - code: "worker_unavailable", - title: "Maple API is temporarily unavailable", - message: "Maple API is temporarily unavailable. Retry in a few seconds.", - retryable: true, - recovery: "retry", - identifier: "WorkerUnavailableError", + ...v2WorkerUnavailableDefinition, }) diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 0a876e93e..d9c5a8b9b 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -496,6 +496,9 @@ describe("MapleApiV2 OpenAPI", () => { ]) expect(responseErrorTags("post", "/v2/traces/timeseries", "500")).toEqual([ "@maple/http/errors/WarehouseMalformedQueryError", + "@maple/http/errors/WarehouseConfigDecryptionError", + "@maple/http/errors/WarehouseTokenConfigError", + "@maple/http/errors/WarehouseTokenMintError", "@maple/http/errors/QueryEngineResultMismatchError", "@maple/http/v2/ResponseSchemaError", "@maple/http/v2/UnexpectedError", @@ -513,6 +516,23 @@ describe("MapleApiV2 OpenAPI", () => { ]) }) + it("preserves exact PlanetScale token failures on scrape probes", () => { + const path = "/v2/scrape_targets/{id}/probe" + expect(responseErrorTags("post", path, "409")).toContain( + "@maple/http/errors/IntegrationsNotConnectedError", + ) + expect(responseErrorTags("post", path, "401")).toContain( + "@maple/http/errors/IntegrationsRevokedError", + ) + expect(responseErrorTags("post", path, "502")).toContain( + "@maple/http/errors/IntegrationsUpstreamError", + ) + const declaredTags = ["400", "401", "409", "500", "502", "503"].flatMap((status) => + responseErrorTags("post", path, status), + ) + expect(declaredTags).not.toContain("@maple/http/errors/ScrapeTargetAuthError") + }) + it("preserves warehouse failures on v2 read-model endpoints", () => { for (const [method, path] of [ ["get", "/v2/error_issues"], @@ -530,12 +550,42 @@ describe("MapleApiV2 OpenAPI", () => { expect(responseErrorTags("get", path, "429")).toContain( "@maple/http/errors/WarehouseQuotaExceededError", ) - expect(responseErrorTags("get", path, "503")).not.toContain( - "@maple/http/errors/WarehouseConfigLookupError", + const declaredTags = ["500", "502", "503"].flatMap((status) => + responseErrorTags("get", path, status), ) + for (const impossibleRoutingTag of [ + "@maple/http/errors/WarehouseConfigLookupError", + "@maple/http/errors/WarehouseConfigDecryptionError", + "@maple/http/errors/WarehouseStoredConfigInvalidError", + "@maple/http/errors/WarehouseTokenConfigError", + "@maple/http/errors/WarehouseTokenMintError", + ]) { + expect(declaredTags, path).not.toContain(impossibleRoutingTag) + } } }) + it("declares exact alert not-found and query-engine failures", () => { + expect(responseErrorTags("get", "/v2/alerts/rules/{id}", "404")).toEqual([ + "@maple/http/errors/AlertRuleNotFoundError", + ]) + expect(responseErrorTags("get", "/v2/alerts/destinations/{id}", "404")).toEqual([ + "@maple/http/errors/AlertDestinationNotFoundError", + ]) + expect(responseErrorTags("get", "/v2/alerts/incidents/{id}", "404")).toEqual([ + "@maple/http/errors/AlertIncidentNotFoundError", + ]) + expect(responseErrorTags("post", "/v2/alerts/rules/preview", "502")).toContain( + "@maple/http/errors/QueryEngineExecutionError", + ) + expect(responseErrorTags("post", "/v2/alerts/rules/preview", "502")).not.toContain( + "@maple/http/errors/AlertDeliveryError", + ) + expect(responseErrorTags("post", "/v2/alerts/rules/preview", "504")).toContain( + "@maple/http/errors/QueryEngineTimeoutError", + ) + }) + it("decodes slack-bot destination create/update params and rejects a blank channel_id", () => { expect(schemas["AlertDestinationCreateSlackBot"], "create component present").toBeDefined() expect(schemas["AlertDestinationUpdateSlackBot"], "update component present").toBeDefined() @@ -640,11 +690,15 @@ describe("MapleApiV2 OpenAPI", () => { expect(bearer.bearerFormat.length).toBeGreaterThan(0) }) - it("documents error responses with a stable code example", () => { + it("documents every static error policy field as a literal", () => { 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)) + const properties = notFound.properties.error.properties + expect(properties._tag.enum).toEqual(["@maple/http/errors/ApiKeyNotFoundError"]) + expect(properties.type.enum).toEqual(["not_found_error"]) + expect(properties.code.enum).toEqual(["api_key_not_found"]) + expect(properties.title.enum).toEqual(["API key not found"]) + expect(properties.message.enum).toEqual(["No such API key."]) + expect(properties.retryable.enum).toEqual([false]) + expect(properties.recovery.enum).toEqual(["none"]) }) }) diff --git a/packages/domain/src/http/v2/public-error.test.ts b/packages/domain/src/http/v2/public-error.test.ts index 916350f0f..6205d6631 100644 --- a/packages/domain/src/http/v2/public-error.test.ts +++ b/packages/domain/src/http/v2/public-error.test.ts @@ -13,6 +13,7 @@ import { import { WarehouseMalformedQueryError, WarehouseQuotaExceededError, + WarehouseResultDecodeError, WarehouseSchemaDriftError, WarehouseUpstreamError, WarehouseValidationError, @@ -88,6 +89,20 @@ describe("HttpTaggedError public body", () => { expect(publicHttpErrorPolicy(error).status).toBe(502) }) + it("distinguishes cluster schema drift from Maple result decoding failures", () => { + const error = new WarehouseResultDecodeError({ + pipeName: "service_overview", + message: "Secret wire-format mismatch", + }) + + expect(error.error).toMatchObject({ + _tag: "@maple/http/errors/WarehouseResultDecodeError", + code: "warehouse_result_decode_failed", + recovery: "contact_support", + }) + expect(error.error.message).not.toContain("Secret wire-format mismatch") + }) + it("serializes query-engine failures directly", () => { const validation = new QueryEngineValidationError({ message: "invalid aggregation", details: [] }) const execution = new QueryEngineExecutionError({ message: "execution failed" }) diff --git a/packages/domain/src/http/v2/public-error.ts b/packages/domain/src/http/v2/public-error.ts index 4145cc755..6c0cdd715 100644 --- a/packages/domain/src/http/v2/public-error.ts +++ b/packages/domain/src/http/v2/public-error.ts @@ -1,14 +1,16 @@ import { + makeExactPublicHttpErrorBodySchema, publicHttpErrorDefinitionFor, + publicHttpErrorTypeForStatus, + type PublicHttpErrorBody, type PublicHttpErrorStatusOf, type SelfDescribingHttpErrorClass, type SelfDescribingHttpError, } from "../error-policy" import { Schema } from "effect" -import { makeV2ErrorSchema, type V2PublicError } from "./errors" export type V2ErrorEnvelopeFor = Error extends SelfDescribingHttpError - ? V2PublicError> + ? { readonly error: PublicHttpErrorBody> } : never export type V2PublicErrorSchema = Schema.Codec< @@ -16,6 +18,12 @@ export type V2PublicErrorSchema = Schema. > const publicErrorSchemaCache = new WeakMap() +export interface PublicErrorSchemaOptions { + readonly identifier?: string + readonly title?: string + readonly description?: string +} + /** * Project a self-describing domain error into its exact public wire schema. * The literal domain `_tag`, HTTP status, and envelope category all come from @@ -23,11 +31,14 @@ const publicErrorSchemaCache = new WeakMap() */ export const publicError = ( errorClass: SelfDescribingHttpErrorClass & Schema.Schema, + options?: PublicErrorSchemaOptions, ): V2PublicErrorSchema => { - const cached = publicErrorSchemaCache.get(errorClass) - if (cached !== undefined) return cached as V2PublicErrorSchema - const schema = makePublicErrorSchema(errorClass) - publicErrorSchemaCache.set(errorClass, schema) + if (options === undefined) { + const cached = publicErrorSchemaCache.get(errorClass) + if (cached !== undefined) return cached as V2PublicErrorSchema + } + const schema = makePublicErrorSchema(errorClass, options) + if (options === undefined) publicErrorSchemaCache.set(errorClass, schema) return schema as unknown as V2PublicErrorSchema } @@ -42,13 +53,26 @@ export const publicErrors = } -const makePublicErrorSchema = (errorClass: Function) => { +const makePublicErrorSchema = ( + errorClass: Function, + options?: PublicErrorSchemaOptions, +) => { const { tag, policy } = publicHttpErrorDefinitionFor(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}.`, + const type = publicHttpErrorTypeForStatus(policy.status) + return Schema.Struct({ + error: makeExactPublicHttpErrorBodySchema( + Schema.Literal(tag).annotate({ + description: "Stable semantic error tag. Branch on this exact value.", + }), + Schema.Literal(type).annotate({ + description: "Broad error category shared by related semantic tags.", + }), + policy, + ), + }).annotate({ + httpApiStatus: policy.status, + identifier: options?.identifier ?? errorClass.name, + title: options?.title ?? (typeof policy.title === "string" ? policy.title : errorClass.name), + description: options?.description ?? `The ${tag} failure. HTTP ${policy.status}.`, }) } diff --git a/packages/domain/src/http/v2/query-errors.ts b/packages/domain/src/http/v2/query-errors.ts index 1e38e4ef4..e53ed49f5 100644 --- a/packages/domain/src/http/v2/query-errors.ts +++ b/packages/domain/src/http/v2/query-errors.ts @@ -4,53 +4,25 @@ import { QueryEngineTimeoutError, QueryEngineValidationError, } from "../query-engine" -import { - WarehouseAuthError, - WarehouseClientError, - WarehouseConfigError, - WarehouseConfigLookupError, - WarehouseMalformedQueryError, - WarehouseQueryError, - WarehouseQuotaExceededError, - WarehouseSchemaDriftError, - WarehouseUpstreamError, - WarehouseValidationError, -} from "../warehouse-errors" +import { managedWarehouseHttpErrors, warehouseHttpErrors } 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, - WarehouseConfigLookupError, -) +export const V2WarehouseErrors = publicErrors(...warehouseHttpErrors) /** Managed-only routes never consult the per-org warehouse configuration. */ -export const V2ManagedWarehouseErrors = publicErrors( - WarehouseQueryError, - WarehouseUpstreamError, - WarehouseAuthError, - WarehouseConfigError, - WarehouseClientError, - WarehouseSchemaDriftError, - WarehouseMalformedQueryError, - WarehouseQuotaExceededError, - WarehouseValidationError, -) +export const V2ManagedWarehouseErrors = publicErrors(...managedWarehouseHttpErrors) /** Exact public schemas for failures added by the higher-level query engine. */ -export const V2QueryEngineErrors = publicErrors( +export const V2QueryEngineRouteErrors = publicErrors( QueryEngineValidationError, QueryEngineExecutionError, QueryEngineTimeoutError, - QueryEngineResultMismatchError, ) +export const V2QueryEngineErrors = [ + ...V2QueryEngineRouteErrors, + ...publicErrors(QueryEngineResultMismatchError), +] as const + export const V2QueryErrors = [...V2WarehouseErrors, ...V2QueryEngineErrors] as const diff --git a/packages/domain/src/http/v2/scrape-targets.ts b/packages/domain/src/http/v2/scrape-targets.ts index ed76f5b4f..ecb5df531 100644 --- a/packages/domain/src/http/v2/scrape-targets.ts +++ b/packages/domain/src/http/v2/scrape-targets.ts @@ -2,7 +2,14 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ScrapeAuthType, ScrapeIntervalSeconds, ScrapeTargetId, ScrapeTargetType } from "../../primitives" import { - ScrapeTargetAuthError, + IntegrationsConfigurationError, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsValidationError, +} from "../integrations" +import { ScrapeTargetEncryptionError, ScrapeTargetNotFoundError, ScrapeTargetPersistenceError, @@ -340,12 +347,19 @@ export const V2ScrapeTargetChecksQuery = Schema.Struct({ }) export type V2ScrapeTargetChecksQuery = Schema.Schema.Type -const [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption, scrapeAuth] = publicErrors( +const [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption] = publicErrors( ScrapeTargetNotFoundError, ScrapeTargetValidationError, ScrapeTargetPersistenceError, ScrapeTargetEncryptionError, - ScrapeTargetAuthError, +) +const planetScaleAccessTokenErrors = publicErrors( + IntegrationsNotConnectedError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsPersistenceError, + IntegrationsValidationError, + IntegrationsConfigurationError, ) const ScrapeTargetList = ListOf(V2ScrapeTarget).annotate({ @@ -436,7 +450,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.post("probe", "/:id/probe", { params: { id: ScrapeTargetPublicId }, success: V2ScrapeTargetProbeResult, - error: [scrapeNotFound, scrapePersistence, scrapeEncryption, scrapeAuth], + error: [scrapeNotFound, scrapePersistence, scrapeEncryption, ...planetScaleAccessTokenErrors], }).annotateMerge( OpenApi.annotations({ identifier: "probeScrapeTarget", diff --git a/packages/domain/src/http/v2/session-replays.ts b/packages/domain/src/http/v2/session-replays.ts index 2fd4a6774..75d0de7a6 100644 --- a/packages/domain/src/http/v2/session-replays.ts +++ b/packages/domain/src/http/v2/session-replays.ts @@ -420,7 +420,7 @@ export const V2SessionReplayNotFound = defineV2Error({ code: "session_replay_not_found", title: "Session replay not found", message: "No such session replay.", - retryable: false, + retry: "never", recovery: "none", identifier: "SessionReplayNotFoundError", }) @@ -432,7 +432,7 @@ export const V2SessionReplayRangeTooLarge = defineV2Error({ 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, + retry: "never", recovery: "fix_request", identifier: "SessionReplayRangeTooLargeError", }) diff --git a/packages/domain/src/http/v2/telemetry.ts b/packages/domain/src/http/v2/telemetry.ts index bcdacc223..4324d24c8 100644 --- a/packages/domain/src/http/v2/telemetry.ts +++ b/packages/domain/src/http/v2/telemetry.ts @@ -14,7 +14,7 @@ export const V2TelemetryRangeTooLarge = defineV2Error({ code: "time_range_too_large", title: "Time range too large", message: "The requested time range exceeds this operation's limit.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "TelemetryRangeTooLargeError", }) @@ -25,7 +25,7 @@ export const V2TelemetryBucketCountTooLarge = defineV2Error({ code: "bucket_count_too_large", title: "Too many time buckets", message: "bucket_seconds produces too many buckets.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "TelemetryBucketCountTooLargeError", }) @@ -36,7 +36,7 @@ export const V2TelemetryBreakdownFilterRequired = defineV2Error({ code: "breakdown_filter_required", title: "Breakdown filter required", message: "This breakdown range requires at least one narrowing filter.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "TelemetryBreakdownFilterRequiredError", }) @@ -47,7 +47,7 @@ export const V2TraceQueryInvalid = defineV2Error({ code: "trace_query_invalid", title: "Invalid trace query", message: "The trace aggregation request is invalid.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "TraceQueryInvalidError", }) @@ -58,7 +58,7 @@ export const V2LogQueryInvalid = defineV2Error({ code: "log_query_invalid", title: "Invalid log query", message: "The log aggregation request is invalid.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "LogQueryInvalidError", }) @@ -69,7 +69,7 @@ export const V2MetricQueryInvalid = defineV2Error({ code: "metric_query_invalid", title: "Invalid metric query", message: "The metric aggregation request is invalid.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "MetricQueryInvalidError", }) @@ -80,7 +80,7 @@ export const V2TraceNotFound = defineV2Error({ code: "trace_not_found", title: "Trace not found", message: "No such trace.", - retryable: false, + retry: "never", recovery: "none", identifier: "TraceNotFoundError", }) @@ -91,7 +91,7 @@ export const V2SpanNotFound = defineV2Error({ code: "span_not_found", title: "Span not found", message: "No such span.", - retryable: false, + retry: "never", recovery: "none", identifier: "SpanNotFoundError", }) @@ -102,7 +102,7 @@ export const V2LogIdInvalid = defineV2Error({ code: "log_id_invalid", title: "Invalid log ID", message: "Malformed log ID.", - retryable: false, + retry: "never", recovery: "fix_request", identifier: "LogIdInvalidError", }) @@ -113,7 +113,7 @@ export const V2LogNotFound = defineV2Error({ code: "log_not_found", title: "Log not found", message: "No such log.", - retryable: false, + retry: "never", recovery: "none", identifier: "LogNotFoundError", }) @@ -124,7 +124,7 @@ export const V2ServiceNotFound = defineV2Error({ code: "service_not_found", title: "Service not found", message: "No such service.", - retryable: false, + retry: "never", recovery: "none", identifier: "ServiceNotFoundError", }) diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 1c3043466..cd9aaef32 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -457,7 +457,7 @@ describe("v2 error envelope", () => { code: "resource_missing", title: "Not found", message: "The resource does not exist.", - retryable: false, + retry: "never", recovery: "none", identifier: "TestNotFoundError", }) @@ -502,9 +502,7 @@ describe("v2 error envelope", () => { }) it("omits param when not provided", () => { - const wire = Schema.encodeSync(TestNotFound.schema)(TestNotFound.make("gone")) as { - error: Record - } + const wire = Schema.encodeSync(TestNotFound.schema)(TestNotFound.make("gone")) expect("param" in wire.error).toBe(false) }) diff --git a/packages/domain/src/http/v2/worker-unavailable.ts b/packages/domain/src/http/v2/worker-unavailable.ts new file mode 100644 index 000000000..138318fd2 --- /dev/null +++ b/packages/domain/src/http/v2/worker-unavailable.ts @@ -0,0 +1,29 @@ +/** + * Pure bootstrap-safe policy for the one v2 error that can be emitted before + * the Effect HTTP graph is available. Keep this module free of Effect imports. + */ +export const v2WorkerUnavailableDefinition = { + tag: "@maple/http/v2/WorkerUnavailableError", + status: 504, + type: "api_error", + code: "worker_unavailable", + title: "Maple API is temporarily unavailable", + message: "Maple API is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + identifier: "WorkerUnavailableError", + retryAfterSeconds: 1, +} as const + +const isRetryable = (retry: "never" | "backoff" | "after") => retry !== "never" + +export const v2WorkerUnavailableBody = () => ({ + _tag: v2WorkerUnavailableDefinition.tag, + type: v2WorkerUnavailableDefinition.type, + code: v2WorkerUnavailableDefinition.code, + title: v2WorkerUnavailableDefinition.title, + message: v2WorkerUnavailableDefinition.message, + retryable: isRetryable(v2WorkerUnavailableDefinition.retry), + recovery: v2WorkerUnavailableDefinition.recovery, + retry_after_seconds: v2WorkerUnavailableDefinition.retryAfterSeconds, +}) diff --git a/packages/domain/src/http/warehouse-error-meta.test.ts b/packages/domain/src/http/warehouse-error-meta.test.ts deleted file mode 100644 index 30f491785..000000000 --- a/packages/domain/src/http/warehouse-error-meta.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { - WAREHOUSE_ERROR_TAGS, - warehouseErrorCode, - warehouseErrorStatus, - presentWarehouseError, - isWarehouseErrorTag, -} from "./warehouse-error-meta" -import { warehouseHttpErrors } from "./warehouse-errors" - -describe("warehouse error meta", () => { - it("derives one tag per error class", () => { - expect(WAREHOUSE_ERROR_TAGS).toHaveLength(warehouseHttpErrors.length) - expect(new Set(WAREHOUSE_ERROR_TAGS).size).toBe(warehouseHttpErrors.length) - }) - - 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) - } - }) - - it("gives every tag a non-generic presentation", () => { - for (const tag of WAREHOUSE_ERROR_TAGS) { - const presented = presentWarehouseError({ _tag: tag, message: "boom" }) - expect(presented.title, tag).not.toBe("") - expect(presented.title, tag).not.toBe("Something went wrong") - expect(presented.description, tag).not.toBe("") - } - }) - - it("unique codes per tag", () => { - const codes = WAREHOUSE_ERROR_TAGS.map((tag) => warehouseErrorCode({ _tag: tag })) - expect(new Set(codes).size).toBe(codes.length) - }) - - it("suppresses the schema-apply advice for decode-kind drift", () => { - const cluster = presentWarehouseError({ - _tag: "@maple/http/errors/WarehouseSchemaDriftError", - message: "Missing column SampleRate", - }) - expect(cluster.description).toContain("schema apply") - - const decode = presentWarehouseError({ - _tag: "@maple/http/errors/WarehouseSchemaDriftError", - message: "Compiled query row 0 did not match its declared output schema", - kind: "decode", - }) - expect(decode.description).not.toContain("schema apply") - expect(decode.description).toContain("Maple bug") - }) - - it("re-sniffs embedded statuses out of generic query failures", () => { - const auth = presentWarehouseError({ - _tag: "@maple/http/errors/WarehouseQueryError", - message: "Request failed, response status code: 403", - }) - expect(auth.title).toBe("Database rejected our credentials") - - const upstream = presentWarehouseError({ - _tag: "@maple/http/errors/WarehouseQueryError", - message: "503 Service Temporarily Unavailable", - }) - expect(upstream.title).toBe("Database is temporarily unavailable") - }) -}) diff --git a/packages/domain/src/http/warehouse-error-meta.ts b/packages/domain/src/http/warehouse-error-meta.ts deleted file mode 100644 index d4f377479..000000000 --- a/packages/domain/src/http/warehouse-error-meta.ts +++ /dev/null @@ -1,249 +0,0 @@ -// Warehouse error presentation metadata — the single source of truth. -// -// The warehouse error classes used to be re-enumerated by hand in five -// places (two MCP tables, the alerts v2 map, AlertsService's failure -// categories, and the web's error formatter), so adding a tag meant five -// lockstep edits across five packages and any missed arm silently changed -// where the error went. This module owns everything derivable per tag: -// -// - `WAREHOUSE_ERROR_TAGS` / `warehouseErrorStatus` are DERIVED from the error -// classes themselves (same annotation-reading as `anticipated-errors.ts`). -// - 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. -// -// Imports nothing beyond `./warehouse-errors` (which imports only `effect` -// Schema), so web/CLI bundles stay driver-free. - -import type { WarehouseError } from "./warehouse-errors" -import { warehouseHttpErrors } from "./warehouse-errors" -import { - publicHttpErrorPolicyFor, - type PublicHttpErrorPolicy, - type PublicHttpErrorStatus, - type PublicTaggedError, -} from "./error-policy" - -export type WarehouseErrorTag = WarehouseError["_tag"] - -const prop = (obj: unknown, key: string): unknown => - (typeof obj === "object" || typeof obj === "function") && obj !== null && key in obj - ? (obj as Record)[key] - : undefined - -const readTag = (value: unknown): string | undefined => { - const literal = prop(prop(prop(prop(value, "fields"), "_tag"), "schema"), "literal") - return typeof literal === "string" ? literal : undefined -} - -const readHttpStatus = (value: unknown): number | undefined => { - const status = prop(prop(prop(value, "ast"), "annotations"), "httpApiStatus") - return typeof status === "number" ? status : undefined -} - -/** Every warehouse error tag, derived from the classes — never hand-listed. */ -export const WAREHOUSE_ERROR_TAGS: ReadonlyArray = warehouseHttpErrors.map((cls) => { - const tag = readTag(cls) - if (tag === undefined) throw new Error("warehouse error class without a _tag literal") - return tag as WarehouseErrorTag -}) - -/** `httpApiStatus` per tag, derived from the class annotations. */ -export const warehouseErrorStatus: ReadonlyMap = new Map( - warehouseHttpErrors.map((cls) => { - const tag = readTag(cls) as WarehouseErrorTag - const status = readHttpStatus(cls) - if (status === undefined) throw new Error(`warehouse error ${tag} without httpApiStatus`) - return [tag, status] as const - }), -) - -const warehouseErrorClassByTag = new Map( - warehouseHttpErrors.map((cls) => [readTag(cls) as WarehouseErrorTag, cls]), -) - -type WarehousePublicError = PublicTaggedError & WarehouseErrorLike - -const warehouseErrorPolicy = ( - error: WarehouseErrorLike, -): PublicHttpErrorPolicy => { - const errorClass = warehouseErrorClassByTag.get(error._tag) - if (errorClass === undefined) throw new Error(`Unknown warehouse error tag: ${error._tag}`) - return publicHttpErrorPolicyFor(errorClass) -} - -const resolvePolicyValue = ( - 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) -} - -/** - * Strip HTML error pages / whitespace noise out of an upstream error message. - * Shared by the classifier (query-engine) and the web formatter — this used to - * be two byte-identical copies. - */ -export const cleanErrorMessage = (raw: string): string => { - let cleaned = raw - const htmlIndex = cleaned.search(/<\s*(html|head|body|center|h1|hr|title)\b/i) - if (htmlIndex >= 0) cleaned = cleaned.slice(0, htmlIndex) - cleaned = cleaned - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim() - if (cleaned.endsWith(":")) cleaned = cleaned.slice(0, -1).trim() - return cleaned || raw.slice(0, 200) -} - -/** Sniff an HTTP status embedded in an upstream error message. */ -export const extractUpstreamStatus = (message: string): number | undefined => { - const match = message.match(/(?:status|HTTP status|response status code)[:\s]+(\d{3})/i) - if (match) return Number(match[1]) - const titleMatch = message.match(/\b(\d{3})\s+(?:error|service temporarily unavailable)\b/i) - if (titleMatch) return Number(titleMatch[1]) - return undefined -} - -const QUOTA_DESCRIPTIONS: Record = { - max_execution_time: "Query exceeded the 30s execution limit. Narrow the time range or add filters.", - max_memory_usage: "Query exceeded the memory limit. Add filters or reduce cardinality.", - max_threads: "Query exceeded the thread limit. Try a smaller scan.", -} - -const authDescription = (upstreamStatus: number | undefined): string => - upstreamStatus === 403 - ? "The configured database credentials are missing required permissions." - : "The configured database credentials are invalid or expired. Update them in settings." - -/** - * A warehouse error as seen AFTER crossing the HTTP boundary: a plain decoded - * object, not necessarily a class instance. Every field beyond `_tag` is - * optional so the presenter never trusts the wire. - */ -export interface WarehouseErrorLike { - readonly _tag: WarehouseErrorTag - readonly message?: string - readonly setting?: string - readonly upstreamStatus?: number - readonly kind?: string -} - -export interface PresentedWarehouseError { - readonly title: string - readonly description: string -} - -/** - * Shared human-facing copy per warehouse error. Structural on purpose — the - * web formatter feeds it wire-decoded objects, the MCP layer feeds it class - * instances; both get identical copy. - */ -export const presentWarehouseError = (error: WarehouseErrorLike): PresentedWarehouseError => { - 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, - description: - QUOTA_DESCRIPTIONS[setting] ?? - `Query exceeded the ${setting} limit. Narrow the time range or add filters.`, - } - } - case "@maple/http/errors/WarehouseAuthError": - return { title, description: authDescription(error.upstreamStatus) } - case "@maple/http/errors/WarehouseUpstreamError": - return { - 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, description: message ?? "Database is not configured correctly." } - case "@maple/http/errors/WarehouseConfigLookupError": - return { - title, - description: "Maple could not load the database settings. Retry in a few seconds.", - } - case "@maple/http/errors/WarehouseClientError": - 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 - // advise it (the advice sent people chasing infra symptoms). - if (error.kind === "decode") { - return { - title: "Query results did not match what Maple expected", - description: `${message ?? "The database answered with rows Maple could not decode."} This is likely a Maple bug, not a problem with your cluster.`, - } - } - return { - title, - description: `${message ?? "A column Maple expects is missing from the cluster."} Run schema apply from your ClickHouse settings.`, - } - } - case "@maple/http/errors/WarehouseMalformedQueryError": - // Maple generated SQL the database refused to plan. Nothing the user - // can do — do not send them to their database settings. - return { - 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, 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 - // sniff it to surface those nicely. - const text = message ?? "Database query failed" - const upstreamStatus = extractUpstreamStatus(text) - if (upstreamStatus === 401 || upstreamStatus === 403) { - return { - title: warehouseErrorTitle({ _tag: "@maple/http/errors/WarehouseAuthError" }), - description: authDescription(upstreamStatus), - } - } - if (upstreamStatus !== undefined && upstreamStatus >= 500 && upstreamStatus < 600) { - return { - title: warehouseErrorTitle({ _tag: "@maple/http/errors/WarehouseUpstreamError" }), - description: `The query backend returned ${upstreamStatus}. Retry in a few seconds.`, - } - } - return { title, description: text } - } - } -} - -export const isWarehouseErrorTag = (tag: string): tag is WarehouseErrorTag => - warehouseErrorClassByTag.has(tag as WarehouseErrorTag) - -/** - * Presentation with the raw upstream message REDACTED — every description - * falls back to Maple-authored copy. Public API envelopes use this: ClickHouse - * diagnostics can echo generated SQL and internal identifiers, and the v2 - * surface deliberately never forwards them (the web app, talking to its own - * org's data, uses `presentWarehouseError` directly). - */ -export const presentWarehouseErrorPublic = (error: WarehouseErrorLike): PresentedWarehouseError => { - const { message: _message, ...rest } = error - return presentWarehouseError(rest) -} diff --git a/packages/domain/src/http/warehouse-errors.ts b/packages/domain/src/http/warehouse-errors.ts index 2227697db..976baa380 100644 --- a/packages/domain/src/http/warehouse-errors.ts +++ b/packages/domain/src/http/warehouse-errors.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { HttpTaggedError } from "./error-policy" +import { HttpTaggedError, publicHttpErrorDefinitionFor } from "./error-policy" // Pure error definitions for warehouse queries. This module imports ONLY // `effect` Schema — never `effect/unstable/httpapi` — so non-HTTP consumers @@ -109,6 +109,66 @@ export class WarehouseConfigLookupError extends HttpTaggedError()( + "@maple/http/errors/WarehouseConfigDecryptionError", + warehouseErrorBaseFields, + { + status: 500, + code: "warehouse_config_decryption_failed", + title: "Maple could not read database credentials", + message: "Maple could not securely read the saved database credentials.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** A saved per-org warehouse configuration no longer passes runtime validation. */ +export class WarehouseStoredConfigInvalidError extends HttpTaggedError()( + "@maple/http/errors/WarehouseStoredConfigInvalidError", + warehouseErrorBaseFields, + { + status: 502, + code: "warehouse_stored_config_invalid", + title: "Saved database settings are invalid", + message: "The saved database settings are invalid. Reconnect the database in settings.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, +) {} + +/** The deployment is missing configuration required to mint an org-scoped token. */ +export class WarehouseTokenConfigError extends HttpTaggedError()( + "@maple/http/errors/WarehouseTokenConfigError", + warehouseErrorBaseFields, + { + status: 500, + code: "warehouse_token_config_invalid", + title: "Maple warehouse access is not configured", + message: "Maple could not configure secure access to the database.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** Maple failed while minting an org-scoped warehouse access token. */ +export class WarehouseTokenMintError extends HttpTaggedError()( + "@maple/http/errors/WarehouseTokenMintError", + warehouseErrorBaseFields, + { + status: 500, + code: "warehouse_token_mint_failed", + title: "Maple could not authorize database access", + message: "Maple could not authorize secure access to the database.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + /** Maple's query client could not decode/consume the response. */ export class WarehouseClientError extends HttpTaggedError()( "@maple/http/errors/WarehouseClientError", @@ -124,37 +184,38 @@ export class WarehouseClientError extends HttpTaggedError( }, ) {} -/** - * A BYO ClickHouse cluster is missing a column or has the wrong type for one - * Maple expects; remediated by running schema apply on the cluster. The MCP - * layer enriches this with an actionable hint. - * - * `kind` splits two failure modes that need opposite advice: `"cluster"` - * (absent = cluster, for wire compatibility) means the cluster itself rejected - * the query — run schema apply; `"decode"` means the cluster answered but the - * rows failed Maple's own row schema — schema apply cannot fix that and the - * presenter must not suggest it. - */ +/** A customer-managed cluster is missing schema Maple requires. */ export class WarehouseSchemaDriftError extends HttpTaggedError()( "@maple/http/errors/WarehouseSchemaDriftError", - { ...warehouseErrorBaseFields, kind: Schema.optional(Schema.Literals(["cluster", "decode"])) }, + warehouseErrorBaseFields, { 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.", + title: "Database schema is out of date", + message: + "A column Maple expects is missing from the cluster. Run schema apply from your ClickHouse settings.", retry: "never", recovery: "reconnect", exposure: "redacted", }, ) {} +/** The database returned rows that did not match Maple's declared result schema. */ +export class WarehouseResultDecodeError extends HttpTaggedError()( + "@maple/http/errors/WarehouseResultDecodeError", + warehouseErrorBaseFields, + { + status: 502, + code: "warehouse_result_decode_failed", + title: "Database response did not match the expected schema", + message: + "The database returned a response Maple could not decode. This is likely a Maple bug, not a problem with your cluster.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + /** * ClickHouse's analyzer rejected the SQL Maple generated — a type mismatch * between `if()` arms or `UNION` branches, an illegal argument type, an @@ -227,37 +288,50 @@ export class WarehouseValidationError extends HttpTaggedError - /** - * The full set of warehouse error classes, for reuse in `HttpApiEndpoint` - * `error:` arrays. Every endpoint that can surface a warehouse error must list - * all of them, or the HttpApi client throws when it decodes an unrecognized - * `_tag`. Spread this (`...warehouseHttpErrors`) into each endpoint's array. + * Managed-query errors are defined once as classes, then both the union and + * endpoint schemas derive from this tuple. This prevents a new tagged error + * from being added to execution without being added to OpenAPI as well. */ -export const warehouseHttpErrors = [ +export const managedWarehouseHttpErrors = [ WarehouseQueryError, WarehouseUpstreamError, WarehouseAuthError, WarehouseConfigError, WarehouseClientError, WarehouseSchemaDriftError, + WarehouseResultDecodeError, WarehouseMalformedQueryError, WarehouseQuotaExceededError, WarehouseValidationError, +] as const + +/** Errors added by resolving a per-org warehouse route. */ +export const warehouseRouteHttpErrors = [ WarehouseConfigLookupError, + WarehouseConfigDecryptionError, + WarehouseStoredConfigInvalidError, + WarehouseTokenConfigError, + WarehouseTokenMintError, ] as const + +/** Full query error set, including failures while resolving a per-org route. */ +export const warehouseHttpErrors = [...managedWarehouseHttpErrors, ...warehouseRouteHttpErrors] as const + +type ErrorInstance = ErrorClass extends abstract new (...args: never[]) => infer Error + ? Error + : never + +/** Every warehouse error. Use this as the error channel of warehouse-facing effects. */ +export type WarehouseError = ErrorInstance<(typeof warehouseHttpErrors)[number]> +export type WarehouseErrorTag = WarehouseError["_tag"] + +/** Errors possible on managed-only routes, which never read per-org routing config. */ +export type ManagedWarehouseError = ErrorInstance<(typeof managedWarehouseHttpErrors)[number]> + +export type WarehouseRouteError = ErrorInstance<(typeof warehouseRouteHttpErrors)[number]> + +/** Exact tags derived from the class tuple for tag-based consumers. */ +export const warehouseErrorTags = warehouseHttpErrors.map( + (errorClass) => publicHttpErrorDefinitionFor(errorClass).tag, +) as ReadonlyArray diff --git a/packages/domain/src/http/warehouse.ts b/packages/domain/src/http/warehouse.ts index 2bcf7d1e9..915fa268e 100644 --- a/packages/domain/src/http/warehouse.ts +++ b/packages/domain/src/http/warehouse.ts @@ -11,7 +11,6 @@ export { UnauthorizedError } from "./current-tenant" // so `@maple/domain/http`'s barrel keeps surfacing every class and there is a // single definition site (keeps `instanceof` identity-safe across import paths). export * from "./warehouse-errors" -export * from "./warehouse-error-meta" const WarehouseQueryNameSchema = Schema.Literals(warehouseQueries) diff --git a/packages/query-engine/src/execution/errors.ts b/packages/query-engine/src/execution/errors.ts index caa4308ba..23e57dd65 100644 --- a/packages/query-engine/src/execution/errors.ts +++ b/packages/query-engine/src/execution/errors.ts @@ -1,6 +1,4 @@ import { - cleanErrorMessage, - extractUpstreamStatus, WarehouseAuthError, WarehouseClientError, WarehouseConfigError, @@ -8,39 +6,50 @@ import { WarehouseMalformedQueryError, WarehouseQueryError, WarehouseQuotaExceededError, + type WarehouseResultDecodeError, WarehouseSchemaDriftError, WarehouseUpstreamError, + type WarehouseError, + type WarehouseRouteError, + type WarehouseValidationError, } from "@maple/domain/http" import { detectQuotaSetting } from "../profiles" -// The message sanitizer and status sniffer moved to `@maple/domain/http` -// (warehouse-error-meta) so the web formatter shares one implementation; -// re-exported here for existing consumers/tests. -export { cleanErrorMessage, extractUpstreamStatus } +/** Strip HTML error pages and whitespace noise before classifying/logging an upstream failure. */ +export const cleanErrorMessage = (raw: string): string => { + let cleaned = raw + const htmlIndex = cleaned.search(/<\s*(html|head|body|center|h1|hr|title)\b/i) + if (htmlIndex >= 0) cleaned = cleaned.slice(0, htmlIndex) + cleaned = cleaned + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + if (cleaned.endsWith(":")) cleaned = cleaned.slice(0, -1).trim() + return cleaned || raw.slice(0, 200) +} -/** - * Every warehouse error `mapWarehouseError` can produce. Precondition failures - * (`WarehouseValidationError`) are raised by the executor before a query runs, - * not by this classifier, so they're intentionally absent here. - */ -export type WarehouseClassifiedError = - | WarehouseQueryError - | WarehouseUpstreamError - | WarehouseAuthError - | WarehouseConfigError - | WarehouseClientError - | WarehouseSchemaDriftError - | WarehouseMalformedQueryError - | WarehouseQuotaExceededError - -/** Complete error channel for an executed warehouse operation. */ -export type WarehouseExecutionError = WarehouseClassifiedError | WarehouseConfigLookupError +const extractUpstreamStatus = (message: string): number | undefined => { + const match = message.match(/(?:status|HTTP status|response status code)[:\s]+(\d{3})/i) + if (match) return Number(match[1]) + const titleMatch = message.match(/\b(\d{3})\s+(?:error|service temporarily unavailable)\b/i) + if (titleMatch) return Number(titleMatch[1]) + return undefined +} /** - * Backwards-compatible name for the complete warehouse operation error channel. - * New code should prefer `WarehouseExecutionError`. + * Every warehouse error `mapWarehouseError` can produce. Precondition and row + * decode failures are raised elsewhere in the executor, so they are absent. */ -export type WarehouseSqlError = WarehouseExecutionError +export type WarehouseClassifiedError = Exclude< + WarehouseError, + WarehouseRouteError | WarehouseValidationError | WarehouseResultDecodeError +> + +/** Failures while routing or executing SQL, before decoding a declared row schema. */ +export type WarehouseExecutionError = WarehouseClassifiedError | WarehouseRouteError + +/** SQL execution plus the result-schema failure unique to compiled queries. */ +export type WarehouseCompiledQueryError = WarehouseExecutionError | WarehouseResultDecodeError type ClickHouseErrorDetails = { readonly message: string diff --git a/packages/query-engine/src/execution/executor.test.ts b/packages/query-engine/src/execution/executor.test.ts index a0e4c0703..73c5ebc8b 100644 --- a/packages/query-engine/src/execution/executor.test.ts +++ b/packages/query-engine/src/execution/executor.test.ts @@ -61,6 +61,8 @@ const compiled = compile(listRuleChecksQuery({ limit: 1 }), { orgId: "org_test", ruleId: "rule_test", }) +const _ingestRoutingIsPartOfTheCompiledType: "ingest" = compiled.routing +void _ingestRoutingIsPartOfTheCompiledType // The old `sqlQuery(tenant, sql)` entry point took a raw string; scope now // travels on the compiled query, so these execution/span/retry tests wrap their @@ -341,7 +343,7 @@ describe("makeWarehouseExecutor compiled-query defaults", () => { const error = yield* executor .compiledQuery(tenant, withSchema, { context: "serviceOverview" }) .pipe(Effect.flip) - assert.strictEqual(error._tag, "@maple/http/errors/WarehouseSchemaDriftError") + assert.strictEqual(error._tag, "@maple/http/errors/WarehouseResultDecodeError") // The real query identity, not the old constant "compiledQuery". assert.strictEqual(error.pipeName, "serviceOverview") }), diff --git a/packages/query-engine/src/execution/executor.ts b/packages/query-engine/src/execution/executor.ts index 31ad750a3..120c51a5b 100644 --- a/packages/query-engine/src/execution/executor.ts +++ b/packages/query-engine/src/execution/executor.ts @@ -5,7 +5,7 @@ import { RawSqlValidationError, type WarehouseQueryRequest, WarehouseQueryResponse, - WarehouseSchemaDriftError, + WarehouseResultDecodeError, WarehouseUpstreamError, WarehouseValidationError, } from "@maple/domain/http" @@ -696,10 +696,9 @@ WHERE name = 'enable_full_text_index'`, const decodedRows = yield* compiled.decodeRows(rows).pipe( Effect.mapError( (error) => - new WarehouseSchemaDriftError({ + new WarehouseResultDecodeError({ pipeName: payload.pipeName, message: error.message, - kind: "decode", cause: error, }), ), @@ -801,21 +800,23 @@ WHERE name = 'enable_full_text_index'`, return yield* selected.decodeRows(rows).pipe( Effect.mapError( (error) => - new WarehouseSchemaDriftError({ + new WarehouseResultDecodeError({ pipeName: options.context ?? "compiledQuery", message: error.message, - kind: "decode", cause: error, }), ), ) }) - const compiledQuery = ( + const compiledQuery = (( tenant: ExecutionTenant, compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), options?: SqlQueryOptions, - ) => unbounded(executeCompiledQuery(tenant, compiled, withoutResponseLimits(options))) + ) => + unbounded( + executeCompiledQuery(tenant, compiled, withoutResponseLimits(options)), + )) as WarehouseQueryServiceShape["compiledQuery"] /** * Read with an explicit ceiling on the response we're willing to materialize. @@ -876,10 +877,9 @@ WHERE name = 'enable_full_text_index'`, return yield* compiled.decodeRows(rows).pipe( Effect.mapError( (error) => - new WarehouseSchemaDriftError({ + new WarehouseResultDecodeError({ pipeName: context, message: error.message, - kind: "decode", cause: error, }), ), @@ -911,10 +911,9 @@ WHERE name = 'enable_full_text_index'`, return yield* selected.decodeFirstRow(rows).pipe( Effect.mapError( (error) => - new WarehouseSchemaDriftError({ + new WarehouseResultDecodeError({ pipeName: options.context ?? "compiledQueryFirst", message: error.message, - kind: "decode", cause: error, }), ), diff --git a/packages/query-engine/src/execution/ports.ts b/packages/query-engine/src/execution/ports.ts index 8218f85d1..e92a5c1a9 100644 --- a/packages/query-engine/src/execution/ports.ts +++ b/packages/query-engine/src/execution/ports.ts @@ -2,17 +2,17 @@ import type { Effect, Option } from "effect" import type { OrgId, UserId } from "@maple/domain" import type { RawSqlValidationError, + ManagedWarehouseError, WarehouseQueryRequest, WarehouseQueryResponse, WarehouseValidationError, - WarehouseSchemaDriftError, } from "@maple/domain/http" import type { ResolvedWarehouseConfig } from "./backend" import type { CompiledQuery } from "../ch" import type { WarehouseCapabilities } from "../capabilities" import type { WarehouseExecutorShape } from "../observability" import type { SqlQueryOptions } from "../profiles" -import type { WarehouseExecutionError } from "./errors" +import type { WarehouseCompiledQueryError, WarehouseExecutionError } from "./errors" import type { WarehouseResponseLimitError } from "./response-limits" /** The minimal tenant surface the executor reads (org scope + identity for spans). */ @@ -26,6 +26,14 @@ export type { SqlQueryOptions } from "../profiles" export type { ResolvedWarehouseConfig } from "./backend" +/** + * An ingest-routed compiled query skips tenant route resolution entirely, so + * its type excludes the configuration failures that only that lookup can emit. + */ +export type CompiledQueryError = Routing extends "ingest" + ? ManagedWarehouseError + : WarehouseCompiledQueryError | WarehouseValidationError + /** Minimal client interface — raw SQL execution plus row inserts. */ export interface WarehouseSqlClient { readonly sql: ( @@ -107,7 +115,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, payload: WarehouseQueryRequest, options?: SqlQueryOptions, - ) => Effect.Effect + ) => Effect.Effect /** * Execute a query that deliberately spans every tenant. The compiled query * must declare `.crossOrg()`, and `justification` is recorded on the span so @@ -121,10 +129,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, compiled: CompiledQuery, options: SqlQueryOptions & { readonly justification: string }, - ) => Effect.Effect< - ReadonlyArray, - WarehouseExecutionError | WarehouseValidationError | WarehouseSchemaDriftError - > + ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> /** Execute validated user-authored SQL with tenant-scoped credentials and hard response limits. */ readonly rawSqlQuery: ( tenant: ExecutionTenant, @@ -134,11 +139,18 @@ export interface WarehouseQueryServiceShape { ReadonlyArray>, WarehouseExecutionError | RawSqlValidationError > - readonly compiledQuery: ( - tenant: ExecutionTenant, - compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), - options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseExecutionError | WarehouseValidationError> + readonly compiledQuery: { + ( + tenant: ExecutionTenant, + compiled: CompiledQuery, + options?: SqlQueryOptions, + ): Effect.Effect, CompiledQueryError> + ( + tenant: ExecutionTenant, + compiled: (capabilities: WarehouseCapabilities) => CompiledQuery, + options?: SqlQueryOptions, + ): Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> + } /** * `compiledQuery` with an explicit ceiling on how much of the response we are * willing to materialize, failing with `WarehouseResponseLimitError` past it. @@ -155,18 +167,18 @@ export interface WarehouseQueryServiceShape { }, ) => Effect.Effect< ReadonlyArray, - WarehouseExecutionError | WarehouseValidationError | WarehouseResponseLimitError + WarehouseCompiledQueryError | WarehouseValidationError | WarehouseResponseLimitError > readonly compiledQueryWithCapabilities: ( tenant: ExecutionTenant, compile: (capabilities: WarehouseCapabilities) => CompiledQuery, options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseExecutionError | WarehouseValidationError> + ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> readonly compiledQueryFirst: ( tenant: ExecutionTenant, compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseExecutionError | WarehouseValidationError> + ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> /** * Resolve this tenant's route and capabilities once, so a fan-out that * follows finds them memoized instead of each branch deriving them itself. From 158706b2d5710f337d7da347bedd3f5f97e0ec22 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 13 Aug 2026 22:22:34 +0200 Subject: [PATCH 3/4] refactor(api): expose exact v2 failures --- apps/api/src/chat/tools.ts | 6 +- apps/api/src/mcp/lib/dashboard-mutations.ts | 2 + apps/api/src/mcp/lib/map-http-error.ts | 12 + apps/api/src/mcp/lib/map-warehouse-error.ts | 4 +- apps/api/src/mcp/tools/create-alert-rule.ts | 41 +- apps/api/src/mcp/tools/get-alert-rule.ts | 16 +- apps/api/src/mcp/tools/list-alert-rules.ts | 16 +- apps/api/src/mcp/tools/query-data.ts | 8 +- apps/api/src/mcp/tools/update-alert-rule.ts | 52 +-- apps/api/src/routes/v1/anomalies.http.ts | 4 +- apps/api/src/routes/v1/dashboards.http.ts | 49 ++- apps/api/src/routes/v1/errors.http.ts | 6 +- apps/api/src/routes/v1/integrations.http.ts | 40 +- apps/api/src/routes/v1/investigations.http.ts | 59 ++- apps/api/src/routes/v1/scrape-targets.http.ts | 11 +- .../v2/alchemy-provider.integration.test.ts | 9 +- apps/api/src/routes/v2/alerts.http.test.ts | 278 +++++++++++++- .../routes/v2/config-resources.http.test.ts | 50 ++- .../api/src/routes/v2/dashboards.http.test.ts | 31 +- .../src/routes/v2/integrations.http.test.ts | 99 +++++ apps/api/src/routes/v2/integrations.http.ts | 34 +- apps/api/src/routes/v2/investigations.http.ts | 1 - .../routes/v2/phase1-resources.http.test.ts | 82 +--- .../src/routes/v2/setup-audit.http.test.ts | 32 +- apps/api/src/routes/v2/telemetry.http.test.ts | 9 +- apps/api/src/routes/v2/v2-test-support.ts | 12 +- .../alerts/AlertDestinationDelivery.ts | 16 +- .../alerts/AlertDestinationHydration.ts | 10 +- .../alerts/AlertDestinationsService.ts | 222 ++++++----- .../api/src/services/alerts/AlertRuleModel.ts | 355 ++++++++++++------ .../services/alerts/AlertRulesService.test.ts | 4 +- .../src/services/alerts/AlertRulesService.ts | 76 +++- .../src/services/alerts/AlertsService.test.ts | 12 +- apps/api/src/services/alerts/AlertsService.ts | 204 ++++------ .../alerts/AnomalyDetectionService.ts | 4 +- .../DashboardPersistenceService.test.ts | 6 +- .../dashboards/DashboardPersistenceService.ts | 91 ++++- .../errors/ErrorIssueReadModelsService.ts | 6 +- .../errors/InvestigationService.test.ts | 61 +-- .../services/errors/InvestigationService.ts | 258 ++++++------- .../PlanetScaleConnectionService.test.ts | 32 ++ .../PlanetScaleConnectionService.ts | 162 ++++---- .../integrations/ScrapeTargetsService.ts | 189 +++++++--- .../TinybirdOrgTokenService.test.ts | 2 +- .../integrations/TinybirdOrgTokenService.ts | 20 +- .../org/OrgClickHouseSettingsService.test.ts | 21 +- .../org/OrgClickHouseSettingsService.ts | 13 +- .../api/src/services/org/OrgMembersService.ts | 44 +-- .../services/warehouse/QueryEngineService.ts | 19 +- .../warehouse/WarehouseQueryService.test.ts | 47 +-- .../warehouse/WarehouseQueryService.ts | 226 ++++++----- .../warehouse/warehouse-error-handlers.ts | 18 +- packages/alchemy-maple/src/MapleApi.ts | 31 +- packages/alchemy-maple/src/errors.ts | 16 +- packages/alchemy-maple/test/maple-api.test.ts | 27 +- packages/domain/src/http/alerts.ts | 152 +++++++- packages/domain/src/http/dashboards.ts | 21 ++ packages/domain/src/http/error-policy.ts | 34 +- packages/domain/src/http/investigations.ts | 5 +- .../http/org-clickhouse-settings-errors.ts | 110 ++++++ .../src/http/org-clickhouse-settings.ts | 98 +---- packages/domain/src/http/query-engine.ts | 4 + packages/domain/src/http/scrape-targets.ts | 29 ++ .../domain/src/http/v2/alert-destinations.ts | 67 +++- packages/domain/src/http/v2/alert-rules.ts | 24 +- packages/domain/src/http/v2/anomalies.ts | 4 +- packages/domain/src/http/v2/dashboards.ts | 39 +- packages/domain/src/http/v2/error-issues.ts | 6 +- packages/domain/src/http/v2/errors.ts | 32 +- .../src/http/v2/integrations-planetscale.ts | 35 +- packages/domain/src/http/v2/investigations.ts | 12 - packages/domain/src/http/v2/openapi.test.ts | 278 +++++++++++++- .../domain/src/http/v2/public-error.test.ts | 7 + packages/domain/src/http/v2/query-errors.ts | 22 +- packages/domain/src/http/v2/scrape-targets.ts | 27 +- .../domain/src/http/v2/session-replays.ts | 4 +- packages/domain/src/http/v2/telemetry.ts | 10 +- packages/domain/src/http/warehouse-errors.ts | 169 +++++---- packages/domain/src/setup-audit.ts | 2 +- packages/query-engine/src/execution/errors.ts | 23 +- .../src/execution/executor.test.ts | 1 + .../query-engine/src/execution/executor.ts | 271 +++++++++---- packages/query-engine/src/execution/ports.ts | 67 ++-- .../src/execution/response-limits.ts | 5 + .../src/profiles/query-profile.ts | 12 - .../query-engine/src/runtime/query-engine.ts | 41 +- 86 files changed, 3140 insertions(+), 1626 deletions(-) create mode 100644 apps/api/src/mcp/lib/map-http-error.ts create mode 100644 packages/domain/src/http/org-clickhouse-settings-errors.ts diff --git a/apps/api/src/chat/tools.ts b/apps/api/src/chat/tools.ts index 04bfecb4f..a21f68b16 100644 --- a/apps/api/src/chat/tools.ts +++ b/apps/api/src/chat/tools.ts @@ -9,6 +9,7 @@ import { investigationIdFromChatSessionId } from "@maple/domain/chat-session" import { evaluatePermission, type PermissionRuleset } from "@maple/domain/permission" import { AiTriageResult, + InvestigationDataCorruptionError, InvestigationNotFoundError, InvestigationPersistenceError, SubmitDiagnosisRequest, @@ -42,7 +43,10 @@ export type SubmitDiagnosis = ( orgId: TenantContext["orgId"], investigationId: InvestigationId, request: SubmitDiagnosisRequest, -) => Effect.Effect +) => Effect.Effect< + unknown, + InvestigationPersistenceError | InvestigationNotFoundError | InvestigationDataCorruptionError +> export const buildSubmitDiagnosisTool = ( sessionId: string, diff --git a/apps/api/src/mcp/lib/dashboard-mutations.ts b/apps/api/src/mcp/lib/dashboard-mutations.ts index 8368393f8..47ac00abf 100644 --- a/apps/api/src/mcp/lib/dashboard-mutations.ts +++ b/apps/api/src/mcp/lib/dashboard-mutations.ts @@ -139,6 +139,8 @@ export const withDashboardMutation = Effect.fn("withDashboardMutation")(function Effect.catchTags({ "@maple/http/errors/DashboardPersistenceError": (error) => Effect.fail(new McpQueryError({ message: error.message, pipeName: tool, cause: error })), + "@maple/http/errors/DashboardStoredConfigInvalidError": (error) => + Effect.fail(new McpQueryError({ message: error.message, pipeName: tool, cause: error })), "@maple/http/errors/DashboardConcurrencyError": (error) => Effect.fail(new McpQueryError({ message: error.message, pipeName: tool, cause: error })), "@maple/http/errors/DashboardValidationError": (error) => diff --git a/apps/api/src/mcp/lib/map-http-error.ts b/apps/api/src/mcp/lib/map-http-error.ts new file mode 100644 index 000000000..c7d00c876 --- /dev/null +++ b/apps/api/src/mcp/lib/map-http-error.ts @@ -0,0 +1,12 @@ +import type { SelfDescribingHttpError } from "@maple/domain/http" +import { McpQueryError } from "@/mcp/tools/types" + +/** Adapt an HTTP-domain failure at the MCP protocol boundary without reclassifying its tag. */ +export const toMcpHttpError = + (pipeName: string) => + (error: SelfDescribingHttpError): McpQueryError => + new McpQueryError({ + message: `${error._tag}: ${error.error.message}`, + pipeName, + cause: error, + }) diff --git a/apps/api/src/mcp/lib/map-warehouse-error.ts b/apps/api/src/mcp/lib/map-warehouse-error.ts index 3ddc180c2..aadf41a75 100644 --- a/apps/api/src/mcp/lib/map-warehouse-error.ts +++ b/apps/api/src/mcp/lib/map-warehouse-error.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" import { type WarehouseError, WarehouseSchemaDriftError } from "@maple/domain" -import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" +import { warehouseHandlers, warehouseReadHandlers } from "@/services/warehouse/warehouse-error-handlers" import { McpQueryError } from "@/mcp/tools/types" -export { warehouseHandlers } +export { warehouseHandlers, warehouseReadHandlers } const SCHEMA_DRIFT_HINT = " — your ClickHouse cluster's schema is out of sync with what Maple expects. " + diff --git a/apps/api/src/mcp/tools/create-alert-rule.ts b/apps/api/src/mcp/tools/create-alert-rule.ts index 99295f414..dc24ec0fd 100644 --- a/apps/api/src/mcp/tools/create-alert-rule.ts +++ b/apps/api/src/mcp/tools/create-alert-rule.ts @@ -8,6 +8,7 @@ import { } from "./types" import { Effect, Match, Option, Schema } from "effect" import { createDualContent } from "@/mcp/lib/structured-output" +import { toMcpHttpError } from "@/mcp/lib/map-http-error" import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" import { AlertRulesService } from "@/services/alerts/AlertRulesService" import { AlertRuleUpsertRequest } from "@maple/domain/http" @@ -330,43 +331,9 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) { const tenant = yield* CurrentMcpTenant const alerts = yield* AlertRulesService - const rule = yield* alerts.createRule(tenant.orgId, tenant.userId, tenant.roles, decoded).pipe( - Effect.catchTag("@maple/http/errors/AlertValidationError", (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}\n${error.details.join("\n")}`, - pipeName: "create_alert_rule", - cause: error, - }), - ), - ), - Effect.catchTags({ - "@maple/http/errors/AlertForbiddenError": (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}`, - pipeName: "create_alert_rule", - cause: error, - }), - ), - "@maple/http/errors/AlertPersistenceError": (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}`, - pipeName: "create_alert_rule", - cause: error, - }), - ), - "@maple/http/errors/AlertRuleNotFoundError": (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}`, - pipeName: "create_alert_rule", - cause: error, - }), - ), - }), - ) + const rule = yield* alerts + .createRule(tenant.orgId, tenant.userId, tenant.roles, decoded) + .pipe(Effect.mapError(toMcpHttpError("create_alert_rule"))) const lines: string[] = [ `## Alert Rule Created`, diff --git a/apps/api/src/mcp/tools/get-alert-rule.ts b/apps/api/src/mcp/tools/get-alert-rule.ts index 6497314a8..1f7e85b9d 100644 --- a/apps/api/src/mcp/tools/get-alert-rule.ts +++ b/apps/api/src/mcp/tools/get-alert-rule.ts @@ -1,4 +1,5 @@ -import { McpQueryError, requiredStringParam, type McpToolRegistrar } from "./types" +import { requiredStringParam, type McpToolRegistrar } from "./types" +import { toMcpHttpError } from "@/mcp/lib/map-http-error" import { formatNextSteps } from "@/mcp/lib/next-steps" import { Effect, Schema } from "effect" import { createDualContent } from "@/mcp/lib/structured-output" @@ -23,16 +24,9 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) { const tenant = yield* CurrentMcpTenant const alerts = yield* AlertRulesService - const result = yield* alerts.listRules(tenant.orgId).pipe( - Effect.mapError( - (error) => - new McpQueryError({ - message: error.message, - pipeName: "get_alert_rule", - cause: error, - }), - ), - ) + const result = yield* alerts + .listRules(tenant.orgId) + .pipe(Effect.mapError(toMcpHttpError("get_alert_rule"))) const rule = result.rules.find((r) => r.id === rule_id) diff --git a/apps/api/src/mcp/tools/list-alert-rules.ts b/apps/api/src/mcp/tools/list-alert-rules.ts index 3af80acd7..b6e2510d7 100644 --- a/apps/api/src/mcp/tools/list-alert-rules.ts +++ b/apps/api/src/mcp/tools/list-alert-rules.ts @@ -1,5 +1,6 @@ -import { McpQueryError, optionalBooleanParam, optionalStringParam, type McpToolRegistrar } from "./types" +import { optionalBooleanParam, optionalStringParam, type McpToolRegistrar } from "./types" import { formatTable } from "@/mcp/lib/format" +import { toMcpHttpError } from "@/mcp/lib/map-http-error" import { formatNextSteps } from "@/mcp/lib/next-steps" import { Effect, Schema } from "effect" import { createDualContent } from "@/mcp/lib/structured-output" @@ -34,16 +35,9 @@ export function registerListAlertRulesTool(server: McpToolRegistrar) { const tenant = yield* CurrentMcpTenant const alerts = yield* AlertRulesService - const result = yield* alerts.listRules(tenant.orgId).pipe( - Effect.mapError( - (error) => - new McpQueryError({ - message: error.message, - pipeName: "list_alert_rules", - cause: error, - }), - ), - ) + const result = yield* alerts + .listRules(tenant.orgId) + .pipe(Effect.mapError(toMcpHttpError("list_alert_rules"))) let rules = result.rules diff --git a/apps/api/src/mcp/tools/query-data.ts b/apps/api/src/mcp/tools/query-data.ts index 2f742af45..7e677249c 100644 --- a/apps/api/src/mcp/tools/query-data.ts +++ b/apps/api/src/mcp/tools/query-data.ts @@ -26,7 +26,7 @@ import { type MetricsBreakdownQuery, } from "@maple/query-engine" import { formatQueryResult } from "@/mcp/lib/format-query-result" -import { warehouseErrorText, warehouseHandlers } from "@/mcp/lib/map-warehouse-error" +import { warehouseErrorText, warehouseReadHandlers } from "@/mcp/lib/map-warehouse-error" import { CommitSha, DeploymentEnvironment, @@ -373,13 +373,11 @@ export function registerQueryDataTool(server: McpToolRegistrar) { Effect.succeed(taggedErrorResult(error._tag, error.message, error.details)), ), Effect.catchTags({ - "@maple/http/errors/QueryEngineExecutionError": (error) => - Effect.succeed(taggedErrorResult(error._tag, error.message)), "@maple/http/errors/QueryEngineTimeoutError": (error) => Effect.succeed(taggedErrorResult(error._tag, error.message)), - // Shared 9-tag warehouse table; warehouseErrorText appends the + // Shared exact warehouse table; warehouseErrorText appends the // schema-apply hint for schema drift, matching the other MCP tools. - ...warehouseHandlers((error) => + ...warehouseReadHandlers((error) => Effect.succeed(taggedErrorResult(error._tag, warehouseErrorText(error))), ), }), diff --git a/apps/api/src/mcp/tools/update-alert-rule.ts b/apps/api/src/mcp/tools/update-alert-rule.ts index faa99f8b1..1c60cd3f4 100644 --- a/apps/api/src/mcp/tools/update-alert-rule.ts +++ b/apps/api/src/mcp/tools/update-alert-rule.ts @@ -8,6 +8,7 @@ import { } from "./types" import { Effect, Option, Schema } from "effect" import { createDualContent } from "@/mcp/lib/structured-output" +import { toMcpHttpError } from "@/mcp/lib/map-http-error" import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" import { AlertsService } from "@/services/alerts/AlertsService" import { AlertRulesService } from "@/services/alerts/AlertRulesService" @@ -195,16 +196,9 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) { const alerts = yield* AlertsService const rules = yield* AlertRulesService - const list = yield* rules.listRules(tenant.orgId).pipe( - Effect.mapError( - (error) => - new McpQueryError({ - message: error.message, - pipeName: "update_alert_rule", - cause: error, - }), - ), - ) + const list = yield* rules + .listRules(tenant.orgId) + .pipe(Effect.mapError(toMcpHttpError("update_alert_rule"))) const current = list.rules.find((r) => r.id === params.rule_id) if (!current) { @@ -240,43 +234,7 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) { const rule = yield* alerts .updateRule(tenant.orgId, tenant.userId, tenant.roles, current.id, decoded) - .pipe( - Effect.catchTag("@maple/http/errors/AlertValidationError", (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}\n${error.details.join("\n")}`, - pipeName: "update_alert_rule", - cause: error, - }), - ), - ), - Effect.catchTags({ - "@maple/http/errors/AlertForbiddenError": (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}`, - pipeName: "update_alert_rule", - cause: error, - }), - ), - "@maple/http/errors/AlertPersistenceError": (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}`, - pipeName: "update_alert_rule", - cause: error, - }), - ), - "@maple/http/errors/AlertRuleNotFoundError": (error) => - Effect.fail( - new McpQueryError({ - message: `${error._tag}: ${error.message}`, - pipeName: "update_alert_rule", - cause: error, - }), - ), - }), - ) + .pipe(Effect.mapError(toMcpHttpError("update_alert_rule"))) const lines: string[] = [ `## Alert Rule Updated`, diff --git a/apps/api/src/routes/v1/anomalies.http.ts b/apps/api/src/routes/v1/anomalies.http.ts index 226e04ac7..01612b8db 100644 --- a/apps/api/src/routes/v1/anomalies.http.ts +++ b/apps/api/src/routes/v1/anomalies.http.ts @@ -16,7 +16,7 @@ import { } from "@/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@/services/errors/ErrorsService" import { requireAdmin } from "@/services/auth/auth" -import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" +import { warehouseReadHandlers } from "@/services/warehouse/warehouse-error-handlers" // Preserve v1's historical persistence envelope while v2 exposes warehouse tags directly. const legacyPersistenceFailure = (error: { readonly message: string }) => @@ -96,7 +96,7 @@ export const HttpAnomaliesLive = HttpApiBuilder.group(MapleApi, "anomalies", (ha startTime: query.startTime, endTime: query.endTime, }) - .pipe(Effect.catchTags(warehouseHandlers(legacyPersistenceFailure))) + .pipe(Effect.catchTags(warehouseReadHandlers(legacyPersistenceFailure))) }).pipe(Effect.withSpan("HttpAnomalies.getIncidentTimeseries")), ) .handle("resolveIncident", ({ params }) => diff --git a/apps/api/src/routes/v1/dashboards.http.ts b/apps/api/src/routes/v1/dashboards.http.ts index 647e49078..1086493ff 100644 --- a/apps/api/src/routes/v1/dashboards.http.ts +++ b/apps/api/src/routes/v1/dashboards.http.ts @@ -1,6 +1,8 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant, + DashboardPersistenceError, + DashboardStoredConfigInvalidError, DashboardTemplateMetadata, DashboardTemplateNotFoundError, DashboardTemplatesListResponse, @@ -15,6 +17,24 @@ import { getTemplateById, listTemplateMetadata } from "@/dashboard-templates" import type { TemplateParameterValues } from "@/dashboard-templates" import { convertPersesDashboardToPortable } from "@/services/dashboards/perses-dashboard-import" +// v1 keeps its existing generic persistence contract. v2 exposes the exact +// non-retryable stored-document tag instead. +const preserveV1DashboardErrors = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.catchTag("@maple/http/errors/DashboardStoredConfigInvalidError", (error) => + Effect.fail( + new DashboardPersistenceError({ + message: + error instanceof DashboardStoredConfigInvalidError + ? error.message + : "Stored dashboard payload is invalid", + }), + ), + ), + ) + export const HttpDashboardsLive = HttpApiBuilder.group(MapleApi, "dashboards", (handlers) => Effect.gen(function* () { const persistence = yield* DashboardPersistenceService @@ -29,7 +49,7 @@ export const HttpDashboardsLive = HttpApiBuilder.group(MapleApi, "dashboards", ( .handle("list", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* persistence.list(tenant.orgId) + return yield* persistence.list(tenant.orgId).pipe(preserveV1DashboardErrors) }), ) .handle("importPerses", ({ payload }) => @@ -59,7 +79,9 @@ export const HttpDashboardsLive = HttpApiBuilder.group(MapleApi, "dashboards", ( } const tenant = yield* CurrentTenant.Context - return yield* persistence.upsert(tenant.orgId, tenant.userId, payload.dashboard) + return yield* persistence + .upsert(tenant.orgId, tenant.userId, payload.dashboard) + .pipe(preserveV1DashboardErrors) }), ) .handle("delete", ({ params }) => @@ -71,27 +93,28 @@ export const HttpDashboardsLive = HttpApiBuilder.group(MapleApi, "dashboards", ( .handle("listVersions", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* persistence.listVersions(tenant.orgId, params.dashboardId, { - limit: query.limit, - before: query.before, - }) + return yield* persistence + .listVersions(tenant.orgId, params.dashboardId, { + limit: query.limit, + before: query.before, + }) + .pipe(preserveV1DashboardErrors) }), ) .handle("getVersion", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* persistence.getVersion(tenant.orgId, params.dashboardId, params.versionId) + return yield* persistence + .getVersion(tenant.orgId, params.dashboardId, params.versionId) + .pipe(preserveV1DashboardErrors) }), ) .handle("restoreVersion", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* persistence.restoreVersion( - tenant.orgId, - tenant.userId, - params.dashboardId, - params.versionId, - ) + return yield* persistence + .restoreVersion(tenant.orgId, tenant.userId, params.dashboardId, params.versionId) + .pipe(preserveV1DashboardErrors) }), ) .handle("listTemplates", () => diff --git a/apps/api/src/routes/v1/errors.http.ts b/apps/api/src/routes/v1/errors.http.ts index 38fb4ec7d..93a744e7c 100644 --- a/apps/api/src/routes/v1/errors.http.ts +++ b/apps/api/src/routes/v1/errors.http.ts @@ -7,7 +7,7 @@ import { ErrorIssueWorkflowService } from "@/services/errors/ErrorIssueWorkflowS import { ErrorPolicyService } from "@/services/errors/ErrorPolicyService" import { ErrorsService } from "@/services/errors/ErrorsService" import { requireAdmin } from "@/services/auth/auth" -import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" +import { warehouseReadHandlers } from "@/services/warehouse/warehouse-error-handlers" import { makePersistenceError } from "@/services/errors/error-persistence" // Preserve v1's historical persistence envelope while v2 exposes warehouse tags directly. @@ -45,7 +45,7 @@ export const HttpErrorsLive = HttpApiBuilder.group(MapleApi, "errors", (handlers limit: query.limit, cursor: query.cursor, }) - .pipe(Effect.catchTags(warehouseHandlers(legacyPersistenceFailure))) + .pipe(Effect.catchTags(warehouseReadHandlers(legacyPersistenceFailure))) yield* Effect.annotateCurrentSpan("issueCount", response.issues.length) return response }).pipe(Effect.withSpan("HttpErrors.listIssues")), @@ -64,7 +64,7 @@ export const HttpErrorsLive = HttpApiBuilder.group(MapleApi, "errors", (handlers bucketSeconds: query.bucketSeconds, sampleLimit: query.sampleLimit, }) - .pipe(Effect.catchTags(warehouseHandlers(legacyPersistenceFailure))) + .pipe(Effect.catchTags(warehouseReadHandlers(legacyPersistenceFailure))) }).pipe(Effect.withSpan("HttpErrors.getIssue")), ) .handle("transitionIssue", ({ params, payload }) => diff --git a/apps/api/src/routes/v1/integrations.http.ts b/apps/api/src/routes/v1/integrations.http.ts index dd776fcd1..79151a5b8 100644 --- a/apps/api/src/routes/v1/integrations.http.ts +++ b/apps/api/src/routes/v1/integrations.http.ts @@ -110,6 +110,30 @@ const requireAdmin = (roles: ReadonlyArray) => () => new IntegrationsForbiddenError({ message: "Only org admins can manage integrations" }), ) +/** Preserve v1's collapsed PlanetScale mutation errors while v2 exposes their exact tags. */ +const v1PlanetScaleScrapeTargetErrors = { + "@maple/http/errors/ScrapeTargetValidationError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsValidationError({ message: error.message })), + "@maple/http/errors/ScrapeTargetNotFoundError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsPersistenceError({ message: error.message })), + "@maple/http/errors/ScrapeTargetPersistenceError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsPersistenceError({ message: error.message })), + "@maple/http/errors/ScrapeTargetEncryptionError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsPersistenceError({ message: error.message })), + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsPersistenceError({ message: error.message })), +} as const + +const v1PlanetScaleScrapeTargetPersistenceError = { + "@maple/http/errors/ScrapeTargetPersistenceError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsPersistenceError({ message: error.message })), +} as const + +const v1PlanetScaleStatusErrors = { + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError": (error: { readonly message: string }) => + Effect.fail(new IntegrationsPersistenceError({ message: error.message })), +} as const + export const HttpIntegrationsLive = HttpApiBuilder.group(MapleApi, "integrations", (handlers) => Effect.gen(function* () { const hazel = yield* HazelOAuthService @@ -435,7 +459,9 @@ export const HttpIntegrationsLive = HttpApiBuilder.group(MapleApi, "integrations .handle("planetscaleStatus", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* planetscale.getStatus(tenant.orgId) + return yield* planetscale + .getStatus(tenant.orgId) + .pipe(Effect.catchTags(v1PlanetScaleStatusErrors)) }), ) .handle("planetscaleStart", ({ payload }) => @@ -468,21 +494,27 @@ export const HttpIntegrationsLive = HttpApiBuilder.group(MapleApi, "integrations Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles) - return yield* planetscale.finalizeOrgSelection(tenant.orgId, payload) + return yield* planetscale + .finalizeOrgSelection(tenant.orgId, payload) + .pipe(Effect.catchTags(v1PlanetScaleScrapeTargetErrors)) }), ) .handle("planetscaleSetMetricsToken", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles) - return yield* planetscale.setMetricsToken(tenant.orgId, payload) + return yield* planetscale + .setMetricsToken(tenant.orgId, payload) + .pipe(Effect.catchTags(v1PlanetScaleScrapeTargetErrors)) }), ) .handle("planetscaleDisconnect", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles) - const result = yield* planetscale.disconnect(tenant.orgId) + const result = yield* planetscale + .disconnect(tenant.orgId) + .pipe(Effect.catchTags(v1PlanetScaleScrapeTargetPersistenceError)) return new PlanetScaleDisconnectResponse(result) }), ) diff --git a/apps/api/src/routes/v1/investigations.http.ts b/apps/api/src/routes/v1/investigations.http.ts index 89a0bfd25..ef2483a52 100644 --- a/apps/api/src/routes/v1/investigations.http.ts +++ b/apps/api/src/routes/v1/investigations.http.ts @@ -1,8 +1,31 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" -import { CurrentTenant, MapleApi } from "@maple/domain/http" +import { + CurrentTenant, + InvestigationDataCorruptionError, + InvestigationPersistenceError, + MapleApi, +} from "@maple/domain/http" import { Effect } from "effect" import { InvestigationService } from "@/services/errors/InvestigationService" +// v1 keeps its existing generic persistence response for unreadable stored +// investigation data; v2 exposes the exact corruption tag. +const preserveV1InvestigationErrors = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.catchTag("@maple/http/investigations/InvestigationDataCorruptionError", (error) => + Effect.fail( + new InvestigationPersistenceError({ + message: + error instanceof InvestigationDataCorruptionError + ? error.message + : "Stored investigation data is invalid", + }), + ), + ), + ) + /** * User-facing investigation endpoints (Clerk-authed via the group's * Authorization middleware). The internal `submit_diagnosis` write is a separate @@ -18,13 +41,15 @@ export const HttpInvestigationsLive = HttpApiBuilder.group(MapleApi, "investigat Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) - return yield* service.listInvestigations(tenant.orgId, { - issueId: query.issueId, - incidentKind: query.incidentKind, - incidentId: query.incidentId, - status: query.status, - limit: query.limit, - }) + return yield* service + .listInvestigations(tenant.orgId, { + issueId: query.issueId, + incidentKind: query.incidentKind, + incidentId: query.incidentId, + status: query.status, + limit: query.limit, + }) + .pipe(preserveV1InvestigationErrors) }).pipe(Effect.withSpan("HttpInvestigations.list")), ) .handle("getInvestigation", ({ params }) => @@ -34,7 +59,9 @@ export const HttpInvestigationsLive = HttpApiBuilder.group(MapleApi, "investigat orgId: tenant.orgId, "maple.investigation.id": params.id, }) - return yield* service.getInvestigation(tenant.orgId, params.id) + return yield* service + .getInvestigation(tenant.orgId, params.id) + .pipe(preserveV1InvestigationErrors) }).pipe(Effect.withSpan("HttpInvestigations.get")), ) .handle("createInvestigation", ({ payload }) => @@ -44,9 +71,9 @@ export const HttpInvestigationsLive = HttpApiBuilder.group(MapleApi, "investigat orgId: tenant.orgId, "maple.investigation.subject_type": payload.subject.type, }) - return yield* service.createAndStartInvestigation(tenant.orgId, tenant.userId, payload, { - automatic: false, - }) + return yield* service + .createAndStartInvestigation(tenant.orgId, tenant.userId, payload) + .pipe(preserveV1InvestigationErrors) }).pipe(Effect.withSpan("HttpInvestigations.create")), ) .handle("restartInvestigation", ({ params }) => @@ -56,7 +83,9 @@ export const HttpInvestigationsLive = HttpApiBuilder.group(MapleApi, "investigat orgId: tenant.orgId, "maple.investigation.id": params.id, }) - return yield* service.restartInvestigation(tenant.orgId, params.id) + return yield* service + .restartInvestigation(tenant.orgId, params.id) + .pipe(preserveV1InvestigationErrors) }).pipe(Effect.withSpan("HttpInvestigations.restart")), ) .handle("updateInvestigationStatus", ({ params, payload }) => @@ -67,7 +96,9 @@ export const HttpInvestigationsLive = HttpApiBuilder.group(MapleApi, "investigat "maple.investigation.id": params.id, "maple.investigation.status": payload.status, }) - return yield* service.updateStatus(tenant.orgId, params.id, payload.status) + return yield* service + .updateStatus(tenant.orgId, params.id, payload.status) + .pipe(preserveV1InvestigationErrors) }).pipe(Effect.withSpan("HttpInvestigations.updateStatus")), ) }), diff --git a/apps/api/src/routes/v1/scrape-targets.http.ts b/apps/api/src/routes/v1/scrape-targets.http.ts index c7495c832..7f3c7502d 100644 --- a/apps/api/src/routes/v1/scrape-targets.http.ts +++ b/apps/api/src/routes/v1/scrape-targets.http.ts @@ -34,6 +34,11 @@ const v1PlanetScaleAccessTokenErrors = { Effect.fail(new ScrapeTargetPersistenceError({ message: error.message })), } as const +const v1StoredConfigError = { + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError": (error: { readonly message: string }) => + Effect.fail(new ScrapeTargetPersistenceError({ message: error.message })), +} as const + export const HttpScrapeTargetsLive = HttpApiBuilder.group(MapleApi, "scrapeTargets", (handlers) => Effect.gen(function* () { const service = yield* ScrapeTargetsService @@ -42,7 +47,7 @@ export const HttpScrapeTargetsLive = HttpApiBuilder.group(MapleApi, "scrapeTarge .handle("list", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* service.list(tenant.orgId) + return yield* service.list(tenant.orgId).pipe(Effect.catchTags(v1StoredConfigError)) }), ) .handle("create", ({ payload }) => @@ -54,7 +59,9 @@ export const HttpScrapeTargetsLive = HttpApiBuilder.group(MapleApi, "scrapeTarge .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* service.update(tenant.orgId, params.targetId, payload) + return yield* service + .update(tenant.orgId, params.targetId, payload) + .pipe(Effect.catchTags(v1StoredConfigError)) }), ) .handle("delete", ({ params }) => 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 23f7d848f..8465bff44 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -46,6 +46,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + makeWarehouseServiceStub, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -73,17 +74,13 @@ const testConfig = () => ) /** The v2 CRUD endpoints exercised here never reach the warehouse. */ -const warehouseStub: WarehouseQueryServiceShape = { +const warehouseStub = makeWarehouseServiceStub({ query: () => Effect.die(new Error("unexpected warehouse pipe query")), - sqlQuery: () => Effect.succeed([]), rawSqlQuery: () => Effect.succeed([]), compiledQuery: (_tenant, compiled) => compiled.decodeRows([]).pipe(Effect.orDie), compiledQueryFirst: () => Effect.die(new Error("unexpected compiled query")), ingest: () => Effect.void, - asExecutor: () => { - throw new Error("asExecutor is not supported by this test stub") - }, -} +}) const session: ScopedPlanStatusSession = { emit: () => Effect.void, diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 9b53b3c59..3f27fad35 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it } from "@effect/vitest" import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { OrgId, UserId } from "@maple/domain/http" +import { + AlertMemberDirectoryUnavailableError, + IntegrationsRevokedError, + OrgId, + UserId, +} from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { BucketCacheService } from "@maple/query-engine/caching" import { EdgeCacheService } from "@maple/cache" @@ -20,15 +25,16 @@ import { AlertRuntime, AlertsService } from "@/services/alerts/AlertsService" import { AlertDestinationsService } from "@/services/alerts/AlertDestinationsService" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@/services/alerts/AlertRulesService" -import { HazelOAuthService } from "@/services/auth/HazelOAuthService" +import { HazelOAuthService, type HazelOAuthServiceShape } from "@/services/auth/HazelOAuthService" import { OrgClickHouseSettingsService } from "@/services/org/OrgClickHouseSettingsService" -import { OrgMembersService } from "@/services/org/OrgMembersService" +import { OrgMembersService, type OrgMembersServiceShape } from "@/services/org/OrgMembersService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + makeWarehouseServiceStub, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -56,19 +62,19 @@ const testConfig = () => ) /** The v2 alert CRUD endpoints never reach the warehouse; stub it inert. */ -const warehouseStub: WarehouseQueryServiceShape = { +const warehouseStub = makeWarehouseServiceStub({ query: () => Effect.die(new Error("unexpected warehouse pipe query")), - sqlQuery: () => Effect.succeed([]), rawSqlQuery: () => Effect.succeed([]), compiledQuery: (_tenant, compiled) => compiled.decodeRows([]).pipe(Effect.orDie), compiledQueryFirst: () => Effect.die(new Error("unexpected compiled query")), ingest: () => Effect.void, - asExecutor: () => { - throw new Error("asExecutor is not supported by this test stub") - }, -} +}) -const makeHarness = (warehouseService: WarehouseQueryServiceShape = warehouseStub) => { +const makeHarness = ( + warehouseService: WarehouseQueryServiceShape = warehouseStub, + hazelOAuthService?: HazelOAuthServiceShape, + orgMembersService?: OrgMembersServiceShape, +) => { const testDb = createTestDb(createdDbs) const configLive = testConfig() const envLive = Env.layer.pipe(Layer.provide(configLive)) @@ -87,14 +93,18 @@ const makeHarness = (warehouseService: WarehouseQueryServiceShape = warehouseStu fetch: globalThis.fetch, deliveryTimeoutMs: () => 15_000, }) - const hazelOAuthLive = HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) + const hazelOAuthLive = + hazelOAuthService === undefined + ? HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) + : Layer.succeed(HazelOAuthService, hazelOAuthService) const emailLive = Layer.succeed(EmailService, { isConfigured: false, send: () => Effect.void, }) - const orgMembersLive = Layer.succeed(OrgMembersService, { - resolveMembers: () => Effect.succeed([]), - }) + const orgMembersLive = Layer.succeed( + OrgMembersService, + orgMembersService ?? { resolveMembers: () => Effect.succeed([]) }, + ) // Held by AlertsService only to hand an autonomous investigation turn its `submit_diagnosis` // tool; nothing in these tests starts one. The real layer is cheap — Env plus the database. const investigationsLive = InvestigationService.layer.pipe( @@ -449,11 +459,128 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("returns the exact rule-destination tag for a missing destination_id", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const destination = await harness.request("POST", "/v2/alerts/destinations", key.secret, { + type: "webhook", + name: "Temporary destination", + url: "https://example.com/hooks/temporary", + }) + expect(destination.status).toBe(200) + const removed = await harness.request( + "DELETE", + `/v2/alerts/destinations/${destination.body.id}`, + key.secret, + ) + expect(removed.status).toBe(200) + + const response = await harness.request("POST", "/v2/alerts/rules", key.secret, { + name: "Missing destination", + severity: "warning", + signal_type: "error_rate", + comparator: "gt", + threshold: 0.1, + window_minutes: 5, + destination_ids: [destination.body.id], + }) + expect(response.status).toBe(404) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/AlertRuleDestinationNotFoundError", + code: "alert_rule_destination_not_found", + param: "destination_ids", + }) + + await harness.dispose() + }) + + it("reports malformed stored rules as a redacted storage failure", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const created = await createWebhookAndRule(harness, key.secret) + expect(created.rule.status).toBe(200) + await executeSql( + harness.testDb, + "update alert_rules set severity = 'not-a-severity' where org_id = $1", + ["org_alerts_e2e"], + ) + + const response = await harness.request("GET", "/v2/alerts/rules", key.secret) + expect(response.status).toBe(500) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/AlertRuleStoredConfigInvalidError", + code: "alert_rule_stored_config_invalid", + retryable: false, + recovery: "contact_support", + }) + expect(JSON.stringify(response.body)).not.toContain("not-a-severity") + + await harness.dispose() + }) + + it("does not erase malformed optional stored rule fields", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const created = await createWebhookAndRule(harness, key.secret) + expect(created.rule.status).toBe(200) + + await executeSql(harness.testDb, "update alert_rules set tags_json = '{}'::jsonb where org_id = $1", [ + "org_alerts_e2e", + ]) + + const response = await harness.request("GET", `/v2/alerts/rules/${created.rule.body.id}`, key.secret) + expect(response.status).toBe(500) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/AlertRuleStoredConfigInvalidError", + code: "alert_rule_stored_config_invalid", + }) + expect(JSON.stringify(response.body)).not.toContain("tags_json") + + await harness.dispose() + }) + + it("refuses to delete a destination when stored rule references cannot be decoded", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + const created = await createWebhookAndRule(harness, key.secret) + expect(created.destination.status).toBe(200) + expect(created.rule.status).toBe(200) + + await executeSql( + harness.testDb, + "update alert_rules set destination_ids_json = '{}'::jsonb where org_id = $1", + ["org_alerts_e2e"], + ) + + const response = await harness.request( + "DELETE", + `/v2/alerts/destinations/${created.destination.body.id}`, + key.secret, + ) + expect(response.status).toBe(500) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/AlertRuleStoredConfigInvalidError", + code: "alert_rule_stored_config_invalid", + retryable: false, + recovery: "contact_support", + }) + expect(JSON.stringify(response.body)).not.toContain("destination_ids") + + const destination = await harness.request( + "GET", + `/v2/alerts/destinations/${created.destination.body.id}`, + key.secret, + ) + expect(destination.status).toBe(200) + + await harness.dispose() + }) + it("blocks raw SQL preview for non-admin keys without querying the warehouse", async () => { let warehouseCalls = 0 const harness = makeHarness({ ...warehouseStub, - sqlQuery: () => { + rawSqlQuery: () => { warehouseCalls += 1 return Effect.succeed([{ value: 42 }]) }, @@ -636,6 +763,65 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("preserves the exact Hazel integration failure on destination create", async () => { + const unavailable = () => Effect.die("unexpected Hazel OAuth method") + const hazelOAuth: HazelOAuthServiceShape = { + startConnect: unavailable, + completeConnect: unavailable, + getStatus: unavailable, + getValidAccessToken: unavailable, + listOrganizations: unavailable, + listChannels: unavailable, + createChannelWebhook: () => + Effect.fail( + new IntegrationsRevokedError({ + message: "Hazel rejected the access token — reconnect required", + }), + ), + disconnect: unavailable, + } + const harness = makeHarness(warehouseStub, hazelOAuth) + const key = await harness.bootstrapKey(["alerts:write"]) + + const response = await harness.request("POST", "/v2/alerts/destinations", key.secret, { + type: "hazel-oauth", + name: "Hazel incidents", + hazel_organization_id: "hazel-org", + hazel_organization_name: "Maple", + hazel_channel_id: "hazel-channel", + hazel_channel_name: "incidents", + }) + + expect(response.status).toBe(401) + expect(response.body.error._tag).toBe("@maple/http/errors/IntegrationsRevokedError") + await harness.dispose() + }) + + it("preserves a member-directory outage on email destination create", async () => { + const orgMembers: OrgMembersServiceShape = { + resolveMembers: () => + Effect.fail( + new AlertMemberDirectoryUnavailableError({ + message: "Clerk member lookup failed", + cause: new Error("Clerk unavailable"), + }), + ), + } + const harness = makeHarness(warehouseStub, undefined, orgMembers) + const key = await harness.bootstrapKey(["alerts:write"]) + + const response = await harness.request("POST", "/v2/alerts/destinations", key.secret, { + type: "email", + name: "On-call email", + member_user_ids: ["user_2Nk8mXqPfR3yZ1aB4cD5eF6g"], + }) + + expect(response.status).toBe(503) + expect(response.body.error._tag).toBe("@maple/http/errors/AlertMemberDirectoryUnavailableError") + expect(JSON.stringify(response.body)).not.toContain("Clerk") + await harness.dispose() + }) + it("maps slack-bot update params and never clobbers the stored channel with a blank", async () => { const harness = makeHarness() const key = await harness.bootstrapKey(["alerts:write"]) @@ -688,6 +874,68 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("preserves exact stored destination failures through the HTTP envelope", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + + const invalidConfig = await harness.request("POST", "/v2/alerts/destinations", key.secret, { + type: "webhook", + name: "Invalid stored config", + url: "https://example.com/hooks/invalid-config", + }) + expect(invalidConfig.status).toBe(200) + await executeSql( + harness.testDb, + "update alert_destinations set config_json = '{}'::jsonb where name = $1", + ["Invalid stored config"], + ) + const invalidConfigList = await harness.request("GET", "/v2/alerts/destinations", key.secret) + expect(invalidConfigList.status).toBe(500) + expect(invalidConfigList.body.error._tag).toBe( + "@maple/http/errors/AlertDestinationStoredConfigInvalidError", + ) + + const invalidConfigTest = await harness.request( + "POST", + `/v2/alerts/destinations/${invalidConfig.body.id}/test`, + key.secret, + ) + expect(invalidConfigTest.status).toBe(500) + expect(invalidConfigTest.body.error).toMatchObject({ + _tag: "@maple/http/errors/AlertDestinationStoredConfigInvalidError", + code: "alert_destination_stored_config_invalid", + retryable: false, + recovery: "contact_support", + }) + expect(JSON.stringify(invalidConfigTest.body)).not.toContain("public_config") + + const unreadableSecret = await harness.request("POST", "/v2/alerts/destinations", key.secret, { + type: "webhook", + name: "Unreadable stored secret", + url: "https://example.com/hooks/unreadable-secret", + }) + expect(unreadableSecret.status).toBe(200) + await executeSql(harness.testDb, "update alert_destinations set secret_tag = '' where name = $1", [ + "Unreadable stored secret", + ]) + + const unreadableSecretTest = await harness.request( + "POST", + `/v2/alerts/destinations/${unreadableSecret.body.id}/test`, + key.secret, + ) + expect(unreadableSecretTest.status).toBe(500) + expect(unreadableSecretTest.body.error).toMatchObject({ + _tag: "@maple/http/errors/AlertDestinationDecryptionError", + code: "alert_destination_decryption_failed", + retryable: false, + recovery: "contact_support", + }) + expect(JSON.stringify(unreadableSecretTest.body)).not.toContain("Decryption failed") + + await harness.dispose() + }) + it("enforces the alerts scope family and rejects malformed ids", async () => { const harness = makeHarness() const readOnly = await harness.bootstrapKey(["alerts:read"]) 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 e2d2a3673..0095c12bf 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -4,7 +4,7 @@ import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { OrgId, ScrapeTargetId, UserId } from "@maple/domain/http" import { decodePublicId, MapleApiV2 } from "@maple/domain/http/v2" -import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@/platform/test-pglite" import type { WarehouseQueryServiceShape } from "@/services/warehouse/WarehouseQueryService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { Env } from "@/platform/Env" @@ -23,6 +23,7 @@ import { AlertsServiceStubLayer, AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, + makeWarehouseServiceStub, Phase1ResourceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, @@ -58,17 +59,13 @@ const testConfig = () => const die = () => Effect.die(new Error("not available in this test harness")) /** Recommendations reconcile against the warehouse; an empty read is a valid state. */ -const warehouseStub: WarehouseQueryServiceShape = { +const warehouseStub = makeWarehouseServiceStub({ query: () => Effect.die(new Error("unexpected warehouse pipe query")), - sqlQuery: () => Effect.succeed([]), rawSqlQuery: () => Effect.succeed([]), compiledQuery: (_tenant, compiled) => compiled.decodeRows([]).pipe(Effect.orDie), compiledQueryFirst: () => Effect.die(new Error("unexpected compiled query")), ingest: () => Effect.void, - asExecutor: () => { - throw new Error("asExecutor is not supported by this test stub") - }, -} +}) /** PlanetScale integrations are only reached by `planetscale` targets. */ const planetScaleStubs = Layer.mergeAll( @@ -173,11 +170,21 @@ const makeHarness = () => { }), ) } + const corruptScrapeDiscoveryConfig = async (publicId: string) => { + const internalId = decodePublicId("scrp", publicId) + if (internalId === null) throw new Error(`Invalid scrape target public ID: ${publicId}`) + await executeSql( + testDb, + "UPDATE scrape_targets SET target_type = 'planetscale', discovery_config_json = '{}'::jsonb WHERE id = $1", + [internalId], + ) + } return { request, bootstrapKey, seedScrapeChecks, + corruptScrapeDiscoveryConfig, dispose: async () => { await disposeHandler() await runtime.dispose() @@ -404,6 +411,35 @@ describe("v2 scrape_targets over HTTP", () => { expect(invalid.body.error.type).toBe("invalid_request_error") await harness.dispose() }) + + it("returns the exact stored-config tag instead of fabricating PlanetScale defaults", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey() + const created = await harness.request("POST", "/v2/scrape_targets", { + token: key.secret, + body: { + name: "corrupt target", + url: "https://example.com:1/metrics", + target_type: "prometheus", + }, + }) + expect(created.status).toBe(200) + await harness.corruptScrapeDiscoveryConfig(created.body.id) + + const response = await harness.request("GET", `/v2/scrape_targets/${created.body.id}`, { + token: key.secret, + }) + expect(response.status).toBe(502) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/ScrapeTargetStoredConfigInvalidError", + type: "api_error", + code: "scrape_target_stored_config_invalid", + retryable: false, + recovery: "reconnect", + }) + expect(JSON.stringify(response.body)).not.toContain("discovery_config_json") + await harness.dispose() + }) }) describe("v2 instrumentation recommendations over HTTP", () => { diff --git a/apps/api/src/routes/v2/dashboards.http.test.ts b/apps/api/src/routes/v2/dashboards.http.test.ts index 2c27a7d57..7aa78ae32 100644 --- a/apps/api/src/routes/v2/dashboards.http.test.ts +++ b/apps/api/src/routes/v2/dashboards.http.test.ts @@ -5,7 +5,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { OrgId, UserId } from "@maple/domain/http" import { DashboardTemplatePublicId, MapleApiV2 } from "@maple/domain/http/v2" import { Env } from "@/platform/Env" -import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@/platform/test-pglite" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -95,6 +95,7 @@ const makeHarness = () => { return { bootstrapKey, request, + testDb, dispose: async () => { await disposeHandler() await runtime.dispose() @@ -184,6 +185,34 @@ describe("v2 dashboards over HTTP", () => { await harness.dispose() }) + it("reports corrupt stored dashboards separately from retryable persistence failures", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["dashboards:write"]) + const created = await harness.request("POST", "/v2/dashboards", key.secret, { + name: "Corrupt me", + widgets: [], + }) + expect(created.status).toBe(200) + + await executeSql( + harness.testDb, + "update dashboards set payload_json = '\"not-a-dashboard\"'::jsonb where org_id = $1", + ["org_dashboard_e2e"], + ) + + const response = await harness.request("GET", `/v2/dashboards/${created.body.id}`, key.secret) + expect(response.status).toBe(500) + expect(response.body.error).toMatchObject({ + _tag: "@maple/http/errors/DashboardStoredConfigInvalidError", + code: "dashboard_stored_config_invalid", + retryable: false, + recovery: "contact_support", + }) + expect(JSON.stringify(response.body)).not.toContain("not-a-dashboard") + + await harness.dispose() + }) + // A widget may pin its own window; the field is optional, snake_cased on the // wire, and must survive a round-trip without leaking onto unpinned widgets. it("round-trips a per-widget time range", async () => { diff --git a/apps/api/src/routes/v2/integrations.http.test.ts b/apps/api/src/routes/v2/integrations.http.test.ts index ecdf7aa56..99b3f6dbc 100644 --- a/apps/api/src/routes/v2/integrations.http.test.ts +++ b/apps/api/src/routes/v2/integrations.http.test.ts @@ -9,7 +9,10 @@ import { IntegrationsUpstreamError, IntegrationsValidationError, OrgId, + ScrapeTargetEncryptionError, ScrapeTargetId, + ScrapeTargetNotFoundError, + ScrapeTargetStoredConfigInvalidError, UserId, } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" @@ -757,6 +760,37 @@ describe("v2 planetscale integration over HTTP", () => { await harness.dispose() }) + it("preserves a malformed managed-target tag on status", async () => { + const harness = makeHarness( + {}, + { + connection: { + getStatus: () => + Effect.fail( + new ScrapeTargetStoredConfigInvalidError({ + rawTargetId: "broken-target", + component: "discovery_config", + message: "stored discovery config is malformed", + cause: new Error("organization is missing"), + }), + ), + }, + }, + ) + const key = await harness.bootstrapAdminKey() + + const { status, body } = await harness.request("GET", "/v2/integrations/planetscale", key.secret) + expect(status).toBe(502) + expect(body.error).toMatchObject({ + _tag: "@maple/http/errors/ScrapeTargetStoredConfigInvalidError", + code: "scrape_target_stored_config_invalid", + retryable: false, + recovery: "reconnect", + }) + expect(JSON.stringify(body)).not.toContain("organization is missing") + await harness.dispose() + }) + it("attaches a metrics token for an admin and answers with the refreshed status", async () => { let received: { tokenId: string; tokenSecret: string } | null = null const harness = makeHarness( @@ -815,6 +849,71 @@ describe("v2 planetscale integration over HTTP", () => { await harness.dispose() }) + it("preserves a managed scrape-target encryption failure", async () => { + const harness = makeHarness( + {}, + { + connection: { + setMetricsToken: () => + Effect.fail( + new ScrapeTargetEncryptionError({ + message: "failed to encrypt token with key material", + }), + ), + }, + }, + ) + const key = await harness.bootstrapAdminKey() + + const { status, body } = await harness.request( + "POST", + "/v2/integrations/planetscale/metrics_token", + key.secret, + { body: { token_id: "tok_1", token_secret: "pscale_tkn_secret" } }, + ) + + expect(status).toBe(500) + expect(body.error).toMatchObject({ + _tag: "@maple/http/errors/ScrapeTargetEncryptionError", + code: "scrape_target_encryption_failed", + }) + expect(JSON.stringify(body)).not.toContain("key material") + await harness.dispose() + }) + + it("preserves a missing managed scrape target as an exact 404", async () => { + const harness = makeHarness( + {}, + { + connection: { + setMetricsToken: () => + Effect.fail( + new ScrapeTargetNotFoundError({ + targetId: connectedStatus.scrapeTarget.id, + message: "The managed PlanetScale scrape target no longer exists", + }), + ), + }, + }, + ) + const key = await harness.bootstrapAdminKey() + + const { status, body } = await harness.request( + "POST", + "/v2/integrations/planetscale/metrics_token", + key.secret, + { body: { token_id: "tok_1", token_secret: "pscale_tkn_secret" } }, + ) + + expect(status).toBe(404) + expect(body.error).toMatchObject({ + _tag: "@maple/http/errors/ScrapeTargetNotFoundError", + code: "scrape_target_not_found", + param: "id", + }) + await harness.dispose() + }) + it("rejects an empty token id at the schema boundary, before the service is reached", async () => { const harness = makeHarness({}, {}) const key = await harness.bootstrapAdminKey() diff --git a/apps/api/src/routes/v2/integrations.http.ts b/apps/api/src/routes/v2/integrations.http.ts index 9edc26236..22d2f41dd 100644 --- a/apps/api/src/routes/v2/integrations.http.ts +++ b/apps/api/src/routes/v2/integrations.http.ts @@ -1,9 +1,9 @@ import { HttpServerRequest } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import type { - IntegrationHttpError, PlanetScaleIntegrationStatus, PlanetScaleQueryInsightsResponse, + SelfDescribingHttpError, } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import type { PlanetScaleDatabaseRow } from "@maple/db" @@ -184,9 +184,9 @@ const toQueryInsightList = (response: PlanetScaleQueryInsightsResponse): V2Plane unavailable_reason: response.unavailableReason, }) -const mapIntegrationErrors = +const tapHttpErrors = (context: string) => - (effect: Effect.Effect) => + (effect: Effect.Effect) => effect.pipe( Effect.tapError((error) => Effect.logError(context, { tag: error._tag, message: error.message })), ) @@ -234,7 +234,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla const tenant = yield* CurrentTenant.Context const status = yield* slack .getStatus(tenant.orgId) - .pipe(mapIntegrationErrors("Slack integration status failed")) + .pipe(tapHttpErrors("Slack integration status failed")) return toSlackStatus(status) }), ) @@ -257,7 +257,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla const callbackUrl = `${origin}${SLACK_CALLBACK_PATH}` const result = yield* slack .startInstall(tenant.orgId, tenant.userId, callbackUrl) - .pipe(mapIntegrationErrors("Slack install failed")) + .pipe(tapHttpErrors("Slack install failed")) return { object: "slack_integration.install" as const, url: result.url, @@ -272,7 +272,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla ) yield* slack .uninstall(tenant.orgId) - .pipe(mapIntegrationErrors("Slack integration uninstall failed")) + .pipe(tapHttpErrors("Slack integration uninstall failed")) return { object: "slack_integration" as const, installed: false as const, @@ -292,7 +292,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla ) const list = yield* slack .listChannels(tenant.orgId) - .pipe(mapIntegrationErrors("Slack channel list failed")) + .pipe(tapHttpErrors("Slack channel list failed")) return toChannelList(list) }), ) @@ -317,7 +317,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( const tenant = yield* CurrentTenant.Context const status = yield* planetscale .getStatus(tenant.orgId) - .pipe(mapIntegrationErrors("PlanetScale status failed")) + .pipe(tapHttpErrors("PlanetScale status failed")) return toPlanetScaleStatus(status) }), ) @@ -352,7 +352,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( callbackUrl: `${origin}${PLANETSCALE_CALLBACK_PATH}`, returnTo: payload.return_to, }) - .pipe(mapIntegrationErrors("PlanetScale connect failed")) + .pipe(tapHttpErrors("PlanetScale connect failed")) return { object: "planetscale_integration.connect" as const, redirect_url: result.redirectUrl, @@ -372,7 +372,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( ) const organizations = yield* planetscaleOAuth .listOrganizations(tenant.orgId) - .pipe(mapIntegrationErrors("PlanetScale organization list failed")) + .pipe(tapHttpErrors("PlanetScale organization list failed")) return { object: "planetscale_integration.organization_list" as const, organizations: Arr.map(organizations, (org) => ({ @@ -396,7 +396,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( includeBranches: payload.include_branches, excludeBranches: payload.exclude_branches, }) - .pipe(mapIntegrationErrors("PlanetScale organization selection failed")) + .pipe(tapHttpErrors("PlanetScale organization selection failed")) return toPlanetScaleStatus(status) }), ) @@ -413,7 +413,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( tokenId: payload.token_id, tokenSecret: payload.token_secret, }) - .pipe(mapIntegrationErrors("PlanetScale metrics token update failed")) + .pipe(tapHttpErrors("PlanetScale metrics token update failed")) return toPlanetScaleStatus(status) }), ) @@ -425,7 +425,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( ) yield* planetscale .disconnect(tenant.orgId) - .pipe(mapIntegrationErrors("PlanetScale disconnect failed")) + .pipe(tapHttpErrors("PlanetScale disconnect failed")) return { object: "planetscale_integration" as const, connected: false as const, @@ -440,7 +440,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( const [rows, connection] = yield* Effect.all([ inventory.listDatabases(tenant.orgId), planetscale.loadConnection(tenant.orgId), - ]).pipe(mapIntegrationErrors("PlanetScale database list failed")) + ]).pipe(tapHttpErrors("PlanetScale database list failed")) return { object: "planetscale_integration.database_list" as const, databases: Arr.map(rows, toPlanetScaleDatabase), @@ -462,7 +462,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( const req = yield* HttpServerRequest.HttpServerRequest const config = yield* planetscale .webhookConfig(tenant.orgId) - .pipe(mapIntegrationErrors("PlanetScale webhook config failed")) + .pipe(tapHttpErrors("PlanetScale webhook config failed")) return { object: "planetscale_integration.webhook_config" as const, configured: config.configured, @@ -505,7 +505,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( }) .pipe( Effect.map(toQueryInsightList), - mapIntegrationErrors("PlanetScale query insights failed"), + tapHttpErrors("PlanetScale query insights failed"), ), ) return cached.value @@ -560,7 +560,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( })), next_cursor: nextCursor, })), - mapIntegrationErrors("PlanetScale event list failed"), + tapHttpErrors("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 d82e1560e..50aa5d457 100644 --- a/apps/api/src/routes/v2/investigations.http.ts +++ b/apps/api/src/routes/v2/investigations.http.ts @@ -249,7 +249,6 @@ export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "invest ? { snapshot: toInternalSnapshot(payload.snapshot) } : {}), }), - { automatic: false }, ) return yield* serializeInvestigation(doc) 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 d99cf90d3..38c894f03 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -27,8 +27,6 @@ import { InvestigationFanout, InvestigationIncidentSubject, InvestigationNotFoundError, - InvestigationQuotaError, - InvestigationRejectedError, InvestigationSnapshotFact, InvestigationSnapshotReference, InvestigationSubjectSnapshot, @@ -40,8 +38,8 @@ import { SpanId, TraceId, UserId, - WarehouseConfigDecryptionError, - WarehouseConfigLookupError, + OrgClickHouseSettingsEncryptionError, + OrgClickHouseSettingsPersistenceError, } from "@maple/domain/http" import { MapleApiV2, encodePublicId } from "@maple/domain/http/v2" import { WarehouseResponseLimitError } from "@maple/query-engine/execution" @@ -64,6 +62,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + makeWarehouseServiceStub, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -322,9 +321,7 @@ const errorIssueDetailFixture = new ErrorIssueDetailResponse({ const die = () => Effect.die(new Error("not exercised in this test harness")) /** Empty warehouse — enough to exercise the session_replays envelope + 404 paths. */ -const warehouseStub: WarehouseQueryServiceShape = { - query: die, - sqlQuery: () => Effect.succeed([]), +const warehouseStub = makeWarehouseServiceStub({ rawSqlQuery: () => Effect.succeed([]), compiledQuery: (_tenant, compiled) => compiled.decodeRows([]).pipe(Effect.orDie), // Replay payload reads go through the bounded variant (they carry an explicit @@ -335,10 +332,7 @@ const warehouseStub: WarehouseQueryServiceShape = { // route + capabilities, which this stub has nothing to resolve. warmRoute: () => Effect.void, ingest: () => Effect.void, - asExecutor: () => { - throw new Error("asExecutor is not supported by this test stub") - }, -} +}) const testConfig = () => ConfigProvider.layer( @@ -359,7 +353,7 @@ const testConfig = () => const ORG = Schema.decodeUnknownSync(OrgId)("org_phase1_e2e") const USER = Schema.decodeUnknownSync(UserId)("user_phase1_e2e") -type InvestigationStartMode = "success" | "quota" | "unavailable" | "rejected" | "restart_not_found" +type InvestigationStartMode = "success" | "unavailable" | "restart_not_found" type IssueReadFailure = "none" | "persistence" | "warehouse_config_lookup" | "warehouse_config_decryption" const makeHarness = ( @@ -384,15 +378,13 @@ const makeHarness = ( switch (issueReadFailure) { case "warehouse_config_lookup": return Effect.fail( - new WarehouseConfigLookupError({ - pipeName: "errorIssues", + new OrgClickHouseSettingsPersistenceError({ message: "SECRET_CONFIG_LOOKUP_FAILURE", }), ) case "warehouse_config_decryption": return Effect.fail( - new WarehouseConfigDecryptionError({ - pipeName: "errorIssues", + new OrgClickHouseSettingsEncryptionError({ message: "SECRET_DECRYPTION_FAILURE", }), ) @@ -402,28 +394,12 @@ const makeHarness = ( } const startInvestigation = () => { switch (investigationStartMode) { - case "quota": - return Effect.fail( - new InvestigationQuotaError({ - message: "Daily quota reached", - dimension: "passes", - limit: 90, - retryableAt: decodeIso("2026-07-16T00:00:00.000Z"), - }), - ) case "unavailable": return Effect.fail( new InvestigationAgentUnavailableError({ message: "Agent unavailable", }), ) - case "rejected": - return Effect.fail( - new InvestigationRejectedError({ - message: "Agent rejected the request", - status: 401, - }), - ) default: return Effect.succeed(investigationFixture) } @@ -741,8 +717,8 @@ describe("v2 error_issues over HTTP", () => { expect(response.status).toBe(503) expect(response.body.error).toMatchObject({ - _tag: "@maple/http/errors/WarehouseConfigLookupError", - code: "warehouse_config_lookup_unavailable", + _tag: "@maple/http/errors/OrgClickHouseSettingsPersistenceError", + code: "clickhouse_settings_unavailable", retryable: true, recovery: "retry", }) @@ -757,8 +733,8 @@ describe("v2 error_issues over HTTP", () => { expect(response.status).toBe(500) expect(response.body.error).toMatchObject({ - _tag: "@maple/http/errors/WarehouseConfigDecryptionError", - code: "warehouse_config_decryption_failed", + _tag: "@maple/http/errors/OrgClickHouseSettingsEncryptionError", + code: "clickhouse_settings_encryption_failed", retryable: false, recovery: "contact_support", }) @@ -914,7 +890,7 @@ describe("v2 investigations over HTTP", () => { await missingHarness.dispose() }) - it("preserves quota reset time and distinguishes unavailable from rejected starts", async () => { + it("preserves the exact unavailable-start failure", async () => { const createBody = { subject: { type: "freeform", @@ -924,26 +900,6 @@ describe("v2 investigations over HTTP", () => { }, } - const quotaHarness = makeHarness(warehouseStub, false, "quota") - const quotaKey = await quotaHarness.bootstrapKey() - const quota = await quotaHarness.request("POST", "/v2/investigations", { - token: quotaKey.secret, - body: createBody, - }) - expect(quota.status).toBe(429) - expect(quota.body.error.code).toBe("investigation_daily_quota") - expect(quota.body.error._tag).toBe("@maple/http/investigations/InvestigationQuotaError") - expect(quota.body.error.retryable).toBe(true) - expect(quota.body.error.recovery).toBe("retry") - expect(quota.body.error.retry_at).toBe("2026-07-16T00:00:00.000Z") - expect(quota.headers.get("retry-after")).toBe("Thu, 16 Jul 2026 00:00:00 GMT") - // The ceiling that was hit is named, so a raised run cap can't look ignored - // when it was the pass cap that stopped the start. - expect(quota.body.error.message).toBe( - "Daily limit of 90 model passes reached. Resets at 2026-07-16T00:00:00.000Z.", - ) - await quotaHarness.dispose() - const unavailableHarness = makeHarness(warehouseStub, false, "unavailable") const unavailableKey = await unavailableHarness.bootstrapKey() const unavailable = await unavailableHarness.request("POST", "/v2/investigations", { @@ -957,18 +913,6 @@ describe("v2 investigations over HTTP", () => { ) expect(unavailable.body.error.retryable).toBe(true) await unavailableHarness.dispose() - - const rejectedHarness = makeHarness(warehouseStub, false, "rejected") - const rejectedKey = await rejectedHarness.bootstrapKey() - const rejected = await rejectedHarness.request("POST", "/v2/investigations", { - token: rejectedKey.secret, - body: createBody, - }) - expect(rejected.status).toBe(502) - expect(rejected.body.error.code).toBe("investigation_start_rejected") - expect(rejected.body.error._tag).toBe("@maple/http/investigations/InvestigationRejectedError") - expect(rejected.body.error.retryable).toBe(false) - await rejectedHarness.dispose() }) }) 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 cc0a6b19c..a2b0a1544 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -25,6 +25,7 @@ import { AlertsServiceStubLayer, AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, + makeWarehouseServiceStub, Phase1ResourceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, @@ -69,23 +70,20 @@ const testConfig = () => */ const warehouseStub = ( rowsByTable: Readonly>>> = {}, -): WarehouseQueryServiceShape => ({ - query: () => Effect.die(new Error("unexpected warehouse pipe query")), - sqlQuery: () => Effect.succeed([]), - rawSqlQuery: () => Effect.succeed([]), - compiledQuery: (_tenant, compiled) => { - const table = Object.keys(rowsByTable).find((name) => compiled.sql.includes(`FROM ${name}`)) - return compiled.decodeRows(table === undefined ? [] : rowsByTable[table]!).pipe(Effect.orDie) - }, - compiledQueryFirst: () => Effect.die(new Error("unexpected compiled query")), - // `fetchWarehouseInputs` / `fetchTraceCompleteness` warm the route before - // their fan-outs; nothing to resolve against a stub. - warmRoute: () => Effect.void, - ingest: () => Effect.void, - asExecutor: () => { - throw new Error("asExecutor is not supported by this test stub") - }, -}) +): WarehouseQueryServiceShape => + makeWarehouseServiceStub({ + query: () => Effect.die(new Error("unexpected warehouse pipe query")), + rawSqlQuery: () => Effect.succeed([]), + compiledQuery: (_tenant, compiled) => { + const table = Object.keys(rowsByTable).find((name) => compiled.sql.includes(`FROM ${name}`)) + return compiled.decodeRows(table === undefined ? [] : rowsByTable[table]!).pipe(Effect.orDie) + }, + compiledQueryFirst: () => Effect.die(new Error("unexpected compiled query")), + // `fetchWarehouseInputs` / `fetchTraceCompleteness` warm the route before + // their fan-outs; nothing to resolve against a stub. + warmRoute: () => Effect.void, + ingest: () => Effect.void, + }) const unavailableWarehouse: WarehouseQueryServiceShape = { ...warehouseStub(), diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index eedb2cc8f..c1ea33676 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -22,6 +22,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + makeWarehouseServiceStub, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, } from "./v2-test-support" @@ -175,19 +176,15 @@ const rowsForSql = (sql: string): ReadonlyArray> => { return [] } -const warehouseStub: WarehouseQueryServiceShape = { +const warehouseStub = makeWarehouseServiceStub({ query: () => Effect.die(new Error("unexpected named query")), - sqlQuery: () => Effect.succeed([{ bucket: "2026-07-15 12:00:00", value: 1 }]), compiledQuery: (_tenant, compiled) => compiled.decodeRows(rowsForSql(compiled.sql)), compiledQueryFirst: (_tenant, compiled) => compiled .decodeRows(rowsForSql(compiled.sql)) .pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), ingest: () => Effect.void, - asExecutor: () => { - throw new Error("not used") - }, -} +}) const queryEngineStub = { execute: (_tenant, request) => { diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index d7a7fe016..7d3c15a3e 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -19,7 +19,10 @@ import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsServi import { SlackIntegrationService } from "@/services/integrations/SlackIntegrationService" import { SetupAuditService } from "@/services/org/SetupAuditService" import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter" -import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" +import { + WarehouseQueryService, + type WarehouseQueryServiceShape, +} from "@/services/warehouse/WarehouseQueryService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { HttpV2AlertDeliveriesLive } from "./alert-deliveries.http" import { HttpV2AlertDestinationsLive } from "./alert-destinations.http" @@ -159,7 +162,9 @@ export const Phase1ResourceStubsLayer = Layer.mergeAll( ) /** Inert WarehouseQueryService for harnesses that never touch warehouse-backed groups. */ -export const WarehouseServiceStubLayer = Layer.succeed(WarehouseQueryService, { +export const makeWarehouseServiceStub = ( + overrides: Partial = {}, +): WarehouseQueryServiceShape => ({ query: die, crossOrgQuery: die, rawSqlQuery: die, @@ -172,8 +177,11 @@ export const WarehouseServiceStubLayer = Layer.succeed(WarehouseQueryService, { warmRoute: () => Effect.void, ingest: die, asExecutor: dieSync, + ...overrides, }) +export const WarehouseServiceStubLayer = Layer.succeed(WarehouseQueryService, makeWarehouseServiceStub()) + export const TelemetryServiceStubsLayer = Layer.mergeAll( Layer.succeed(QueryEngineService, { execute: die, diff --git a/apps/api/src/services/alerts/AlertDestinationDelivery.ts b/apps/api/src/services/alerts/AlertDestinationDelivery.ts index 9e7b3ee08..0e0e2eb50 100644 --- a/apps/api/src/services/alerts/AlertDestinationDelivery.ts +++ b/apps/api/src/services/alerts/AlertDestinationDelivery.ts @@ -1,5 +1,7 @@ import { AlertDeliveryError, + AlertDestinationDecryptionError, + AlertDestinationStoredConfigInvalidError, AlertValidationError, type AlertNotificationTemplate, type AlertIncidentId, @@ -68,20 +70,22 @@ export const makeAlertDestinationDelivery = (options: { ) { const { publicConfig, secretConfig } = yield* hydrateDestinationRow(row, options.encryptionKey, { onPublicConfigInvalid: (cause) => - new AlertValidationError({ + new AlertDestinationStoredConfigInvalidError({ message: "Stored destination config is invalid", - details: [], + destinationId: row.id, + component: "public_config", cause, }), onDecryptFailure: () => - new AlertValidationError({ + new AlertDestinationDecryptionError({ message: "Failed to decrypt destination secret", - details: [], + destinationId: row.id, }), onSecretConfigInvalid: (cause) => - new AlertValidationError({ + new AlertDestinationStoredConfigInvalidError({ message: "Stored destination secret is invalid", - details: [], + destinationId: row.id, + component: "secret_config", cause, }), }) diff --git a/apps/api/src/services/alerts/AlertDestinationHydration.ts b/apps/api/src/services/alerts/AlertDestinationHydration.ts index 2b6cbf1bc..eb6c89cd2 100644 --- a/apps/api/src/services/alerts/AlertDestinationHydration.ts +++ b/apps/api/src/services/alerts/AlertDestinationHydration.ts @@ -88,15 +88,15 @@ const parseSecretConfig = ( ): Effect.Effect => Schema.decodeEffect(SecretConfigFromJson)(json).pipe(Effect.mapError(onError)) -export const hydrateDestinationRow = ( +export const hydrateDestinationRow = ( row: AlertDestinationRow, encryptionKey: Buffer, errors: { - onPublicConfigInvalid: (cause: unknown) => E - onDecryptFailure: () => E - onSecretConfigInvalid: (cause: unknown) => E + onPublicConfigInvalid: (cause: unknown) => PublicConfigError + onDecryptFailure: () => DecryptionError + onSecretConfigInvalid: (cause: unknown) => SecretConfigError }, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const publicConfig = yield* parsePublicConfig(row, errors.onPublicConfigInvalid) const secretJson = yield* decryptAes256Gcm( diff --git a/apps/api/src/services/alerts/AlertDestinationsService.ts b/apps/api/src/services/alerts/AlertDestinationsService.ts index d8eb82f76..207254864 100644 --- a/apps/api/src/services/alerts/AlertDestinationsService.ts +++ b/apps/api/src/services/alerts/AlertDestinationsService.ts @@ -1,7 +1,10 @@ import { AlertDeliveryError, + AlertDestinationDecryptionError, AlertDestinationDeleteResponse, AlertDestinationDocument, + AlertDestinationEncryptionError, + AlertDestinationStoredConfigInvalidError, AlertDestinationType as AlertDestinationTypeSchema, AlertDestinationInUseError, AlertDestinationTestResponse, @@ -10,10 +13,10 @@ import { AlertDestinationNotFoundError, AlertPersistenceError, AlertRuleDocument, + AlertRuleStoredConfigInvalidError, AlertValidationError, RoleName, type AlertDestinationCreateRequest, - type AlertDestinationType, type AlertDestinationUpdateRequest, type OrgId, type UserId, @@ -27,10 +30,14 @@ import { EmailService } from "@/platform/EmailService" import { Env } from "@/platform/Env" import { readTxid, txidColumn } from "@/platform/electric-txid" import { validateExternalUrl } from "@/http/url-validator" -import { HazelOAuthService } from "@/services/auth/HazelOAuthService" +import { HazelOAuthService, type HazelOAuthServiceShape } from "@/services/auth/HazelOAuthService" import { makeDbExecute } from "@/platform/db-execute" import { makePersistenceError } from "./alert-persistence" -import { OrgMembersService, type OrgMember } from "@/services/org/OrgMembersService" +import { + OrgMembersService, + type OrgMember, + type OrgMembersServiceShape, +} from "@/services/org/OrgMembersService" import { SlackBotTokenResolver } from "@/services/integrations/slack-bot-token" import { PAGERDUTY_ROUTING_KEY_PATTERN, verifyPagerDutyRoutingKey } from "./AlertDeliveryDispatch" import { @@ -40,8 +47,8 @@ import { } from "./AlertDestinationHydration" import { makeAlertDestinationDelivery, parseAlertDestinationEncryptionKey } from "./AlertDestinationDelivery" import { AlertRuntime } from "./AlertRuntime" +import { decodeStoredAlertRuleDestinationIds } from "./AlertRuleModel" -const StringArraySchema = Schema.Array(Schema.String) const decodeAlertDestinationIdSync = Schema.decodeUnknownSync(AlertDestinationDocument.fields.id) const decodeAlertDestinationTypeSync = Schema.decodeUnknownSync(AlertDestinationTypeSchema) const decodeAlertRuleIdSync = Schema.decodeUnknownSync(AlertRuleDocument.fields.id) @@ -53,13 +60,6 @@ const adminRoles = [decodeRoleNameSync("root"), decodeRoleNameSync("org:admin")] const makeValidationError = (message: string, details: ReadonlyArray = [], cause?: unknown) => new AlertValidationError({ message, details, ...(cause === undefined ? {} : { cause }) }) -const makeDeliveryError = (message: string, destinationType?: AlertDestinationType, cause?: unknown) => - new AlertDeliveryError({ - message, - destinationType, - ...(cause === undefined ? {} : { cause }), - }) - const normalizeOptionalString = (value: string | null | undefined) => { const trimmed = value?.trim() return trimmed && trimmed.length > 0 ? trimmed : null @@ -93,12 +93,23 @@ const emailSecretConfig = (members: ReadonlyArray): DestinationSecret })), }) +type AlertDestinationDependencyError = + | Effect.Error> + | Effect.Error> + const encryptSecret = ( plaintext: string, encryptionKey: Buffer, -): Effect.Effect => - encryptAes256Gcm(plaintext, encryptionKey, () => - makeValidationError("Failed to encrypt destination secret"), + destinationId: AlertDestinationDocument["id"], +): Effect.Effect => + encryptAes256Gcm( + plaintext, + encryptionKey, + () => + new AlertDestinationEncryptionError({ + message: "Failed to encrypt destination secret", + destinationId, + }), ) const summarizeWebhookUrl = (url: string) => @@ -151,16 +162,25 @@ const buildSecretConfig = ( }), ) -const safeParsePublicConfig = (row: AlertDestinationRow): DestinationPublicConfig => - Option.getOrElse(Schema.decodeUnknownOption(DestinationPublicConfigSchema)(row.configJson), () => ({ - summary: "Invalid destination config", - channelLabel: null, - })) - -const safeParseStringArray = (value: unknown): ReadonlyArray => - Option.getOrElse(Schema.decodeUnknownOption(StringArraySchema)(value), () => []) +const decodePublicConfig = ( + row: AlertDestinationRow, +): Effect.Effect => + Schema.decodeUnknownEffect(DestinationPublicConfigSchema)(row.configJson).pipe( + Effect.mapError( + (cause) => + new AlertDestinationStoredConfigInvalidError({ + message: "Stored destination config is invalid", + destinationId: row.id, + component: "public_config", + cause, + }), + ), + ) -const rowToDestinationDocument = (row: AlertDestinationRow, publicConfig: DestinationPublicConfig) => +const destinationDocumentFromRow = ( + row: AlertDestinationRow, + publicConfig: DestinationPublicConfig, +): AlertDestinationDocument => new AlertDestinationDocument({ id: decodeAlertDestinationIdSync(row.id), name: row.name, @@ -176,10 +196,28 @@ const rowToDestinationDocument = (row: AlertDestinationRow, publicConfig: Destin updatedAt: decodeIsoDateTimeStringSync(row.updatedAt.toISOString()), }) +const rowToDestinationDocument = ( + row: AlertDestinationRow, + publicConfig: DestinationPublicConfig, +): Effect.Effect => + Effect.try({ + try: () => destinationDocumentFromRow(row, publicConfig), + catch: (cause) => + new AlertDestinationStoredConfigInvalidError({ + message: "Stored destination document is invalid", + destinationId: row.id, + component: "document", + cause, + }), + }) + export interface AlertDestinationsServiceShape { readonly listDestinations: ( orgId: OrgId, - ) => Effect.Effect + ) => Effect.Effect< + AlertDestinationsListResponse, + AlertPersistenceError | AlertDestinationStoredConfigInvalidError + > readonly createDestination: ( orgId: OrgId, userId: UserId, @@ -187,7 +225,11 @@ export interface AlertDestinationsServiceShape { request: AlertDestinationCreateRequest, ) => Effect.Effect< AlertDestinationDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertDeliveryError + | AlertForbiddenError + | AlertValidationError + | AlertPersistenceError + | AlertDestinationEncryptionError + | AlertDestinationDependencyError > readonly updateDestination: ( orgId: OrgId, @@ -197,7 +239,14 @@ export interface AlertDestinationsServiceShape { request: AlertDestinationUpdateRequest, ) => Effect.Effect< AlertDestinationDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertDestinationNotFoundError + | AlertForbiddenError + | AlertValidationError + | AlertPersistenceError + | AlertDestinationNotFoundError + | AlertDestinationEncryptionError + | AlertDestinationDecryptionError + | AlertDestinationStoredConfigInvalidError + | AlertDestinationDependencyError > readonly deleteDestination: ( orgId: OrgId, @@ -209,6 +258,7 @@ export interface AlertDestinationsServiceShape { | AlertPersistenceError | AlertDestinationNotFoundError | AlertDestinationInUseError + | AlertRuleStoredConfigInvalidError > readonly testDestination: ( orgId: OrgId, @@ -221,7 +271,8 @@ export interface AlertDestinationsServiceShape { | AlertPersistenceError | AlertDestinationNotFoundError | AlertDeliveryError - | AlertValidationError + | AlertDestinationDecryptionError + | AlertDestinationStoredConfigInvalidError > } @@ -282,18 +333,16 @@ export class AlertDestinationsService extends Context.Service< ) }) - const resolveEmailMembers = ( - orgId: OrgId, - memberUserIds: ReadonlyArray, - ): Effect.Effect, AlertValidationError> => - orgMembers.resolveMembers(orgId, memberUserIds).pipe( - Effect.mapError((error) => makeValidationError(error.message, error.unknownUserIds ?? [])), - Effect.flatMap((members) => - members.length === 0 - ? Effect.fail(makeValidationError("At least one workspace member is required")) - : Effect.succeed(members), - ), - ) + const resolveEmailMembers = (orgId: OrgId, memberUserIds: ReadonlyArray) => + orgMembers + .resolveMembers(orgId, memberUserIds) + .pipe( + Effect.flatMap((members) => + members.length === 0 + ? Effect.fail(makeValidationError("At least one workspace member is required")) + : Effect.succeed(members), + ), + ) const markDestinationTest = Effect.fn("AlertsService.markDestinationTest")(function* ( orgId: OrgId, @@ -321,9 +370,10 @@ export class AlertDestinationsService extends Context.Service< .where(eq(alertDestinations.orgId, orgId)) .orderBy(desc(alertDestinations.createdAt), desc(alertDestinations.id)), ) - return new AlertDestinationsListResponse({ - destinations: rows.map((row) => rowToDestinationDocument(row, safeParsePublicConfig(row))), - }) + const destinations = yield* Effect.forEach(rows, (row) => + Effect.flatMap(decodePublicConfig(row), (config) => rowToDestinationDocument(row, config)), + ) + return new AlertDestinationsListResponse({ destinations }) }) const validatePagerDutyKey = Effect.fn("AlertsService.validatePagerDutyKey")(function* ( @@ -384,41 +434,15 @@ export class AlertDestinationsService extends Context.Service< webhookUrl: webhook.webhookUrl, webhookToken: webhook.token, })), - Effect.catchTags({ - "@maple/http/errors/IntegrationsNotConnectedError": (error) => - Effect.fail( - makeValidationError( - `Could not provision Hazel channel webhook: ${error.message}`, - ), - ), - "@maple/http/errors/IntegrationsRevokedError": (error) => - Effect.fail( - makeValidationError( - `Could not provision Hazel channel webhook: ${error.message}`, - ), - ), - "@maple/http/errors/IntegrationsValidationError": (error) => - Effect.fail( - makeValidationError( - `Could not provision Hazel channel webhook: ${error.message}`, - ), - ), - "@maple/http/errors/IntegrationsPersistenceError": (error) => - Effect.fail(makePersistenceError(error)), - "@maple/http/errors/IntegrationsUpstreamError": (error) => - Effect.fail( - makeDeliveryError( - "Could not provision Hazel channel webhook", - "hazel-oauth", - error, - ), - ), - }), ) : buildSecretConfig(request) } if (secretConfig.type === "pagerduty") yield* validatePagerDutyKey(secretConfig.integrationKey) - const encryptedSecret = yield* encryptSecret(JSON.stringify(secretConfig), encryptionKey) + const encryptedSecret = yield* encryptSecret( + JSON.stringify(secretConfig), + encryptionKey, + destinationId, + ) const timestamp = yield* runtime.now const row = { id: destinationId, @@ -441,7 +465,7 @@ export class AlertDestinationsService extends Context.Service< db.insert(alertDestinations).values(row).returning(txidColumn), ) const txid = readTxid(writeRows) - const document = rowToDestinationDocument(row, publicConfig) + const document = destinationDocumentFromRow(row, publicConfig) return txid === undefined ? document : new AlertDestinationDocument({ ...document, txid }) }) @@ -557,18 +581,10 @@ export class AlertDestinationsService extends Context.Service< const channelChanged = previousSecret == null || previousSecret.hazelChannelId !== nextChannelId const provisioned = channelChanged - ? yield* hazelOAuth - .createChannelWebhook(orgId, { - channelId: nextChannelId, - name: nextName, - }) - .pipe( - Effect.mapError((error) => - makeValidationError( - `Could not provision Hazel channel webhook: ${error.message}`, - ), - ), - ) + ? yield* hazelOAuth.createChannelWebhook(orgId, { + channelId: nextChannelId, + name: nextName, + }) : null return { nextPublicConfig: { @@ -638,7 +654,11 @@ export class AlertDestinationsService extends Context.Service< ) { yield* validatePagerDutyKey(nextSecretConfig.integrationKey) } - const encryptedSecret = yield* encryptSecret(JSON.stringify(nextSecretConfig), encryptionKey) + const encryptedSecret = yield* encryptSecret( + JSON.stringify(nextSecretConfig), + encryptionKey, + destinationId, + ) const timestamp = yield* runtime.now const nextName = normalizeOptionalString(request.name) ?? existing.name const nextEnabled = request.enabled === undefined ? existing.enabled : request.enabled @@ -659,7 +679,7 @@ export class AlertDestinationsService extends Context.Service< .returning(txidColumn), ) const txid = readTxid(writeRows) - const document = rowToDestinationDocument( + const document = yield* rowToDestinationDocument( { ...existing, name: nextName, @@ -690,16 +710,18 @@ export class AlertDestinationsService extends Context.Service< }) .from(alertRules) .where(eq(alertRules.orgId, orgId)), - ).pipe( - Effect.map((rows) => - rows.filter((row) => - safeParseStringArray(row.destinationIdsJson).includes(destinationId), - ), + ) + const decodedRules = yield* Effect.forEach(dependentRules, (row) => + decodeStoredAlertRuleDestinationIds(row.id, row.destinationIdsJson).pipe( + Effect.map((destinationIds) => ({ row, destinationIds })), ), ) - if (dependentRules.length > 0) { - const ruleIds = dependentRules.map((row) => decodeAlertRuleIdSync(row.id)) - const ruleNames = dependentRules.map((row) => row.name) + const referencedByRules = decodedRules.filter(({ destinationIds }) => + destinationIds.includes(destinationId), + ) + if (referencedByRules.length > 0) { + const ruleIds = referencedByRules.map(({ row }) => decodeAlertRuleIdSync(row.id)) + const ruleNames = referencedByRules.map(({ row }) => row.name) return yield* Effect.fail( new AlertDestinationInUseError({ message: `Destination is still used by alert rules: ${ruleNames.join(", ")}`, @@ -756,14 +778,6 @@ export class AlertDestinationsService extends Context.Service< error instanceof Error ? error.message : "Destination test failed", ), ), - Effect.mapError((error) => - error instanceof AlertDeliveryError - ? error - : makeDeliveryError( - error instanceof Error ? error.message : "Destination test failed", - decodeAlertDestinationTypeSync(row.type), - ), - ), ) yield* markDestinationTest(orgId, destinationId, null) return new AlertDestinationTestResponse({ diff --git a/apps/api/src/services/alerts/AlertRuleModel.ts b/apps/api/src/services/alerts/AlertRuleModel.ts index b8d5b3ff9..4037b8eb3 100644 --- a/apps/api/src/services/alerts/AlertRuleModel.ts +++ b/apps/api/src/services/alerts/AlertRuleModel.ts @@ -13,6 +13,7 @@ import { AlertGroupBy as AlertGroupBySchema, AlertNotificationTemplate, AlertRuleDocument, + AlertRuleStoredConfigInvalidError, AlertSeverity as AlertSeveritySchema, AlertSignalType as AlertSignalTypeSchema, AlertValidationError, @@ -29,7 +30,7 @@ import { type OrgId, } from "@maple/domain/http" import type { AlertRuleRow } from "@maple/db" -import { Array as Arr, Effect, Option, Result, Schema } from "effect" +import { Array as Arr, Effect, Result, Schema } from "effect" import { dateToMs, msToDate } from "@/platform/time" import type { AlertRuntimeShape } from "./AlertRuntime" @@ -37,7 +38,6 @@ const StringArraySchema = Schema.Array(Schema.String) const DestinationIdArraySchema = Schema.Array(AlertDestinationDocument.fields.id) const AlertGroupByFromJson = Schema.fromJsonString(AlertGroupBySchema) -const decodeAlertDestinationIdSync = Schema.decodeUnknownSync(AlertDestinationDocument.fields.id) const decodeAlertRuleIdSync = Schema.decodeUnknownSync(AlertRuleDocument.fields.id) const decodeQuerySpecSync = Schema.decodeUnknownSync(QuerySpec) const decodeIsoDateTimeStringSync = Schema.decodeUnknownSync(AlertDestinationDocument.fields.createdAt) @@ -46,7 +46,6 @@ const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema) const decodeQueryEngineAlertReducerSync = Schema.decodeUnknownSync(QueryEngineAlertReducer) const decodeNoDataBehaviorSync = Schema.decodeUnknownSync(QueryEngineNoDataBehavior) -const decodeAlertGroupByFromJsonSync = Schema.decodeUnknownSync(AlertGroupByFromJson) const decodeUserIdSync = Schema.decodeUnknownSync(UserId) export interface NormalizedRule { @@ -85,6 +84,54 @@ export interface RuleEvaluationState { readonly evaluatedAt: number | null } +export const normalizedRuleToDocument = ( + rule: NormalizedRule, + options: { + readonly notes: string | null + readonly userId: Schema.Schema.Type + readonly timestamp: number + readonly txid?: AlertRuleDocument["txid"] + }, +): AlertRuleDocument => { + const timestamp = decodeIsoDateTimeStringSync(msToDate(options.timestamp).toISOString()) + return new AlertRuleDocument({ + id: rule.id, + name: rule.name, + notes: options.notes, + notificationTemplate: rule.notificationTemplate, + enabled: rule.enabled, + severity: rule.severity, + serviceNames: [...rule.serviceNames], + excludeServiceNames: [...rule.excludeServiceNames], + environments: [...rule.environments], + tags: [...rule.tags], + groupBy: rule.groupBy, + signalType: rule.signalType, + comparator: rule.comparator, + threshold: rule.threshold, + thresholdUpper: rule.thresholdUpper, + windowMinutes: rule.windowMinutes, + minimumSampleCount: rule.minimumSampleCount, + consecutiveBreachesRequired: rule.consecutiveBreachesRequired, + consecutiveHealthyRequired: rule.consecutiveHealthyRequired, + renotifyIntervalMinutes: rule.renotifyIntervalMinutes, + apdexThresholdMs: rule.apdexThresholdMs, + queryBuilderDraft: rule.queryBuilderDraft, + rawQuerySql: rule.rawQuerySql, + rawQueryReducer: rule.rawQueryReducer, + destinationIds: [...rule.destinationIds], + noDataBehavior: rule.compiledPlan.noDataBehavior, + lastEvaluationError: null, + lastEvaluatedAt: null, + lastScheduledAt: null, + createdAt: timestamp, + updatedAt: timestamp, + createdBy: options.userId, + updatedBy: options.userId, + ...(options.txid === undefined ? {} : { txid: options.txid }), + }) +} + export const normalizeOptionalString = (value: string | null | undefined) => { const trimmed = value?.trim() return trimmed && trimmed.length > 0 ? trimmed : null @@ -101,34 +148,6 @@ export const makeAlertValidationError = ( ...(cause === undefined ? {} : { cause }), }) -export const safeParseStringArray = (value: unknown): ReadonlyArray => - Option.getOrElse(Schema.decodeUnknownOption(StringArraySchema)(value), () => [] as ReadonlyArray) - -const parseStoredGroupBy = (raw: string | null): AlertGroupBy | null => - raw == null ? null : decodeAlertGroupByFromJsonSync(raw) - -const parseStoredQueryBuilderDraft = (raw: unknown): QueryBuilderQueryDraftPayload | null => { - if (raw == null) return null - return Option.getOrElse(Schema.decodeUnknownOption(QueryBuilderQueryDraftSchema)(raw), () => null) -} - -const parseStoredNotificationTemplate = (raw: unknown): AlertNotificationTemplate | null => { - if (raw == null) return null - return Option.getOrElse(Schema.decodeUnknownOption(AlertNotificationTemplate)(raw), () => null) -} - -export const serviceNamesFromRow = (row: AlertRuleRow): ReadonlyArray => - row.serviceNamesJson ? safeParseStringArray(row.serviceNamesJson) : [] - -const excludeServiceNamesFromRow = (row: AlertRuleRow): ReadonlyArray => - row.excludeServiceNamesJson ? safeParseStringArray(row.excludeServiceNamesJson) : [] - -const environmentsFromRow = (row: AlertRuleRow): ReadonlyArray => - row.environmentsJson ? safeParseStringArray(row.environmentsJson) : [] - -const tagsFromRow = (row: AlertRuleRow): ReadonlyArray => - row.tagsJson ? safeParseStringArray(row.tagsJson) : [] - const normalizeTags = (tags: ReadonlyArray | undefined): ReadonlyArray => { if (!tags || tags.length === 0) return [] return Arr.dedupe( @@ -334,12 +353,27 @@ export const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(functi const parseCompiledPlan = ( row: Pick< AlertRuleRow, - "signalType" | "querySpecJson" | "rawQuerySql" | "reducer" | "sampleCountStrategy" | "noDataBehavior" + | "id" + | "signalType" + | "querySpecJson" + | "rawQuerySql" + | "reducer" + | "sampleCountStrategy" + | "noDataBehavior" >, -): Effect.Effect, AlertValidationError> => { +): Effect.Effect, AlertRuleStoredConfigInvalidError> => { + const invalid = (message: string, cause: unknown) => + new AlertRuleStoredConfigInvalidError({ + message, + ruleId: row.id, + component: "compiled_plan", + cause, + }) if (row.signalType === "raw_query") { if (row.rawQuerySql == null) { - return Effect.fail(makeAlertValidationError("Stored raw alert is missing its SQL query")) + return Effect.fail( + invalid("Stored raw alert is missing its SQL query", new Error("rawQuerySql is null")), + ) } return Schema.decodeUnknownEffect(CompiledAlertQueryPlan)({ kind: "raw_sql", @@ -348,11 +382,7 @@ const parseCompiledPlan = ( reducer: row.reducer, sampleCountStrategy: null, noDataBehavior: row.noDataBehavior, - }).pipe( - Effect.mapError((cause) => - makeAlertValidationError("Stored compiled alert plan is invalid", [], cause), - ), - ) + }).pipe(Effect.mapError((cause) => invalid("Stored compiled alert plan is invalid", cause))) } return Schema.decodeUnknownEffect(QuerySpec)(row.querySpecJson).pipe( Effect.flatMap((query) => @@ -365,9 +395,7 @@ const parseCompiledPlan = ( noDataBehavior: row.noDataBehavior, }), ), - Effect.mapError((cause) => - makeAlertValidationError("Stored compiled alert plan is invalid", [], cause), - ), + Effect.mapError((cause) => invalid("Stored compiled alert plan is invalid", cause)), ) } @@ -376,83 +404,187 @@ type IsoDateTimeValue = Schema.Schema.Type value == null ? null : decodeIsoDateTimeStringSync(value.toISOString()) +type StoredRuleComponent = AlertRuleStoredConfigInvalidError["component"] + +const decodeStoredRuleComponent = ( + ruleId: AlertRuleId, + component: StoredRuleComponent, + schema: S, + value: unknown, +): Effect.Effect => + Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError( + (cause) => + new AlertRuleStoredConfigInvalidError({ + message: `Stored alert rule ${component} is invalid`, + ruleId, + component, + cause, + }), + ), + ) + +const decodeStoredStringArray = ( + ruleId: AlertRuleId, + component: "service_names" | "exclude_service_names" | "environments" | "tags", + value: unknown, +): Effect.Effect, AlertRuleStoredConfigInvalidError> => + value == null + ? Effect.succeed([]) + : decodeStoredRuleComponent(ruleId, component, StringArraySchema, value) + +const decodeNullableStoredRuleComponent = ( + ruleId: AlertRuleId, + component: StoredRuleComponent, + schema: S, + value: unknown, +): Effect.Effect => + value == null ? Effect.succeed(null) : decodeStoredRuleComponent(ruleId, component, schema, value) + +export const decodeStoredAlertRuleMetadata = (row: AlertRuleRow) => + Effect.all({ + serviceNames: decodeStoredStringArray(row.id, "service_names", row.serviceNamesJson), + excludeServiceNames: decodeStoredStringArray( + row.id, + "exclude_service_names", + row.excludeServiceNamesJson, + ), + environments: decodeStoredStringArray(row.id, "environments", row.environmentsJson), + tags: decodeStoredStringArray(row.id, "tags", row.tagsJson), + groupBy: decodeNullableStoredRuleComponent(row.id, "group_by", AlertGroupByFromJson, row.groupBy), + notificationTemplate: decodeNullableStoredRuleComponent( + row.id, + "notification_template", + AlertNotificationTemplate, + row.notificationTemplateJson, + ), + queryBuilderDraft: decodeNullableStoredRuleComponent( + row.id, + "query_builder_draft", + QueryBuilderQueryDraftSchema, + row.queryBuilderDraftJson, + ), + }) + +export const decodeStoredAlertRuleDestinationIds = ( + ruleId: AlertRuleId, + value: unknown, +): Effect.Effect, AlertRuleStoredConfigInvalidError> => + Schema.decodeUnknownEffect(DestinationIdArraySchema)(value).pipe( + Effect.mapError( + (cause) => + new AlertRuleStoredConfigInvalidError({ + message: "Stored rule destinations are invalid", + ruleId, + component: "destination_ids", + cause, + }), + ), + ) + export const rowToRuleDocument = ( row: AlertRuleRow, - destinationIds: ReadonlyArray, evaluationState?: RuleEvaluationState, -) => { - const serviceNames = serviceNamesFromRow(row) - return new AlertRuleDocument({ - id: decodeAlertRuleIdSync(row.id), - name: row.name, - notes: row.notes ?? null, - notificationTemplate: parseStoredNotificationTemplate(row.notificationTemplateJson), - enabled: row.enabled, - severity: decodeAlertSeveritySync(row.severity), - serviceNames: [...serviceNames], - excludeServiceNames: [...excludeServiceNamesFromRow(row)], - environments: [...environmentsFromRow(row)], - tags: [...tagsFromRow(row)], - groupBy: parseStoredGroupBy(row.groupBy), - signalType: decodeAlertSignalTypeSync(row.signalType), - comparator: decodeAlertComparatorSync(row.comparator), - threshold: row.threshold, - thresholdUpper: row.thresholdUpper, - windowMinutes: row.windowMinutes, - minimumSampleCount: row.minimumSampleCount, - consecutiveBreachesRequired: row.consecutiveBreachesRequired, - consecutiveHealthyRequired: row.consecutiveHealthyRequired, - renotifyIntervalMinutes: row.renotifyIntervalMinutes, - apdexThresholdMs: row.apdexThresholdMs, - queryBuilderDraft: parseStoredQueryBuilderDraft(row.queryBuilderDraftJson), - rawQuerySql: row.signalType === "raw_query" ? (row.rawQuerySql ?? null) : null, - rawQueryReducer: - row.signalType === "raw_query" ? decodeQueryEngineAlertReducerSync(row.reducer) : null, - destinationIds: destinationIds.map((id) => decodeAlertDestinationIdSync(id)), - noDataBehavior: decodeNoDataBehaviorSync(row.noDataBehavior), - lastEvaluationError: evaluationState?.error ?? null, - lastEvaluatedAt: - evaluationState?.evaluatedAt != null - ? decodeIsoDateTimeStringSync(msToDate(evaluationState.evaluatedAt).toISOString()) - : null, - lastScheduledAt: toIso(row.lastScheduledAt), - createdAt: decodeIsoDateTimeStringSync(row.createdAt.toISOString()), - updatedAt: decodeIsoDateTimeStringSync(row.updatedAt.toISOString()), - createdBy: decodeUserIdSync(row.createdBy), - updatedBy: decodeUserIdSync(row.updatedBy), +): Effect.Effect => + Effect.gen(function* () { + const stored = yield* decodeStoredAlertRuleMetadata(row) + const destinationIds = yield* decodeStoredAlertRuleDestinationIds(row.id, row.destinationIdsJson) + return yield* Effect.try({ + try: () => { + return new AlertRuleDocument({ + id: decodeAlertRuleIdSync(row.id), + name: row.name, + notes: row.notes ?? null, + notificationTemplate: stored.notificationTemplate, + enabled: row.enabled, + severity: decodeAlertSeveritySync(row.severity), + serviceNames: [...stored.serviceNames], + excludeServiceNames: [...stored.excludeServiceNames], + environments: [...stored.environments], + tags: [...stored.tags], + groupBy: stored.groupBy, + signalType: decodeAlertSignalTypeSync(row.signalType), + comparator: decodeAlertComparatorSync(row.comparator), + threshold: row.threshold, + thresholdUpper: row.thresholdUpper, + windowMinutes: row.windowMinutes, + minimumSampleCount: row.minimumSampleCount, + consecutiveBreachesRequired: row.consecutiveBreachesRequired, + consecutiveHealthyRequired: row.consecutiveHealthyRequired, + renotifyIntervalMinutes: row.renotifyIntervalMinutes, + apdexThresholdMs: row.apdexThresholdMs, + queryBuilderDraft: stored.queryBuilderDraft, + rawQuerySql: row.signalType === "raw_query" ? (row.rawQuerySql ?? null) : null, + rawQueryReducer: + row.signalType === "raw_query" + ? decodeQueryEngineAlertReducerSync(row.reducer) + : null, + destinationIds, + noDataBehavior: decodeNoDataBehaviorSync(row.noDataBehavior), + lastEvaluationError: evaluationState?.error ?? null, + lastEvaluatedAt: + evaluationState?.evaluatedAt != null + ? decodeIsoDateTimeStringSync(msToDate(evaluationState.evaluatedAt).toISOString()) + : null, + lastScheduledAt: toIso(row.lastScheduledAt), + createdAt: decodeIsoDateTimeStringSync(row.createdAt.toISOString()), + updatedAt: decodeIsoDateTimeStringSync(row.updatedAt.toISOString()), + createdBy: decodeUserIdSync(row.createdBy), + updatedBy: decodeUserIdSync(row.updatedBy), + }) + }, + catch: (cause) => + new AlertRuleStoredConfigInvalidError({ + message: "Stored alert rule document is invalid", + ruleId: row.id, + component: "document", + cause, + }), + }) }) -} export const makeAlertRuleNormalizer = (runtime: AlertRuntimeShape) => { - const parseDestinationIds = ( - value: unknown, - ): Effect.Effect, AlertValidationError> => - Schema.decodeUnknownEffect(DestinationIdArraySchema)(value).pipe( - Effect.mapError((cause) => - makeAlertValidationError("Stored rule destinations are invalid", [], cause), - ), - ) - const normalizeRuleRow = Effect.fn("AlertsService.normalizeRuleRow")(function* ( row: AlertRuleRow, - ): Effect.fn.Return { - const serviceNames = serviceNamesFromRow(row) - const serviceName = serviceNames.length === 1 ? (serviceNames[0] ?? null) : null - const signalType = decodeAlertSignalTypeSync(row.signalType) + ): Effect.fn.Return { + const stored = yield* decodeStoredAlertRuleMetadata(row) + const decoded = yield* Effect.try({ + try: () => { + return { + id: decodeAlertRuleIdSync(row.id), + serviceName: stored.serviceNames.length === 1 ? (stored.serviceNames[0] ?? null) : null, + signalType: decodeAlertSignalTypeSync(row.signalType), + severity: decodeAlertSeveritySync(row.severity), + comparator: decodeAlertComparatorSync(row.comparator), + groupBy: stored.groupBy, + rawQueryReducer: + row.signalType === "raw_query" + ? decodeQueryEngineAlertReducerSync(row.reducer) + : null, + } + }, + catch: (cause) => + new AlertRuleStoredConfigInvalidError({ + message: "Stored alert rule fields are invalid", + ruleId: row.id, + component: "document", + cause, + }), + }) return { - id: decodeAlertRuleIdSync(row.id), + id: decoded.id, name: row.name, - notificationTemplate: parseStoredNotificationTemplate(row.notificationTemplateJson), + notificationTemplate: stored.notificationTemplate, enabled: row.enabled, - severity: decodeAlertSeveritySync(row.severity), - serviceName, - serviceNames, - excludeServiceNames: excludeServiceNamesFromRow(row), - environments: environmentsFromRow(row), - tags: tagsFromRow(row), - groupBy: parseStoredGroupBy(row.groupBy), - signalType, - comparator: decodeAlertComparatorSync(row.comparator), + severity: decoded.severity, + serviceName: decoded.serviceName, + serviceNames: stored.serviceNames, + excludeServiceNames: stored.excludeServiceNames, + environments: stored.environments, + tags: stored.tags, + groupBy: decoded.groupBy, + signalType: decoded.signalType, + comparator: decoded.comparator, threshold: row.threshold, thresholdUpper: row.thresholdUpper, windowMinutes: row.windowMinutes, @@ -461,11 +593,10 @@ export const makeAlertRuleNormalizer = (runtime: AlertRuntimeShape) => { consecutiveHealthyRequired: row.consecutiveHealthyRequired, renotifyIntervalMinutes: row.renotifyIntervalMinutes, apdexThresholdMs: row.apdexThresholdMs, - queryBuilderDraft: parseStoredQueryBuilderDraft(row.queryBuilderDraftJson), + queryBuilderDraft: stored.queryBuilderDraft, rawQuerySql: row.rawQuerySql ?? null, - rawQueryReducer: - row.signalType === "raw_query" ? decodeQueryEngineAlertReducerSync(row.reducer) : null, - destinationIds: yield* parseDestinationIds(row.destinationIdsJson), + rawQueryReducer: decoded.rawQueryReducer, + destinationIds: yield* decodeStoredAlertRuleDestinationIds(row.id, row.destinationIdsJson), compiledPlan: yield* parseCompiledPlan(row), createdAt: dateToMs(row.createdAt), updatedAt: dateToMs(row.updatedAt), diff --git a/apps/api/src/services/alerts/AlertRulesService.test.ts b/apps/api/src/services/alerts/AlertRulesService.test.ts index 71509b4aa..01e06bfa2 100644 --- a/apps/api/src/services/alerts/AlertRulesService.test.ts +++ b/apps/api/src/services/alerts/AlertRulesService.test.ts @@ -120,8 +120,8 @@ describe("AlertRulesService", () => { new AlertRuleUpsertRequest({ ...request, destinationIds: [unknown] }), ), ) - assert.strictEqual(error._tag, "@maple/http/errors/AlertValidationError") - assert.deepStrictEqual(error.details, [unknown]) + assert.strictEqual(error._tag, "@maple/http/errors/AlertRuleDestinationNotFoundError") + assert.strictEqual(error.destinationId, unknown) }).pipe(Effect.provide(makeLayer(testDb))) }) }) diff --git a/apps/api/src/services/alerts/AlertRulesService.ts b/apps/api/src/services/alerts/AlertRulesService.ts index 60ccb06bd..1ac17703e 100644 --- a/apps/api/src/services/alerts/AlertRulesService.ts +++ b/apps/api/src/services/alerts/AlertRulesService.ts @@ -1,6 +1,8 @@ import { AlertForbiddenError, + AlertRuleDestinationNotFoundError, AlertRuleNotFoundError, + AlertRuleStoredConfigInvalidError, AlertPersistenceError, AlertRuleDeleteResponse, AlertRuleDocument, @@ -30,9 +32,9 @@ import { dateToMs, msToDate } from "@/platform/time" import { makeAlertRuleNormalizer, makeAlertValidationError, + normalizedRuleToDocument, normalizeOptionalString, rowToRuleDocument, - safeParseStringArray, type RuleEvaluationState, } from "./AlertRuleModel" import { makePersistenceError } from "./alert-persistence" @@ -45,7 +47,9 @@ const MAX_ACTIVE_ALERT_RULES_PER_ORG = 100 const isAdmin = (roles: ReadonlyArray) => roles.some((role) => adminRoles.includes(role)) export interface AlertRulesServiceShape { - readonly listRules: (orgId: OrgId) => Effect.Effect + readonly listRules: ( + orgId: OrgId, + ) => Effect.Effect readonly createRule: ( orgId: OrgId, userId: UserId, @@ -53,7 +57,7 @@ export interface AlertRulesServiceShape { request: AlertRuleUpsertRequest, ) => Effect.Effect< AlertRuleDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertRuleNotFoundError + AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertRuleDestinationNotFoundError > readonly deleteRule: ( orgId: OrgId, @@ -84,7 +88,7 @@ export const makeAlertRulePersistence = (options: { ) }) - const requireRuleRow = Effect.fn("AlertsService.requireRuleRow")(function* ( + const findRuleRow = Effect.fn("AlertsService.findRuleRow")(function* ( orgId: OrgId, ruleId: AlertRuleDocument["id"], ) { @@ -95,7 +99,15 @@ export const makeAlertRulePersistence = (options: { .where(and(eq(alertRules.orgId, orgId), eq(alertRules.id, ruleId))) .limit(1), ) - if (rows[0]) return rows[0] + return rows[0] + }) + + const requireRuleRow = Effect.fn("AlertsService.requireRuleRow")(function* ( + orgId: OrgId, + ruleId: AlertRuleDocument["id"], + ) { + const row = yield* findRuleRow(orgId, ruleId) + if (row !== undefined) return row return yield* Effect.fail( new AlertRuleNotFoundError({ message: "Alert rule not found", @@ -122,12 +134,18 @@ export const makeAlertRulePersistence = (options: { ) const existingIds = HashSet.fromIterable(Arr.map(rows, (row) => row.id)) const missing = Arr.filter(destinationIds, (id) => !HashSet.has(existingIds, id)) - if (missing.length > 0) { - return yield* Effect.fail(makeAlertValidationError("Unknown destination IDs", missing)) + const missingDestinationId = missing[0] + if (missingDestinationId !== undefined) { + return yield* Effect.fail( + new AlertRuleDestinationNotFoundError({ + message: "Alert rule references an unknown destination", + destinationId: missingDestinationId, + }), + ) } }) - const upsertRuleRow = Effect.fn("AlertsService.upsertRuleRow")(function* ( + const writeRuleRow = Effect.fn("AlertsService.writeRuleRow")(function* ( orgId: OrgId, userId: UserId, existingId: AlertRuleId | null, @@ -212,9 +230,30 @@ export const makeAlertRulePersistence = (options: { ), ) } - const txid = readTxid(writeResult.writeRows) - const row = yield* requireRuleRow(orgId, ruleId) - const document = rowToRuleDocument(row, safeParseStringArray(row.destinationIdsJson)) + return { + normalized, + ruleId, + timestamp, + txid: readTxid(writeResult.writeRows), + } + }) + + const upsertRuleRow = Effect.fn("AlertsService.upsertRuleRow")(function* ( + orgId: OrgId, + userId: UserId, + existingId: AlertRuleId, + request: AlertRuleUpsertRequest, + ) { + const { ruleId, txid } = yield* writeRuleRow(orgId, userId, existingId, request) + const row = yield* findRuleRow(orgId, ruleId) + if (row === undefined) { + return yield* Effect.fail( + new AlertPersistenceError({ + message: "Alert rule row was not readable after it was saved", + }), + ) + } + const document = yield* rowToRuleDocument(row) return txid === undefined ? document : new AlertRuleDocument({ ...document, txid }) }) @@ -247,11 +286,8 @@ export const makeAlertRulePersistence = (options: { }) } } - return new AlertRulesListResponse({ - rules: rows.map((row) => - rowToRuleDocument(row, safeParseStringArray(row.destinationIdsJson), errorByRule.get(row.id)), - ), - }) + const rules = yield* Effect.forEach(rows, (row) => rowToRuleDocument(row, errorByRule.get(row.id))) + return new AlertRulesListResponse({ rules }) }) const createRule = Effect.fn("AlertsService.createRule")(function* ( @@ -261,7 +297,13 @@ export const makeAlertRulePersistence = (options: { request: AlertRuleUpsertRequest, ) { yield* requireAdmin(roles) - return yield* upsertRuleRow(orgId, userId, null, request) + const { normalized, timestamp, txid } = yield* writeRuleRow(orgId, userId, null, request) + return normalizedRuleToDocument(normalized, { + notes: normalizeOptionalString(request.notes), + userId, + timestamp, + ...(txid === undefined ? {} : { txid }), + }) }) const deleteRule = Effect.fn("AlertsService.deleteRule")(function* ( diff --git a/apps/api/src/services/alerts/AlertsService.test.ts b/apps/api/src/services/alerts/AlertsService.test.ts index b7c434fd6..bd30f5b39 100644 --- a/apps/api/src/services/alerts/AlertsService.test.ts +++ b/apps/api/src/services/alerts/AlertsService.test.ts @@ -4,6 +4,7 @@ import { TestClock } from "effect/testing" import { AlertDestinationInUseError, AlertForbiddenError, + AlertRecipientSelectionError, AlertValidationError, type AlertDestinationId, AlertRulePreviewRequest, @@ -33,7 +34,7 @@ import { Env } from "@/platform/Env" import { HazelOAuthService } from "@/services/auth/HazelOAuthService" import { EmailService } from "@/platform/EmailService" import { OrgClickHouseSettingsService } from "@/services/org/OrgClickHouseSettingsService" -import { OrgMembersError, OrgMembersService, type OrgMember } from "@/services/org/OrgMembersService" +import { OrgMembersService, type OrgMember } from "@/services/org/OrgMembersService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { cleanupTestDbs, createTestDb, executeSql, queryFirstRow, type TestDb } from "@/platform/test-pglite" import { Database } from "@/platform/DatabaseLive" @@ -167,7 +168,7 @@ const stubOrgMembersService = ( resolveMembers: (_orgId, userIds) => { const byId = new Map(members.map((member) => [member.userId, member])) const resolved: Array = [] - const unknown: Array = [] + const unknown: Array<(typeof userIds)[number]> = [] for (const userId of userIds) { const member = byId.get(userId) if (member === undefined) unknown.push(userId) @@ -175,7 +176,7 @@ const stubOrgMembersService = ( } return unknown.length > 0 ? Effect.fail( - new OrgMembersError({ + new AlertRecipientSelectionError({ message: "Some selected users are not members of this workspace", unknownUserIds: unknown, }), @@ -2198,8 +2199,9 @@ describe("AlertsService", () => { assert.isTrue(Exit.isFailure(exit)) const failure = getError(exit) - assert.instanceOf(failure, AlertValidationError) + assert.instanceOf(failure, AlertRecipientSelectionError) assert.include(failure.message, "not members") + assert.deepStrictEqual(failure.unknownUserIds, ["user_stranger"]) }) }) @@ -3025,7 +3027,7 @@ describe("AlertsService evaluation error persistence", () => { const errorChecks = state.ingested.filter((row) => row.Status === "error") assert.lengthOf(errorChecks, 1) assert.strictEqual(errorChecks[0]?.ErrorMessage, "Unknown column FooBar in traces") - assert.strictEqual(errorChecks[0]?.ErrorCategory, "tinybird_query") + assert.strictEqual(errorChecks[0]?.ErrorCategory, "warehouse_query_failed") assert.strictEqual(errorChecks[0]?.GroupKey, "__total__") const stateAfterFirstFailure = yield* Effect.promise(() => diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index b5da0c717..0e3929311 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -2,16 +2,19 @@ import { formatWarehouseDateTime } from "@maple/query-engine" import { AlertComparator as AlertComparatorSchema, AlertDeliveryError, + AlertDestinationDecryptionError, AlertDeliveryEventDocument, AlertDestinationDocument, + AlertDestinationStoredConfigInvalidError, AlertEvaluationResult, AlertEventType as AlertEventTypeSchema, AlertForbiddenError, - AlertGroupBy as AlertGroupBySchema, AlertIncidentDocument, AlertIncidentStatus, type AlertDestinationNotFoundError, + type AlertRuleDestinationNotFoundError, type AlertRuleNotFoundError, + type AlertRuleStoredConfigInvalidError, AlertPersistenceError, AlertRuleDocument, AlertRulePreviewFiringSpan, @@ -33,9 +36,7 @@ import { type AlertRuleId, type AlertDestinationId, type AlertIncidentId, - type QueryEngineExecutionError, - type WarehouseError, - type WarehouseErrorTag, + type WarehouseQueryPathError, type QueryEngineTimeoutError, type QueryEngineValidationError, RoleName, @@ -76,7 +77,6 @@ import { Context, } from "effect" import * as AlertingMetrics from "@/observability/AlertingMetrics" -import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers" import { INVESTIGATION_FANOUT_BINDING } from "@/services/errors/ai-triage-enqueue" import { upsertAlertIssue } from "@/services/errors/issue-hub" import { probeLiveness } from "@/services/alerts/telemetry-liveness" @@ -101,40 +101,15 @@ import { AlertReadModelsService, type AlertReadModelsServiceShape } from "./Aler import { AlertRulesService, makeAlertRulePersistence, type AlertRulesServiceShape } from "./AlertRulesService" import { compileRulePlan, + decodeStoredAlertRuleMetadata, isGroupedPlan, makeAlertValidationError as makeValidationError, planEvaluateSource, - serviceNamesFromRow, type NormalizedRule, } from "./AlertRuleModel" export { AlertRuntime, type AlertRuntimeShape } from "./AlertRuntime" -/** - * Persisted evaluation-failure category per warehouse tag (`ErrorCategory` on - * alert_checks rows and `failureCategory` in logs). The legacy `tinybird_*` - * names are kept stable on purpose — dashboards and stored rows key on them. - * `satisfies Record` makes a new warehouse error - * class a compile error here instead of a silently-uncategorized failure. - */ -const WAREHOUSE_FAILURE_CATEGORIES = { - "@maple/http/errors/WarehouseQueryError": "tinybird_query", - "@maple/http/errors/WarehouseUpstreamError": "tinybird_upstream", - "@maple/http/errors/WarehouseAuthError": "tinybird_auth", - "@maple/http/errors/WarehouseConfigError": "tinybird_config", - "@maple/http/errors/WarehouseConfigLookupError": "tinybird_config_lookup", - "@maple/http/errors/WarehouseConfigDecryptionError": "warehouse_config_decryption", - "@maple/http/errors/WarehouseStoredConfigInvalidError": "warehouse_config_invalid", - "@maple/http/errors/WarehouseTokenConfigError": "warehouse_token_config", - "@maple/http/errors/WarehouseTokenMintError": "warehouse_token_mint", - "@maple/http/errors/WarehouseClientError": "tinybird_client", - "@maple/http/errors/WarehouseSchemaDriftError": "tinybird_schema_drift", - "@maple/http/errors/WarehouseResultDecodeError": "warehouse_result_decode", - "@maple/http/errors/WarehouseMalformedQueryError": "malformed_query", - "@maple/http/errors/WarehouseQuotaExceededError": "tinybird_quota", - "@maple/http/errors/WarehouseValidationError": "tinybird_validation", -} satisfies Record - interface EvaluatedRule { readonly status: Schema.Schema.Type readonly value: number | null @@ -153,9 +128,11 @@ interface EvaluatedRule { readonly derivedFromNoData: boolean } +type AlertDestinationStorageError = AlertDestinationDecryptionError | AlertDestinationStoredConfigInvalidError + interface DeliveryAttemptFailure { readonly message: string - readonly kind: "transport" | "timeout" | "payload" | "destination" | "unknown" + readonly kind: string readonly retryable: boolean } @@ -201,8 +178,6 @@ const StoredDeliveryPayloadSchema = Schema.Struct({ template: Schema.optionalKey(Schema.NullOr(AlertNotificationTemplate)), }) -const AlertGroupByFromJson = Schema.fromJsonString(AlertGroupBySchema) - const decodeAlertRuleIdSync = Schema.decodeUnknownSync(AlertRuleDocument.fields.id) const decodeAlertIncidentIdSync = Schema.decodeUnknownSync(AlertIncidentDocument.fields.id) const decodeAlertDeliveryEventIdSync = Schema.decodeUnknownSync(AlertDeliveryEventDocument.fields.id) @@ -215,12 +190,8 @@ const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema const decodeAlertIncidentStatusSync = Schema.decodeUnknownSync(AlertIncidentStatus) const decodeAlertEventTypeSync = Schema.decodeUnknownSync(AlertEventTypeSchema) -const decodeAlertGroupByFromJsonSync = Schema.decodeUnknownSync(AlertGroupByFromJson) const decodeOrgIdSync = Schema.decodeUnknownSync(OrgId) -const parseStoredGroupBy = (raw: string | null): AlertGroupBy | null => - raw == null ? null : decodeAlertGroupByFromJsonSync(raw) - const isServiceGroupBy = (groupBy: AlertGroupBy | null): boolean => groupBy != null && groupBy.length === 1 && groupBy[0] === "service.name" @@ -234,12 +205,6 @@ const resolveServiceLinkName = ( } return null } -/** Parse the stored notification-template value; returns null when absent/invalid. */ -const parseStoredNotificationTemplate = (raw: unknown): AlertNotificationTemplate | null => { - if (raw == null) return null - return Option.getOrElse(Schema.decodeUnknownOption(AlertNotificationTemplate)(raw), () => null) -} - // Cap on how many evaluation windows a structured rule preview replays. const MAX_PREVIEW_BUCKETS = 200 @@ -323,7 +288,12 @@ export interface AlertsServiceShape request: AlertRuleUpsertRequest, ) => Effect.Effect< AlertRuleDocument, - AlertForbiddenError | AlertValidationError | AlertPersistenceError | AlertRuleNotFoundError + | AlertForbiddenError + | AlertValidationError + | AlertPersistenceError + | AlertRuleNotFoundError + | AlertRuleDestinationNotFoundError + | AlertRuleStoredConfigInvalidError > readonly testRule: ( orgId: OrgId, @@ -336,12 +306,12 @@ export interface AlertsServiceShape | AlertForbiddenError | AlertValidationError | AlertPersistenceError - | AlertDestinationNotFoundError + | AlertRuleDestinationNotFoundError | AlertDeliveryError + | AlertDestinationStorageError | QueryEngineValidationError - | QueryEngineExecutionError | QueryEngineTimeoutError - | WarehouseError + | WarehouseQueryPathError > /** * `roles` gates raw-SQL previews only: preview itself needs just `alerts:read`, @@ -356,11 +326,9 @@ export interface AlertsServiceShape AlertRulePreviewResponse, | AlertValidationError | AlertForbiddenError - | AlertPersistenceError | QueryEngineValidationError - | QueryEngineExecutionError | QueryEngineTimeoutError - | WarehouseError + | WarehouseQueryPathError > readonly runSchedulerTick: () => Effect.Effect< { @@ -374,6 +342,7 @@ export interface AlertsServiceShape | AlertValidationError | AlertRuleNotFoundError | AlertDestinationNotFoundError + | AlertRuleStoredConfigInvalidError // Note: warehouse tagged errors flow up from evaluateRule but are caught // inside the per-rule Effect.catch in the scheduler tick, so the tick // itself never surfaces them. @@ -496,9 +465,8 @@ export class AlertsService extends Context.Service, | AlertValidationError | QueryEngineValidationError - | QueryEngineExecutionError | QueryEngineTimeoutError - | WarehouseError + | WarehouseQueryPathError > { yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.rule_id": rule.id }) const endMs = yield* now @@ -678,29 +646,17 @@ export class AlertsService extends Context.Service composeLinkUrl(resolveServiceLinkName(rule, groupKey)) const toDeliveryAttemptFailure = ( - error: AlertValidationError | AlertDeliveryError | AlertPersistenceError, - ): DeliveryAttemptFailure => - Match.value(error).pipe( - Match.discriminatorsExhaustive("_tag")({ - "@maple/http/errors/AlertValidationError": (e) => ({ - message: e.message, - kind: "payload" as const, - retryable: false, - }), - "@maple/http/errors/AlertDeliveryError": (e) => ({ - message: e.message, - kind: e.message.includes("timed out") - ? ("timeout" as const) - : ("transport" as const), - retryable: true, - }), - "@maple/http/errors/AlertPersistenceError": (e) => ({ - message: e.message, - kind: "unknown" as const, - retryable: false, - }), - }), - ) + error: + | AlertValidationError + | AlertDeliveryError + | AlertPersistenceError + | AlertDestinationStorageError + | AlertRuleStoredConfigInvalidError, + ): DeliveryAttemptFailure => ({ + message: error.message, + kind: error.error.code, + retryable: error.error.retryable, + }) const queueIncidentNotifications = Effect.fn("AlertsService.queueIncidentNotifications")( function* ( @@ -799,11 +755,11 @@ export class AlertsService extends Context.Service { yield* Effect.annotateCurrentSpan("orgId", orgId) const normalized = yield* normalizeRule(orgId, request.rule, { @@ -1381,8 +1335,7 @@ export class AlertsService extends Context.Service - processOneDelivery(row).pipe( - Effect.catchTags({ - "@maple/http/errors/AlertValidationError": (error) => - recoverDeliveryFailure(row, error), - "@maple/http/errors/AlertDeliveryError": (error) => - recoverDeliveryFailure(row, error), - "@maple/http/errors/AlertPersistenceError": (error) => - recoverDeliveryFailure(row, error), - }), - ), + processOneDelivery(row).pipe(Effect.catch((error) => recoverDeliveryFailure(row, error))), ) return { @@ -2413,8 +2363,8 @@ export class AlertsService extends Context.Service = [] const groupCacheKey = (orgId: string, ruleId: string, groupKey: string) => - `${orgId}${ruleId}${groupKey}` - const ruleCacheKey = (orgId: string, ruleId: string) => `${orgId}${ruleId}` + `${orgId}\u0000${ruleId}\u0000${groupKey}` + const ruleCacheKey = (orgId: string, ruleId: string) => `${orgId}\u0000${ruleId}` /** * Load the tick's state and open-incident rows in ONE `execute`. @@ -2684,10 +2634,10 @@ export class AlertsService extends Context.Service - recordEvaluationFailure(row, error, "validation"), - "@maple/http/errors/AlertPersistenceError": (error) => - recordEvaluationFailure(row, error, "unknown"), - "@maple/http/errors/QueryEngineValidationError": (error) => - recordEvaluationFailure(row, error, "query_engine_validation"), - "@maple/http/errors/QueryEngineExecutionError": (error) => - recordEvaluationFailure(row, error, "query_engine_execution"), - "@maple/http/errors/QueryEngineTimeoutError": (error) => - recordEvaluationFailure(row, error, "query_engine_timeout"), - ...warehouseHandlers((error) => - recordEvaluationFailure( - row, - error, - WAREHOUSE_FAILURE_CATEGORIES[error._tag], - { - pipe: error.pipeName, - ...(error._tag === - "@maple/http/errors/WarehouseQuotaExceededError" - ? { quotaSetting: error.setting } - : {}), - ...(error._tag === - "@maple/http/errors/WarehouseUpstreamError" || - error._tag === "@maple/http/errors/WarehouseAuthError" - ? { upstreamStatus: error.upstreamStatus } - : {}), - }, - ), + Effect.catch((error) => + recordEvaluationFailure( + row, + error, + error.error.code, + "pipeName" in error + ? { + pipe: error.pipeName, + ...(error._tag === + "@maple/http/errors/WarehouseQuotaExceededError" + ? { quotaSetting: error.setting } + : {}), + ...(error._tag === + "@maple/http/errors/WarehouseUpstreamError" || + error._tag === "@maple/http/errors/WarehouseAuthError" + ? { upstreamStatus: error.upstreamStatus } + : {}), + } + : undefined, ), - }), + ), ) }), { concurrency: 5 }, diff --git a/apps/api/src/services/alerts/AnomalyDetectionService.ts b/apps/api/src/services/alerts/AnomalyDetectionService.ts index 6fe47f2f6..81171f6ea 100644 --- a/apps/api/src/services/alerts/AnomalyDetectionService.ts +++ b/apps/api/src/services/alerts/AnomalyDetectionService.ts @@ -21,7 +21,7 @@ import { RoleName, type UserId, UserId as UserIdSchema, - type WarehouseError, + type WarehouseReadError, } from "@maple/domain/http" import { anomalyDetectorSettings, @@ -201,7 +201,7 @@ export interface AnomalyDetectionServiceShape { opts: { readonly startTime?: string; readonly endTime?: string }, ) => Effect.Effect< AnomalyIncidentTimeseriesResponse, - AnomalyPersistenceError | AnomalyIncidentNotFoundError | WarehouseError + AnomalyPersistenceError | AnomalyIncidentNotFoundError | WarehouseReadError > readonly getSettings: ( orgId: OrgId, diff --git a/apps/api/src/services/dashboards/DashboardPersistenceService.test.ts b/apps/api/src/services/dashboards/DashboardPersistenceService.test.ts index 8838a52fd..f1fc6cd6d 100644 --- a/apps/api/src/services/dashboards/DashboardPersistenceService.test.ts +++ b/apps/api/src/services/dashboards/DashboardPersistenceService.test.ts @@ -5,6 +5,7 @@ import { DashboardDocument, DashboardNotFoundError, DashboardPersistenceError, + DashboardStoredConfigInvalidError, IsoDateTimeString, OrgId, PortableDashboardDocument, @@ -438,7 +439,10 @@ describe("DashboardPersistenceService", () => { ) assert.isTrue(Exit.isFailure(exit)) - assert.instanceOf(getError(exit), DashboardPersistenceError) + const error = getError(exit) + assert.instanceOf(error, DashboardStoredConfigInvalidError) + assert.strictEqual(error.component, "document") + assert.strictEqual(error.error.retryable, false) }).pipe(Effect.provide(makeLayer(testDb))) }) }) diff --git a/apps/api/src/services/dashboards/DashboardPersistenceService.ts b/apps/api/src/services/dashboards/DashboardPersistenceService.ts index e64075710..3669b8f2f 100644 --- a/apps/api/src/services/dashboards/DashboardPersistenceService.ts +++ b/apps/api/src/services/dashboards/DashboardPersistenceService.ts @@ -4,6 +4,7 @@ import { DashboardId, DashboardNotFoundError, DashboardPersistenceError, + DashboardStoredConfigInvalidError, DashboardValidationError, DashboardDocument, DashboardsListResponse, @@ -71,7 +72,14 @@ const parseTimestamp = (field: "createdAt" | "updatedAt", value: string) => { * the original lost, so the only safe answer to "this payload doesn't decode" is * to refuse it. The read-only version-history path may degrade instead. */ -const parsePayload = Effect.fnUntraced(function* (payloadJson: unknown) { +const parsePayload = Effect.fnUntraced(function* ( + payloadJson: unknown, + context: { + readonly dashboardId: DashboardId + readonly component: DashboardStoredConfigInvalidError["component"] + readonly versionId?: DashboardVersionId + }, +) { const outcome = yield* parseStoredDashboard(payloadJson) if (outcome._tag === "Rejected") { @@ -79,8 +87,12 @@ const parsePayload = Effect.fnUntraced(function* (payloadJson: unknown) { fromVersion: outcome.fromVersion, issue: outcome.issue, }) - return yield* new DashboardPersistenceError({ - message: "Stored dashboard payload is invalid JSON", + return yield* new DashboardStoredConfigInvalidError({ + message: "Stored dashboard payload is invalid", + dashboardId: context.dashboardId, + component: context.component, + ...(context.versionId === undefined ? {} : { versionId: context.versionId }), + cause: outcome.issue, }) } @@ -172,22 +184,27 @@ export interface DashboardPersistenceServiceShape { orgId: OrgId, userId: UserId, dashboard: PortableDashboardDocument, - ) => Effect.Effect< - DashboardDocument, - DashboardValidationError | DashboardPersistenceError | DashboardConcurrencyError - > - readonly list: (orgId: OrgId) => Effect.Effect + ) => Effect.Effect + readonly list: ( + orgId: OrgId, + ) => Effect.Effect readonly get: ( orgId: OrgId, dashboardId: DashboardId, - ) => Effect.Effect + ) => Effect.Effect< + DashboardDocument, + DashboardPersistenceError | DashboardNotFoundError | DashboardStoredConfigInvalidError + > readonly upsert: ( orgId: OrgId, userId: UserId, dashboard: DashboardDocument, ) => Effect.Effect< DashboardDocument, - DashboardValidationError | DashboardPersistenceError | DashboardConcurrencyError + | DashboardValidationError + | DashboardPersistenceError + | DashboardConcurrencyError + | DashboardStoredConfigInvalidError > readonly mutate: ( orgId: OrgId, @@ -201,7 +218,8 @@ export interface DashboardPersistenceServiceShape { | DashboardNotFoundError | DashboardValidationError | DashboardConcurrencyError - | DashboardPersistenceError, + | DashboardPersistenceError + | DashboardStoredConfigInvalidError, R > readonly delete: ( @@ -212,14 +230,20 @@ export interface DashboardPersistenceServiceShape { orgId: OrgId, dashboardId: DashboardId, options?: { readonly limit?: number; readonly before?: number }, - ) => Effect.Effect + ) => Effect.Effect< + DashboardVersionsListResponse, + DashboardPersistenceError | DashboardNotFoundError | DashboardStoredConfigInvalidError + > readonly getVersion: ( orgId: OrgId, dashboardId: DashboardId, versionId: DashboardVersionId, ) => Effect.Effect< DashboardVersionDetail, - DashboardPersistenceError | DashboardNotFoundError | DashboardVersionNotFoundError + | DashboardPersistenceError + | DashboardNotFoundError + | DashboardVersionNotFoundError + | DashboardStoredConfigInvalidError > readonly restoreVersion: ( orgId: OrgId, @@ -233,6 +257,7 @@ export interface DashboardPersistenceServiceShape { | DashboardVersionNotFoundError | DashboardValidationError | DashboardConcurrencyError + | DashboardStoredConfigInvalidError > } @@ -265,7 +290,10 @@ export class DashboardPersistenceService extends Context.Service< const row = rows[0] if (!row) return null - const document = yield* parsePayload(row.payloadJson) + const document = yield* parsePayload(row.payloadJson, { + dashboardId, + component: "document", + }) return { document, version: row.version } }) @@ -355,10 +383,14 @@ export class DashboardPersistenceService extends Context.Service< const list = Effect.fn("DashboardPersistenceService.list")(function* (orgId: OrgId) { yield* Effect.annotateCurrentSpan("orgId", orgId) - const rows: ReadonlyArray<{ readonly payloadJson: unknown }> = yield* database + const rows: ReadonlyArray<{ + readonly id: DashboardId + readonly payloadJson: unknown + }> = yield* database .execute((db) => db .select({ + id: dashboards.id, payloadJson: dashboards.payloadJson, }) .from(dashboards) @@ -367,7 +399,9 @@ export class DashboardPersistenceService extends Context.Service< ) .pipe(Effect.mapError(toPersistenceError)) - const dashboardDocuments = yield* Effect.forEach(rows, (row) => parsePayload(row.payloadJson)) + const dashboardDocuments = yield* Effect.forEach(rows, (row) => + parsePayload(row.payloadJson, { dashboardId: row.id, component: "document" }), + ) return new DashboardsListResponse({ dashboards: dashboardDocuments }) }) @@ -537,7 +571,24 @@ export class DashboardPersistenceService extends Context.Service< const nowMillis = yield* Clock.currentTimeMillis const createdDashboard = createDashboardDocument(dashboard, nowMillis) yield* Effect.annotateCurrentSpan("maple.dashboard.id", createdDashboard.id) - return yield* upsertInternal(orgId, userId, createdDashboard) + const payloadJson = yield* validatePayload(createdDashboard) + const createdAt = yield* parseTimestamp("createdAt", createdDashboard.createdAt) + const updatedAt = yield* parseTimestamp("updatedAt", createdDashboard.updatedAt) + const txid = yield* insertNew(orgId, userId, createdDashboard, createdAt, updatedAt, payloadJson) + + // Version history is secondary to the authoritative dashboard row. + yield* recordVersion(orgId, userId, createdDashboard, null).pipe( + Effect.tapError((error) => + Effect.logWarning("Failed to record initial dashboard version").pipe( + Effect.annotateLogs({ dashboardId: createdDashboard.id, error: String(error) }), + ), + ), + Effect.ignore, + ) + + return txid === undefined + ? createdDashboard + : new DashboardDocument({ ...createdDashboard, txid }) }) // Read-modify-write helper used by the MCP dashboard tools. Loads @@ -745,7 +796,11 @@ export class DashboardPersistenceService extends Context.Service< ) } - const snapshot = yield* parsePayload(row.snapshotJson) + const snapshot = yield* parsePayload(row.snapshotJson, { + dashboardId, + component: "version_snapshot", + versionId, + }) return new DashboardVersionDetail({ id: row.id, diff --git a/apps/api/src/services/errors/ErrorIssueReadModelsService.ts b/apps/api/src/services/errors/ErrorIssueReadModelsService.ts index 356c6222c..713998346 100644 --- a/apps/api/src/services/errors/ErrorIssueReadModelsService.ts +++ b/apps/api/src/services/errors/ErrorIssueReadModelsService.ts @@ -20,7 +20,7 @@ import { RoleName, UserId as UserIdSchema, type WorkflowState, - type WarehouseError, + type WarehouseReadError, } from "@maple/domain/http" import { errorIncidents, type ErrorIncidentRow, errorIssues } from "@maple/db" import { and, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm" @@ -99,7 +99,7 @@ export interface ErrorIssueReadModelsPublicShape { readonly actionable?: boolean readonly sort?: "last_seen" | "severity" }, - ) => Effect.Effect + ) => Effect.Effect /** Fleet-level open (actionable-state) error-issue counts grouped by service. */ readonly countOpenIssuesByService: ( orgId: OrgId, @@ -118,7 +118,7 @@ export interface ErrorIssueReadModelsPublicShape { }, ) => Effect.Effect< ErrorIssueDetailResponse, - ErrorPersistenceError | ErrorIssueNotFoundError | WarehouseError + ErrorPersistenceError | ErrorIssueNotFoundError | WarehouseReadError > readonly listIssueIncidents: ( orgId: OrgId, diff --git a/apps/api/src/services/errors/InvestigationService.test.ts b/apps/api/src/services/errors/InvestigationService.test.ts index d51158ab0..55c6294ba 100644 --- a/apps/api/src/services/errors/InvestigationService.test.ts +++ b/apps/api/src/services/errors/InvestigationService.test.ts @@ -5,6 +5,7 @@ import { AiTriageEvidence, AiTriageResult, InvestigationCreateRequest, + InvestigationDataCorruptionError, InvestigationFreeformSubject, InvestigationIncidentSubject, InvestigationId, @@ -186,6 +187,35 @@ describe("InvestigationService", () => { }).pipe(Effect.provide(makeLayer())), ) + it.effect("surfaces malformed stored snapshots and reports instead of erasing them", () => + Effect.gen(function* () { + const service = yield* InvestigationService + const database = yield* Database + const created = yield* service.createInvestigation( + ORG, + null, + freeformRequest("stored corruption"), + ) + + yield* database.execute((db) => + db.update(investigations).set({ snapshotJson: {} }).where(eq(investigations.id, created.id)), + ) + const snapshotError = yield* Effect.flip(service.getInvestigation(ORG, created.id)) + assert.instanceOf(snapshotError, InvestigationDataCorruptionError) + assert.strictEqual(snapshotError.field, "snapshot") + + yield* database.execute((db) => + db + .update(investigations) + .set({ snapshotJson: created.snapshot, reportJson: {} }) + .where(eq(investigations.id, created.id)), + ) + const reportError = yield* Effect.flip(service.getInvestigation(ORG, created.id)) + assert.instanceOf(reportError, InvestigationDataCorruptionError) + assert.strictEqual(reportError.field, "report") + }).pipe(Effect.provide(makeLayer())), + ) + it.effect("updateStatus transitions an investigation and persists the new status", () => Effect.gen(function* () { const service = yield* InvestigationService @@ -273,9 +303,7 @@ describe("InvestigationService", () => { const database = yield* Database const started = yield* InvestigationService.pipe( Effect.flatMap((service) => - service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_fanout"), { - automatic: false, - }), + service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_fanout")), ), ) assert.strictEqual(started.status, "investigating") @@ -311,9 +339,7 @@ describe("InvestigationService", () => { return Effect.gen(function* () { const started = yield* InvestigationService.pipe( Effect.flatMap((service) => - service.createAndStartInvestigation(ORG, null, freeformRequest("why is checkout slow"), { - automatic: false, - }), + service.createAndStartInvestigation(ORG, null, freeformRequest("why is checkout slow")), ), ) assert.strictEqual(started.status, "investigating") @@ -337,14 +363,7 @@ describe("InvestigationService", () => { const exit = yield* Effect.exit( InvestigationService.pipe( Effect.flatMap((service) => - service.createAndStartInvestigation( - ORG, - null, - criticalIncidentRequest("err_fanout"), - { - automatic: false, - }, - ), + service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_fanout")), ), ), ) @@ -406,7 +425,6 @@ describe("InvestigationService", () => { ORG, null, criticalIncidentRequest("err_over_budget"), - { automatic: false }, ) assert.strictEqual(started.status, "investigating") @@ -427,9 +445,7 @@ describe("InvestigationService", () => { const service = yield* InvestigationService const started = yield* InvestigationService.pipe( Effect.flatMap((service) => - service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_fanout"), { - automatic: false, - }), + service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_fanout")), ), ) // Seed a lane from the first attempt. @@ -481,7 +497,6 @@ describe("InvestigationService", () => { ORG, null, freeformRequest("err_autonomous_start"), - { automatic: false }, ) assert.strictEqual(started.status, "investigating") assert.lengthOf(chat.beginTurns, 1) @@ -505,9 +520,7 @@ describe("InvestigationService", () => { return Effect.gen(function* () { const service = yield* InvestigationService const error = yield* Effect.flip( - service.createAndStartInvestigation(ORG, null, incidentRequest("err_no_binding"), { - automatic: false, - }), + service.createAndStartInvestigation(ORG, null, incidentRequest("err_no_binding")), ) assert.instanceOf(error, InvestigationAgentUnavailableError) }).pipe(Effect.provide(harness.layer)) @@ -519,9 +532,7 @@ describe("InvestigationService", () => { return Effect.gen(function* () { const service = yield* InvestigationService const error = yield* Effect.flip( - service.createAndStartInvestigation(ORG, null, freeformRequest("err_busy"), { - automatic: false, - }), + service.createAndStartInvestigation(ORG, null, freeformRequest("err_busy")), ) assert.instanceOf(error, InvestigationStartFailedError) }).pipe(Effect.provide(harness.layer)) diff --git a/apps/api/src/services/errors/InvestigationService.ts b/apps/api/src/services/errors/InvestigationService.ts index 785b8be1b..50c359621 100644 --- a/apps/api/src/services/errors/InvestigationService.ts +++ b/apps/api/src/services/errors/InvestigationService.ts @@ -4,15 +4,13 @@ import { AiTriageResult, type InvestigationConfidence, InvestigationCreateRequest, + InvestigationDataCorruptionError, InvestigationDocument, InvestigationFanout, InvestigationAgentUnavailableError, - InvestigationAutomationDisabledError, InvestigationLensRun, InvestigationNotFoundError, InvestigationPersistenceError, - InvestigationQuotaError, - InvestigationRejectedError, InvestigationStartFailedError, InvestigationSnapshotFact, InvestigationSubjectSnapshot, @@ -30,21 +28,19 @@ import { encodeChatTurnTenant } from "@maple/domain/chat-session" import { chatSessionStub } from "@/chat/session" import type { TenantContext } from "@/services/auth/tenant-context" import { - aiTriageSettings, investigationLensRuns, investigations, type InvestigationLensRunRow, type InvestigationRow, } from "@maple/db" import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" -import { and, desc, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm" +import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm" import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect" import { trackTokenUsage } from "@/services/billing/autumn-tracker" import { applyDiagnosisWrites } from "@/services/errors/apply-diagnosis" import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@/workflows/incident-context" import { routeInvestigation, type InvestigationRoute } from "@/services/errors/investigation-route" import { FanoutStartError } from "@/services/errors/investigation-fanout-error" -import { evaluateInvestigationQuota, selectInvestigationUsage } from "@/services/errors/investigation-quota" import { STALE_BUDGETS, isInvestigationStale, @@ -67,9 +63,6 @@ interface FanoutWorkflowBinding { } const decodeIdSync = Schema.decodeUnknownSync(InvestigationId) -const decodeSubjectSync = Schema.decodeUnknownSync(InvestigationSubject) -const decodeSnapshotOption = Schema.decodeUnknownOption(InvestigationSubjectSnapshot) -const decodeResultOption = Schema.decodeUnknownOption(AiTriageResult) const decodeIsoSync = Schema.decodeUnknownSync(InvestigationDocument.fields.createdAt) export const newInvestigationId = () => decodeIdSync(randomUUID()) @@ -158,38 +151,39 @@ export interface ListInvestigationsOptions { readonly offset?: number } -export interface StartInvestigationOptions { - /** Automatic incident-open starts respect the per-org enabled flag. */ - readonly automatic: boolean -} - export interface InvestigationServiceShape { readonly listInvestigations: ( orgId: OrgId, opts: ListInvestigationsOptions, - ) => Effect.Effect + ) => Effect.Effect< + InvestigationsListResponse, + InvestigationPersistenceError | InvestigationDataCorruptionError + > readonly getInvestigation: ( orgId: OrgId, id: InvestigationId, - ) => Effect.Effect + ) => Effect.Effect< + InvestigationDocument, + InvestigationPersistenceError | InvestigationNotFoundError | InvestigationDataCorruptionError + > readonly createInvestigation: ( orgId: OrgId, userId: UserId | null, request: InvestigationCreateRequest, - ) => Effect.Effect + ) => Effect.Effect< + InvestigationDocument, + InvestigationPersistenceError | InvestigationDataCorruptionError + > readonly createAndStartInvestigation: ( orgId: OrgId, userId: UserId | null, request: InvestigationCreateRequest, - options: StartInvestigationOptions, ) => Effect.Effect< InvestigationDocument, | InvestigationPersistenceError - | InvestigationQuotaError - | InvestigationRejectedError - | InvestigationAutomationDisabledError | InvestigationAgentUnavailableError | InvestigationStartFailedError + | InvestigationDataCorruptionError > readonly restartInvestigation: ( orgId: OrgId, @@ -198,22 +192,26 @@ export interface InvestigationServiceShape { InvestigationDocument, | InvestigationPersistenceError | InvestigationNotFoundError - | InvestigationQuotaError - | InvestigationRejectedError - | InvestigationAutomationDisabledError | InvestigationAgentUnavailableError | InvestigationStartFailedError + | InvestigationDataCorruptionError > readonly updateStatus: ( orgId: OrgId, id: InvestigationId, status: InvestigationStatus, - ) => Effect.Effect + ) => Effect.Effect< + InvestigationDocument, + InvestigationPersistenceError | InvestigationNotFoundError | InvestigationDataCorruptionError + > readonly submitDiagnosis: ( orgId: OrgId, id: InvestigationId, request: SubmitDiagnosisRequest, - ) => Effect.Effect + ) => Effect.Effect< + InvestigationDocument, + InvestigationPersistenceError | InvestigationNotFoundError | InvestigationDataCorruptionError + > } /** Identity an autonomous investigation turn runs as — the same one the internal MCP RPC uses. */ @@ -254,20 +252,43 @@ export class InvestigationService extends Context.Service { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value) } - return decoded.value - }) + return "[stored JSON]" + } + + const storedDataCorruption = ( + investigationId: InvestigationId, + field: string, + value: unknown, + cause: unknown, + ) => + new InvestigationDataCorruptionError({ + message: `Stored investigation ${field} is invalid`, + investigationId, + field, + value: storedValueLabel(value), + cause, + }) + + const decodeStoredField = ( + investigationId: InvestigationId, + field: string, + schema: S, + value: unknown, + ): Effect.Effect => + Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError((cause) => storedDataCorruption(investigationId, field, value, cause)), + ) + + const parseReport = (row: InvestigationRow) => + row.reportJson == null + ? Effect.succeed(null) + : decodeStoredField(row.id, "report", AiTriageResult, row.reportJson) /** * Lens rows arrive as a parameter rather than being fetched here: this runs @@ -278,32 +299,47 @@ export class InvestigationService extends Context.Service = [], ) { - const subject = decodeSubjectSync(row.subjectJson) - const storedSnapshot = decodeSnapshotOption(row.snapshotJson) - return new InvestigationDocument({ - id: decodeIdSync(row.id), - status: row.status, - subject, - snapshot: Option.match(storedSnapshot, { - onNone: () => fallbackSnapshot(subject), - onSome: (snapshot) => snapshot, - }), - report: yield* parseReport(row.reportJson, row.id), - model: row.model ?? null, - severity: row.severity ?? null, - confidence: row.confidence ?? null, - seededBy: row.seededBy, - createdBy: row.createdBy ?? null, - inputTokens: row.inputTokens ?? null, - outputTokens: row.outputTokens ?? null, - error: row.error ?? null, - createdAt: iso(row.createdAt), - startedAt: row.startedAt ? iso(row.startedAt) : null, - diagnosedAt: row.diagnosedAt ? iso(row.diagnosedAt) : null, - updatedAt: iso(row.updatedAt), - lensRuns: lensRows.map(lensRowToDocument), - validator: validatorFor(row, lensRows), - fanout: new InvestigationFanout({ state: row.fanoutState, size: row.fanoutSize }), + const subject = yield* decodeStoredField( + row.id, + "subject", + InvestigationSubject, + row.subjectJson, + ) + const snapshot = + row.snapshotJson == null + ? fallbackSnapshot(subject) + : yield* decodeStoredField( + row.id, + "snapshot", + InvestigationSubjectSnapshot, + row.snapshotJson, + ) + const report = yield* parseReport(row) + return yield* Effect.try({ + try: () => + new InvestigationDocument({ + id: decodeIdSync(row.id), + status: row.status, + subject, + snapshot, + report, + model: row.model ?? null, + severity: row.severity ?? null, + confidence: row.confidence ?? null, + seededBy: row.seededBy, + createdBy: row.createdBy ?? null, + inputTokens: row.inputTokens ?? null, + outputTokens: row.outputTokens ?? null, + error: row.error ?? null, + createdAt: iso(row.createdAt), + startedAt: row.startedAt ? iso(row.startedAt) : null, + diagnosedAt: row.diagnosedAt ? iso(row.diagnosedAt) : null, + updatedAt: iso(row.updatedAt), + lensRuns: lensRows.map(lensRowToDocument), + validator: validatorFor(row, lensRows), + fanout: new InvestigationFanout({ state: row.fanoutState, size: row.fanoutSize }), + }), + catch: (cause) => storedDataCorruption(row.id, "document", row.id, cause), }) }) @@ -409,82 +445,6 @@ export class InvestigationService extends Context.Service - dbExecute((db) => - db.select().from(aiTriageSettings).where(eq(aiTriageSettings.orgId, orgId)).limit(1), - ).pipe(Effect.map((rows) => rows[0])) - - /** - * Both gates here are automatic-only. - * - * The daily budget exists to bound *unattended* spend — an incident storm - * opening investigations while nobody is watching. A person clicking - * Investigate or Retry is a deliberate act, and charging it to the same - * counter made the Retry button permanently dead once the org was at its - * ceiling, on a page whose failure copy tells the reader to press it. It - * was also double-counting: the usage query counts rows started today, and - * the row being restarted is already one of them. - */ - const ensureStartAllowed = Effect.fnUntraced(function* ( - orgId: OrgId, - automatic: boolean, - nowMs: number, - /** Model passes this start will consume. A fan-out of five is six. */ - passCount: number, - ) { - const settings = yield* loadSettings(orgId) - if (automatic && (settings === undefined || !settings.enabled)) { - return yield* Effect.fail( - new InvestigationAutomationDisabledError({ - message: "Automatic investigations are disabled for this organization.", - }), - ) - } - - // Manual starts are not budgeted — see this function's header. The check - // below is reached only by an automatic start, which today means nothing - // in production: the autonomous producer is `maybeEnqueueTriage`, which - // runs the same verdict itself and skips rather than failing. Kept wired - // (and declared 429 on the routes) so re-enabling is one flag, not a - // rebuild of the error path. - if (!automatic) return - - // Two ceilings counted in two different units, which is the whole point - // of having two columns. Both the query and the verdict live in - // `investigation-quota.ts` so the enqueue path cannot drift back into - // comparing passes against the runs limit — see that module's header. - const usage = yield* dbExecute((db) => selectInvestigationUsage(db, orgId, nowMs)) - const verdict = evaluateInvestigationQuota({ - usage, - limits: settings, - passCount, - nowMs, - }) - if (verdict.kind === "exceeded") { - yield* Effect.annotateCurrentSpan({ - "maple.investigation.start_result": "quota_skipped", - "maple.investigation.quota_dimension": verdict.dimension, - "maple.investigation.quota_limit": verdict.limit, - }) - return yield* Effect.fail( - new InvestigationQuotaError({ - message: - verdict.dimension === "runs" - ? `Daily limit of ${verdict.limit} investigations reached. Resets at midnight UTC.` - : `Daily limit of ${verdict.limit} model passes reached. Resets at midnight UTC.`, - dimension: verdict.dimension, - limit: verdict.limit, - retryableAt: decodeIsoSync(new Date(verdict.retryableAtMs).toISOString()), - }), - ) - } - }) - const markStartFailed = Effect.fnUntraced(function* ( orgId: OrgId, id: InvestigationId, @@ -593,6 +553,7 @@ export class InvestigationService extends Context.Service undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) + catch: (cause) => + new InvestigationStartFailedError({ + message: "The investigation agent could not start a turn.", + cause, + }), + }) if (!claimed) { // Either a turn is already running for this session — which for an investigation @@ -817,11 +782,11 @@ export class InvestigationService extends Context.Service db @@ -897,8 +861,6 @@ export class InvestigationService extends Context.Service { ) }) + it.effect("reports the exact missing managed target when attaching a metrics token", () => { + const testDb = createTestDb(trackedDbs) + const stub = stubPlanetScaleApi() + + return Effect.gen(function* () { + const service = yield* PlanetScaleConnectionService + const orgId = asOrgId("org_1") + + yield* storeGrant(orgId) + const bound = yield* service.finalizeOrgSelection(orgId, { organization: "acme" }) + const targetId = bound.scrapeTarget!.id + yield* Effect.promise(() => + executeSql(testDb, "DELETE FROM scrape_targets WHERE id = $1", [targetId]), + ) + + const error = yield* service + .setMetricsToken( + orgId, + new PlanetScaleMetricsTokenRequest({ tokenId: "tok_good", tokenSecret: "s3cret" }), + ) + .pipe(Effect.flip) + + assert.strictEqual(error._tag, "@maple/http/errors/ScrapeTargetNotFoundError") + if (error._tag === "@maple/http/errors/ScrapeTargetNotFoundError") { + assert.strictEqual(error.targetId, targetId) + } + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, stub), + Effect.provide(Layer.mergeAll(makeLayer(testDb), Layer.succeed(FetchHttpClient.Fetch, stub))), + ) + }) + it.effect("pauses metrics when the data plane rejects the bearer despite a passing SD probe", () => { const testDb = createTestDb(trackedDbs) const calls: Array<{ url: string; authorization: string | null }> = [] diff --git a/apps/api/src/services/integrations/PlanetScaleConnectionService.ts b/apps/api/src/services/integrations/PlanetScaleConnectionService.ts index d5c1470fc..312e21d85 100644 --- a/apps/api/src/services/integrations/PlanetScaleConnectionService.ts +++ b/apps/api/src/services/integrations/PlanetScaleConnectionService.ts @@ -9,11 +9,10 @@ import { PlanetScaleIntegrationStatus, PlanetScaleScrapeTargetSummary, ScrapeTargetId, + ScrapeTargetNotFoundError, + ScrapeTargetStoredConfigInvalidError, UserId, - type ScrapeTargetEncryptionError, - type ScrapeTargetNotFoundError, type ScrapeTargetPersistenceError, - type ScrapeTargetValidationError, type OrgId, type PlanetScaleMetricsTokenRequest, type PlanetScaleSelectOrganizationRequest, @@ -25,14 +24,14 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "@/platform/Crypto" import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" -import { decodeDiscoveryConfig } from "./planetscale/discovery-config" +import { DiscoveryConfigSchema } from "./planetscale/discovery-config" import { HttpSdResponse, PlanetScaleDiscoveryService, subTargetsFromGroup, } from "./PlanetScaleDiscoveryService" import { PlanetScaleOAuthService, planetScaleBearerHeader } from "@/services/auth/PlanetScaleOAuthService" -import { ScrapeTargetsService } from "./ScrapeTargetsService" +import { ScrapeTargetsService, type ScrapeTargetsServiceShape } from "./ScrapeTargetsService" /** * First-class PlanetScale integration: one OAuth-backed connection per org. @@ -56,10 +55,17 @@ export interface PlanetScaleDetectedPermissions { readonly readDatabases: boolean } +type ScrapeTargetMutationError = + | Effect.Error> + | Effect.Error> + export interface PlanetScaleConnectionServiceShape { readonly getStatus: ( orgId: OrgId, - ) => Effect.Effect + ) => Effect.Effect< + PlanetScaleIntegrationStatus, + IntegrationsPersistenceError | ScrapeTargetStoredConfigInvalidError + > /** * Bind the org's stored OAuth grant to one PlanetScale organization. Called * from the OAuth callback (single-org auto-bind) and the org-picker endpoint; @@ -79,6 +85,7 @@ export interface PlanetScaleConnectionServiceShape { | IntegrationsConfigurationError | IntegrationsUpstreamError | IntegrationsPersistenceError + | ScrapeTargetMutationError > /** * Attach (or rotate) the service token that authenticates branch-metrics @@ -96,11 +103,15 @@ export interface PlanetScaleConnectionServiceShape { | IntegrationsValidationError | IntegrationsUpstreamError | IntegrationsPersistenceError + | ScrapeTargetMutationError > /** Drop the org binding, the managed scrape target, and the OAuth grant. */ readonly disconnect: ( orgId: OrgId, - ) => Effect.Effect<{ readonly disconnected: boolean }, IntegrationsPersistenceError> + ) => Effect.Effect< + { readonly disconnected: boolean }, + IntegrationsPersistenceError | ScrapeTargetPersistenceError + > /** Load the org's connection row (null when not connected) — for pollers/webhooks. */ readonly loadConnection: ( orgId: OrgId, @@ -121,6 +132,19 @@ const toPersistenceError = (error: unknown) => const decodeUserIdSync = Schema.decodeUnknownSync(UserId) +const decodeStoredDiscoveryConfig = (row: typeof scrapeTargets.$inferSelect) => + Schema.decodeUnknownEffect(DiscoveryConfigSchema)(row.discoveryConfigJson).pipe( + Effect.mapError( + (cause) => + new ScrapeTargetStoredConfigInvalidError({ + rawTargetId: row.id, + component: "discovery_config", + message: "Stored PlanetScale discovery configuration is invalid", + cause, + }), + ), + ) + export class PlanetScaleConnectionService extends Context.Service< PlanetScaleConnectionService, PlanetScaleConnectionServiceShape @@ -331,7 +355,7 @@ export class PlanetScaleConnectionService extends Context.Service< }) } const target = yield* selectManagedTarget(connection) - const discoveryConfig = target ? decodeDiscoveryConfig(target.discoveryConfigJson) : null + const discoveryConfig = target ? yield* decodeStoredDiscoveryConfig(target) : null // How scraping authenticates: a stored service token wins; grant-resolved // bearer auth counts only while the target is enabled (finalize disables // it unless the bearer passed an end-to-end data-plane scrape probe — @@ -396,33 +420,12 @@ export class PlanetScaleConnectionService extends Context.Service< ), ) .pipe(Effect.mapError(toPersistenceError)) - return ( - rows.find( - (row) => decodeDiscoveryConfig(row.discoveryConfigJson)?.organization === organization, - ) ?? null + const targetsWithConfig = yield* Effect.forEach(rows, (row) => + Effect.map(decodeStoredDiscoveryConfig(row), (config) => ({ row, config })), ) + return targetsWithConfig.find(({ config }) => config.organization === organization)?.row ?? null }) - // Typed on the concrete scrape-target error union so a new tag added to - // ScrapeTargetsService's error channel fails compilation here instead of - // silently collapsing into a 503 persistence error. - const mapScrapeTargetError = ( - error: - | ScrapeTargetNotFoundError - | ScrapeTargetValidationError - | ScrapeTargetPersistenceError - | ScrapeTargetEncryptionError, - ): IntegrationsValidationError | IntegrationsPersistenceError => { - switch (error._tag) { - case "@maple/http/errors/ScrapeTargetValidationError": - return new IntegrationsValidationError({ message: error.message }) - case "@maple/http/errors/ScrapeTargetNotFoundError": - case "@maple/http/errors/ScrapeTargetPersistenceError": - case "@maple/http/errors/ScrapeTargetEncryptionError": - return new IntegrationsPersistenceError({ message: error.message }) - } - } - const finalizeOrgSelection = Effect.fn("PlanetScaleConnectionService.finalizeOrgSelection")( function* ( orgId: OrgId, @@ -485,37 +488,33 @@ export class PlanetScaleConnectionService extends Context.Service< // enabled only if the bearer probe passed. const keepsToken = adoptable.authType === "token" && adoptable.authCredentialsCiphertext !== null - yield* scrapeTargetsService - .update(orgId, adoptable.id, { - ...(keepsToken ? {} : { authType: "planetscale_oauth" }), - ...(request.includeBranches !== undefined - ? { includeBranches: request.includeBranches } - : {}), - ...(request.excludeBranches !== undefined - ? { excludeBranches: request.excludeBranches } - : {}), - enabled: keepsToken || permissions.readMetricsEndpoints, - }) - .pipe(Effect.mapError(mapScrapeTargetError)) + yield* scrapeTargetsService.update(orgId, adoptable.id, { + ...(keepsToken ? {} : { authType: "planetscale_oauth" }), + ...(request.includeBranches !== undefined + ? { includeBranches: request.includeBranches } + : {}), + ...(request.excludeBranches !== undefined + ? { excludeBranches: request.excludeBranches } + : {}), + enabled: keepsToken || permissions.readMetricsEndpoints, + }) scrapeTargetId = adoptable.id } else { - const created = yield* scrapeTargetsService - .create(orgId, { - name: `PlanetScale (${organization})`, - targetType: "planetscale", - organization, - authType: "planetscale_oauth", - ...(request.includeBranches !== undefined - ? { includeBranches: request.includeBranches } - : {}), - ...(request.excludeBranches !== undefined - ? { excludeBranches: request.excludeBranches } - : {}), - // Paused until a service token arrives when the bearer probe - // failed — an enabled target would just 401 every scrape. - enabled: permissions.readMetricsEndpoints, - }) - .pipe(Effect.mapError(mapScrapeTargetError)) + const created = yield* scrapeTargetsService.create(orgId, { + name: `PlanetScale (${organization})`, + targetType: "planetscale", + organization, + authType: "planetscale_oauth", + ...(request.includeBranches !== undefined + ? { includeBranches: request.includeBranches } + : {}), + ...(request.excludeBranches !== undefined + ? { excludeBranches: request.excludeBranches } + : {}), + // Paused until a service token arrives when the bearer probe + // failed — an enabled target would just 401 every scrape. + enabled: permissions.readMetricsEndpoints, + }) scrapeTargetId = created.id createdTarget = true } @@ -679,20 +678,25 @@ export class PlanetScaleConnectionService extends Context.Service< const target = yield* selectManagedTarget(connection) if (target === null) { + if (connection.scrapeTargetId === null) { + return yield* Effect.fail( + new IntegrationsPersistenceError({ + message: "The PlanetScale connection has no managed scrape target", + }), + ) + } return yield* Effect.fail( - new IntegrationsPersistenceError({ - message: - "The managed scrape target is missing — disconnect and reconnect PlanetScale.", + new ScrapeTargetNotFoundError({ + targetId: connection.scrapeTargetId, + message: "The managed PlanetScale scrape target no longer exists", }), ) } - yield* scrapeTargetsService - .update(orgId, target.id, { - authType: "token", - authCredentials: JSON.stringify({ tokenId, tokenSecret: request.tokenSecret }), - enabled: true, - }) - .pipe(Effect.mapError(mapScrapeTargetError)) + yield* scrapeTargetsService.update(orgId, target.id, { + authType: "token", + authCredentials: JSON.stringify({ tokenId, tokenSecret: request.tokenSecret }), + enabled: true, + }) return yield* getStatus(orgId) }) @@ -706,12 +710,16 @@ export class PlanetScaleConnectionService extends Context.Service< // owns it (a user-created row adopted by a *different* connection stays). const target = yield* selectManagedTarget(connection) if (target !== null && target.managedBy === managedByForConnection(connection.id)) { - yield* scrapeTargetsService.delete(orgId, target.id).pipe( - Effect.catchTag("@maple/http/errors/ScrapeTargetNotFoundError", () => - Effect.annotateCurrentSpan("maple.planetscale.disconnect_target_missing", true), - ), - Effect.mapError(toPersistenceError), - ) + yield* scrapeTargetsService + .delete(orgId, target.id) + .pipe( + Effect.catchTag("@maple/http/errors/ScrapeTargetNotFoundError", () => + Effect.annotateCurrentSpan( + "maple.planetscale.disconnect_target_missing", + true, + ), + ), + ) } yield* database diff --git a/apps/api/src/services/integrations/ScrapeTargetsService.ts b/apps/api/src/services/integrations/ScrapeTargetsService.ts index ae6e7b1b1..754bebc88 100644 --- a/apps/api/src/services/integrations/ScrapeTargetsService.ts +++ b/apps/api/src/services/integrations/ScrapeTargetsService.ts @@ -12,6 +12,7 @@ import { ScrapeTargetPersistenceError, ScrapeTargetProbeResponse, ScrapeTargetResponse, + ScrapeTargetStoredConfigInvalidError, ScrapeTargetsListResponse, ScrapeTargetType, ScrapeTargetUpstreamError, @@ -33,7 +34,7 @@ import { TokenCredentialsSchema, } from "@/services/auth/scrape-auth" import { safeFetch, validateExternalUrl } from "@/http/url-validator" -import { decodeDiscoveryConfig } from "./planetscale/discovery-config" +import { DiscoveryConfigSchema } from "./planetscale/discovery-config" import { PlanetScaleDiscoveryService, planetScaleDiscoveryUrl } from "./PlanetScaleDiscoveryService" import { PlanetScaleOAuthService, @@ -74,11 +75,19 @@ const parseRetryAfterSeconds = (value: string | null): number | null => { } export interface ScrapeTargetsServiceShape { - readonly list: (orgId: OrgId) => Effect.Effect + readonly list: ( + orgId: OrgId, + ) => Effect.Effect< + ScrapeTargetsListResponse, + ScrapeTargetPersistenceError | ScrapeTargetStoredConfigInvalidError + > readonly get: ( orgId: OrgId, targetId: ScrapeTargetId, - ) => Effect.Effect + ) => Effect.Effect< + ScrapeTargetResponse, + ScrapeTargetNotFoundError | ScrapeTargetPersistenceError | ScrapeTargetStoredConfigInvalidError + > readonly create: ( orgId: OrgId, request: CreateScrapeTargetRequest, @@ -96,6 +105,7 @@ export interface ScrapeTargetsServiceShape { | ScrapeTargetValidationError | ScrapeTargetPersistenceError | ScrapeTargetEncryptionError + | ScrapeTargetStoredConfigInvalidError > readonly delete: ( orgId: OrgId, @@ -196,8 +206,6 @@ const toEncryptionError = (message: string) => new ScrapeTargetEncryptionError({ const decodeTargetIdSync = Schema.decodeUnknownSync(ScrapeTargetId) const decodeIsoDateTimeStringSync = Schema.decodeUnknownSync(IsoDateTimeString) const decodeScrapeIntervalSecondsSync = Schema.decodeUnknownSync(ScrapeIntervalSeconds) -const decodeScrapeAuthTypeSync = Schema.decodeUnknownSync(ScrapeAuthType) -const decodeScrapeTargetTypeSync = Schema.decodeUnknownSync(ScrapeTargetType) const ScrapeLabelsSchema = Schema.Record(Schema.String, Schema.String) /** Cap pattern lists so a target config stays small and bounded. */ @@ -283,29 +291,96 @@ const validateAuthCredentials = (authType: string, authCredentials: string | nul ) } -const rowToResponse = (row: ScrapeTargetRow): ScrapeTargetResponse => { - const discoveryConfig = decodeDiscoveryConfig(row.discoveryConfigJson) +const storedConfigInvalid = ( + row: ScrapeTargetRow, + component: ScrapeTargetStoredConfigInvalidError["component"], + cause: unknown, +) => + new ScrapeTargetStoredConfigInvalidError({ + rawTargetId: row.id, + component, + message: `Stored scrape target ${component} is invalid`, + cause, + }) + +const decodeStored = ( + row: ScrapeTargetRow, + component: ScrapeTargetStoredConfigInvalidError["component"], + decode: (value: unknown) => Effect.Effect, + value: unknown, +): Effect.Effect => + decode(value).pipe(Effect.mapError((cause) => storedConfigInvalid(row, component, cause))) + +const rowToResponse = Effect.fn("ScrapeTargetsService.rowToResponse")(function* (row: ScrapeTargetRow) { + const id = yield* decodeStored(row, "id", Schema.decodeUnknownEffect(ScrapeTargetId), row.id) + const targetType = yield* decodeStored( + row, + "target_type", + Schema.decodeUnknownEffect(ScrapeTargetType), + row.targetType, + ) + const discoveryConfig = + targetType === "planetscale" + ? yield* decodeStored( + row, + "discovery_config", + Schema.decodeUnknownEffect(DiscoveryConfigSchema), + row.discoveryConfigJson, + ) + : null + const scrapeIntervalSeconds = yield* decodeStored( + row, + "scrape_interval", + Schema.decodeUnknownEffect(ScrapeIntervalSeconds), + row.scrapeIntervalSeconds, + ) + const authType = yield* decodeStored( + row, + "auth_type", + Schema.decodeUnknownEffect(ScrapeAuthType), + row.authType, + ) + const createdAt = yield* decodeStored( + row, + "created_at", + Schema.decodeUnknownEffect(IsoDateTimeString), + row.createdAt.toISOString(), + ) + const updatedAt = yield* decodeStored( + row, + "updated_at", + Schema.decodeUnknownEffect(IsoDateTimeString), + row.updatedAt.toISOString(), + ) + const lastScrapeAt = row.lastScrapeAt + ? yield* decodeStored( + row, + "last_scrape_at", + Schema.decodeUnknownEffect(IsoDateTimeString), + row.lastScrapeAt.toISOString(), + ) + : null return new ScrapeTargetResponse({ - id: decodeTargetIdSync(row.id), + id, name: row.name, serviceName: row.serviceName ?? null, url: row.url, - targetType: decodeScrapeTargetTypeSync(row.targetType), + targetType, organization: discoveryConfig?.organization ?? null, includeBranches: discoveryConfig?.includeBranches ?? [], excludeBranches: discoveryConfig?.excludeBranches ?? [], - scrapeIntervalSeconds: decodeScrapeIntervalSecondsSync(row.scrapeIntervalSeconds), + scrapeIntervalSeconds, labelsJson: row.labelsJson == null ? null : JSON.stringify(row.labelsJson), - authType: decodeScrapeAuthTypeSync(row.authType), + authType, hasCredentials: row.authCredentialsCiphertext !== null, managedBy: row.managedBy ?? null, enabled: row.enabled, - lastScrapeAt: row.lastScrapeAt ? decodeIsoDateTimeStringSync(row.lastScrapeAt.toISOString()) : null, + lastScrapeAt, lastScrapeError: row.lastScrapeError, - createdAt: decodeIsoDateTimeStringSync(row.createdAt.toISOString()), - updatedAt: decodeIsoDateTimeStringSync(row.updatedAt.toISOString()), + createdAt, + updatedAt, }) -} +}) const MIN_SCRAPE_INTERVAL = 5 const MAX_SCRAPE_INTERVAL = 300 @@ -512,7 +587,7 @@ export class ScrapeTargetsService extends Context.Service - db.insert(scrapeTargets).values({ - id, - orgId, - name, - serviceName, - url, - targetType, - discoveryConfigJson, - scrapeIntervalSeconds: - request.scrapeIntervalSeconds ?? (targetType === "planetscale" ? 30 : 15), - labelsJson: labels ?? null, - authType, - ...credentialFields, - enabled: request.enabled !== false, - createdAt: new Date(now), - updatedAt: new Date(now), - }), + db + .insert(scrapeTargets) + .values({ + id, + orgId, + name, + serviceName, + url, + targetType, + discoveryConfigJson, + scrapeIntervalSeconds: + request.scrapeIntervalSeconds ?? (targetType === "planetscale" ? 30 : 15), + labelsJson: labels ?? null, + authType, + ...credentialFields, + enabled: request.enabled !== false, + createdAt: new Date(now), + updatedAt: new Date(now), + }) + .returning({ id: scrapeTargets.id }), ) .pipe(Effect.mapError(toPersistenceError)) - - const row = yield* selectById(orgId, id) - if (Option.isNone(row)) { + if (inserted.length !== 1) { return yield* Effect.fail( new ScrapeTargetPersistenceError({ message: "Failed to create scrape target", }), ) } + const createdAt = decodeIsoDateTimeStringSync(new Date(now).toISOString()) + const scrapeIntervalSeconds = + request.scrapeIntervalSeconds ?? + decodeScrapeIntervalSecondsSync(targetType === "planetscale" ? 30 : 15) + const created = new ScrapeTargetResponse({ + id, + name, + serviceName, + url, + targetType, + organization: discoveryConfigJson?.organization ?? null, + includeBranches: discoveryConfigJson?.includeBranches ?? [], + excludeBranches: discoveryConfigJson?.excludeBranches ?? [], + scrapeIntervalSeconds, + labelsJson: labels == null ? null : JSON.stringify(labels), + authType, + hasCredentials: credentialFields.authCredentialsCiphertext !== null, + managedBy: null, + enabled: request.enabled !== false, + lastScrapeAt: null, + lastScrapeError: null, + createdAt, + updatedAt: createdAt, + }) // Fire the first scrape in the background so target creation returns // promptly, but never swallow its failure silently: a probe that fails @@ -687,7 +787,7 @@ export class ScrapeTargetsService extends Context.Service { return Effect.gen(function* () { const svc = yield* TinybirdOrgTokenService const error = yield* Effect.flip(svc.getOrgReadToken(asOrgId("org_a"))) - assert.strictEqual(error._tag, "@maple/api/services/TinybirdOrgTokenConfigError") + assert.strictEqual(error._tag, "@maple/http/errors/TinybirdOrgTokenConfigError") assert.strictEqual(error.setting, "SigningKey") assert.notInclude(error.message, "api-token-is-not-the-signing-key") }).pipe(Effect.provide(missingLayer)) diff --git a/apps/api/src/services/integrations/TinybirdOrgTokenService.ts b/apps/api/src/services/integrations/TinybirdOrgTokenService.ts index fc0ae28ed..fd22830c4 100644 --- a/apps/api/src/services/integrations/TinybirdOrgTokenService.ts +++ b/apps/api/src/services/integrations/TinybirdOrgTokenService.ts @@ -1,5 +1,5 @@ -import type { OrgId } from "@maple/domain" -import { Clock, Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { TinybirdOrgTokenConfigError, TinybirdOrgTokenMintError, type OrgId } from "@maple/domain" +import { Clock, Context, Effect, Layer, Option, Redacted } from "effect" import { listOrgScopedDatasourceNames } from "@/services/warehouse/warehouse-catalog" import { mintOrgReadJwt } from "@/services/auth/tinybird-jwt" import { Env } from "@/platform/Env" @@ -31,22 +31,6 @@ export interface TinybirdOrgTokenServiceShape { ) => Effect.Effect } -export class TinybirdOrgTokenConfigError extends Schema.TaggedError()( - "@maple/api/services/TinybirdOrgTokenConfigError", - { - setting: Schema.Literals(["SigningKey", "WorkspaceId"]), - message: Schema.String, - }, -) {} - -export class TinybirdOrgTokenMintError extends Schema.TaggedError()( - "@maple/api/services/TinybirdOrgTokenMintError", - { - message: Schema.String, - cause: Schema.Defect(), - }, -) {} - export class TinybirdOrgTokenService extends Context.Service< TinybirdOrgTokenService, TinybirdOrgTokenServiceShape diff --git a/apps/api/src/services/org/OrgClickHouseSettingsService.test.ts b/apps/api/src/services/org/OrgClickHouseSettingsService.test.ts index c5be6625a..0ed4df499 100644 --- a/apps/api/src/services/org/OrgClickHouseSettingsService.test.ts +++ b/apps/api/src/services/org/OrgClickHouseSettingsService.test.ts @@ -3,6 +3,7 @@ import { OrgClickHouseSettingsUpstreamRejectedError, OrgClickHouseSettingsUpstreamUnavailableError, OrgClickHouseSettingsEncryptionError, + OrgClickHouseSettingsStoredConfigInvalidError, OrgClickHouseSettingsValidationError, OrgId, RoleName, @@ -336,6 +337,21 @@ describe("resolveRuntimeConfig caching", () => { return (o as Option.Some).value } + it.effect("distinguishes invalid saved settings from invalid request input", () => { + const testDb = createTestDb(cacheTrackedDbs) + const orgId = "org_ch_invalid_saved_config" + return Effect.gen(function* () { + yield* Effect.promise(() => seedRow(testDb, orgId, "ftp://clickhouse.example.test")) + const exit = yield* OrgClickHouseSettingsService.resolveRuntimeConfig(asOrgId(orgId)).pipe( + Effect.exit, + ) + const error = getError(exit) + expect(error).toBeInstanceOf(OrgClickHouseSettingsStoredConfigInvalidError) + if (!(error instanceof OrgClickHouseSettingsStoredConfigInvalidError)) return + expect(error.cause).toBeInstanceOf(OrgClickHouseSettingsValidationError) + }).pipe(Effect.provide(buildLayer(testDb))) + }) + it.effect("serves the config from cache — a direct Postgres mutation stays invisible", () => { const testDb = createTestDb(cacheTrackedDbs) const orgId = "org_ch_cache" @@ -819,7 +835,10 @@ describe("resolveRuntimeConfig caching", () => { const exit = yield* OrgClickHouseSettingsService.resolveRuntimeConfig(asOrgId(orgId)).pipe( Effect.exit, ) - expect(getError(exit)).toBeInstanceOf(OrgClickHouseSettingsValidationError) + const error = getError(exit) + expect(error).toBeInstanceOf(OrgClickHouseSettingsStoredConfigInvalidError) + if (!(error instanceof OrgClickHouseSettingsStoredConfigInvalidError)) return + expect(error.cause).toBeInstanceOf(OrgClickHouseSettingsValidationError) }).pipe(Effect.provide(buildLayer(testDb))) }) diff --git a/apps/api/src/services/org/OrgClickHouseSettingsService.ts b/apps/api/src/services/org/OrgClickHouseSettingsService.ts index 126529b48..6deeb88c4 100644 --- a/apps/api/src/services/org/OrgClickHouseSettingsService.ts +++ b/apps/api/src/services/org/OrgClickHouseSettingsService.ts @@ -9,6 +9,7 @@ import { OrgClickHouseSettingsForbiddenError, OrgClickHouseSettingsPersistenceError, OrgClickHouseSettingsResponse, + OrgClickHouseSettingsStoredConfigInvalidError, OrgClickHouseSettingsUpstreamRejectedError, OrgClickHouseSettingsUpstreamUnavailableError, OrgClickHouseSettingsValidationError, @@ -324,7 +325,7 @@ export interface OrgClickHouseSettingsServiceShape { Option.Option, | OrgClickHouseSettingsPersistenceError | OrgClickHouseSettingsEncryptionError - | OrgClickHouseSettingsValidationError + | OrgClickHouseSettingsStoredConfigInvalidError > /** * Warm the runtime-config memo for many orgs in ONE Postgres round-trip. @@ -1681,7 +1682,15 @@ export class OrgClickHouseSettingsService extends Context.Service< cached.schemaVersion !== clickHouseSchemaVersion, ) const password = yield* decryptStoredPassword(cached) - yield* validateClickHouseCredentialTransport(cached.chUrl, password) + yield* validateClickHouseCredentialTransport(cached.chUrl, password).pipe( + Effect.mapError( + (cause) => + new OrgClickHouseSettingsStoredConfigInvalidError({ + message: cause.message, + cause, + }), + ), + ) return Option.some({ backend: "clickhouse", url: cached.chUrl, diff --git a/apps/api/src/services/org/OrgMembersService.ts b/apps/api/src/services/org/OrgMembersService.ts index c0cf52e51..3fe56b88b 100644 --- a/apps/api/src/services/org/OrgMembersService.ts +++ b/apps/api/src/services/org/OrgMembersService.ts @@ -1,19 +1,15 @@ import { createClerkClient } from "@clerk/backend" -import type { OrgId } from "@maple/domain/http" -import { Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { + AlertMemberDirectoryNotConfiguredError, + AlertMemberDirectoryUnavailableError, + AlertRecipientSelectionError, + type OrgId, + type UserId, +} from "@maple/domain/http" +import { Context, Effect, Layer, Option, Redacted } from "effect" import { Env } from "@/platform/Env" import { clerkRequest } from "@/services/auth/clerk-request" -export class OrgMembersError extends Schema.TaggedError()( - "@maple/api/services/OrgMembersError", - { - message: Schema.String, - cause: Schema.optionalKey(Schema.Defect()), - /** User ids the caller supplied that are not members of the org. */ - unknownUserIds: Schema.optionalKey(Schema.Array(Schema.String)), - }, -) {} - export interface OrgMember { readonly userId: string readonly email: string @@ -28,8 +24,13 @@ export interface OrgMembersServiceShape { */ readonly resolveMembers: ( orgId: OrgId, - userIds: ReadonlyArray, - ) => Effect.Effect, OrgMembersError> + userIds: ReadonlyArray, + ) => Effect.Effect< + ReadonlyArray, + | AlertMemberDirectoryNotConfiguredError + | AlertMemberDirectoryUnavailableError + | AlertRecipientSelectionError + > } const make = Effect.gen(function* () { @@ -44,7 +45,7 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan("orgId", orgId) if (clerk === null) { return yield* Effect.fail( - new OrgMembersError({ + new AlertMemberDirectoryNotConfiguredError({ message: "Workspace member lookup requires Clerk authentication", }), ) @@ -65,7 +66,7 @@ const make = Effect.gen(function* () { ).pipe( Effect.mapError( (cause) => - new OrgMembersError({ + new AlertMemberDirectoryUnavailableError({ message: `Failed to list workspace members for ${orgId}`, cause, }), @@ -89,7 +90,7 @@ const make = Effect.gen(function* () { const resolveMembers: OrgMembersServiceShape["resolveMembers"] = Effect.fn( "OrgMembersService.resolveMembers", - )(function* (orgId: OrgId, userIds: ReadonlyArray) { + )(function* (orgId: OrgId, userIds: ReadonlyArray) { yield* Effect.annotateCurrentSpan({ orgId, "maple.organization.member.requested_count": userIds.length, @@ -97,11 +98,10 @@ const make = Effect.gen(function* () { const members = yield* listMembers(orgId) const byUserId = new Map(members.map((member) => [member.userId, member])) const resolved: Array = [] - const unknown: Array = [] + const unknown: Array = [] const seen = new Set() - for (const raw of userIds) { - const userId = raw.trim() - if (userId.length === 0 || seen.has(userId)) continue + for (const userId of userIds) { + if (seen.has(userId)) continue seen.add(userId) const member = byUserId.get(userId) if (member === undefined) unknown.push(userId) @@ -109,7 +109,7 @@ const make = Effect.gen(function* () { } if (unknown.length > 0) { return yield* Effect.fail( - new OrgMembersError({ + new AlertRecipientSelectionError({ message: "Some selected users are not members of this workspace", unknownUserIds: unknown, }), diff --git a/apps/api/src/services/warehouse/QueryEngineService.ts b/apps/api/src/services/warehouse/QueryEngineService.ts index 5992e2aa4..96d25c779 100644 --- a/apps/api/src/services/warehouse/QueryEngineService.ts +++ b/apps/api/src/services/warehouse/QueryEngineService.ts @@ -1,5 +1,6 @@ import { Clock, Context, Effect, Layer, Metric } from "effect" import { QueryEngineExecuteResponse, type QueryEngineExecuteRequest } from "@maple/query-engine" +import type { QueryEngineTimeoutError } from "@maple/domain/http" import { buildCacheKey, buildDirectRouteCacheKey, @@ -17,6 +18,7 @@ import { type GroupedAlertObservation, type DirectRouteCachePolicyInput, type QueryEngineDirectError, + type QueryEngineEvaluationError, type AlertEvaluateRequest, type QueryEngineRouteError, type TimeRangeBounds, @@ -58,7 +60,7 @@ export interface QueryEngineServiceShape { readonly evaluate: ( tenant: TenantContext, request: AlertEvaluateRequest, - ) => Effect.Effect, QueryEngineRouteError> + ) => Effect.Effect, QueryEngineEvaluationError> /** * Evaluate an alert query and return the per-(bucket, group) observations * instead of a reduced scalar per group. One bucket == one evaluation window, @@ -68,20 +70,20 @@ export interface QueryEngineServiceShape { readonly evaluateSeries: ( tenant: TenantContext, request: AlertEvaluateRequest, - ) => Effect.Effect, QueryEngineRouteError> + ) => Effect.Effect, QueryEngineEvaluationError> /** * Edge-cache a direct-route query keyed by `(orgId, routeName, payload)`. * A numeric policy preserves the legacy TTL-aligned snap behavior. Routes can * instead pass a versioned policy to tune TTL and time-key snapping * independently without changing the storage service. */ - readonly cachedDirect: ( + readonly cachedDirect: ( tenant: TenantContext, routeName: string, payload: unknown, - effect: Effect.Effect, + effect: Effect.Effect, policy?: DirectRouteCachePolicyInput, - ) => Effect.Effect + ) => Effect.Effect } export class QueryEngineService extends Context.Service()( "@maple/api/services/QueryEngineService", @@ -361,11 +363,14 @@ export class QueryEngineService extends Context.Service( + const cachedDirect = Effect.fn("QueryEngineService.cachedDirect")(function* < + A, + E extends QueryEngineDirectError, + >( tenant: TenantContext, routeName: string, payload: unknown, - effect: Effect.Effect, + effect: Effect.Effect, policyInput: DirectRouteCachePolicyInput = 15, ) { // Attributes go on the `Effect.fn` span, not an inner `withSpan` of the diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts index 37e6430a0..6a35a94e4 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts @@ -1,20 +1,18 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Schema } from "effect" import { - WarehouseQueryError, - WarehouseConfigError, - WarehouseConfigDecryptionError, - WarehouseConfigLookupError, - WarehouseStoredConfigInvalidError, - WarehouseTokenConfigError, MAX_RAW_SQL_RESULT_BYTES, - WarehouseResultDecodeError, - WarehouseUpstreamError, OrgClickHouseSettingsEncryptionError, OrgClickHouseSettingsPersistenceError, - OrgClickHouseSettingsValidationError, + OrgClickHouseSettingsStoredConfigInvalidError, OrgId, + TinybirdOrgTokenConfigError, UserId, + WarehouseConfigError, + WarehouseQueryError, + WarehouseResultDecodeError, + WarehouseScopeError, + WarehouseUpstreamError, } from "@maple/domain/http" import { unsafeCompiledQuery } from "@maple/query-engine/ch" import { EdgeCacheService, MemoryCacheBackendLive } from "@maple/cache" @@ -253,25 +251,25 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { }).pipe(Effect.provide(layer)) }) - it.effect("preserves runtime-config dependency and configuration semantics", () => { + it.effect("preserves exact runtime-config dependency failures", () => { const cases = [ { source: new OrgClickHouseSettingsPersistenceError({ message: "database unavailable" }), - expected: WarehouseConfigLookupError, }, { source: new OrgClickHouseSettingsEncryptionError({ message: "decrypt failed" }), - expected: WarehouseConfigDecryptionError, }, { - source: new OrgClickHouseSettingsValidationError({ message: "invalid stored URL" }), - expected: WarehouseStoredConfigInvalidError, + source: new OrgClickHouseSettingsStoredConfigInvalidError({ + message: "invalid stored URL", + cause: new Error("invalid stored URL"), + }), }, ] as const return Effect.forEach( cases, - ({ source, expected }) => { + ({ source }) => { const configLive = makeConfig({}, false) const envLive = Env.layer.pipe(Layer.provide(configLive)) const tokenLive = TinybirdOrgTokenService.layer.pipe(Layer.provide(envLive)) @@ -287,17 +285,7 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { const exit = yield* WarehouseQueryService.use((service) => service.rawSqlQuery(makeTenant(), "SELECT 1 WHERE OrgId = 'org_test'"), ).pipe(Effect.exit) - const mapped = getError(exit) - assert.instanceOf(mapped, expected) - assert.strictEqual( - ( - mapped as - | WarehouseConfigDecryptionError - | WarehouseConfigLookupError - | WarehouseStoredConfigInvalidError - ).cause, - source, - ) + assert.strictEqual(getError(exit), source) }).pipe(Effect.provide(layer)) }, { discard: true }, @@ -316,9 +304,9 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { service.rawSqlQuery(makeTenant(), "SELECT 1 WHERE OrgId = 'org_test'"), ).pipe(Effect.exit) const failure = getError(exit) - assert.instanceOf(failure, WarehouseTokenConfigError) - assert.include((failure as WarehouseTokenConfigError).message, "TINYBIRD_SIGNING_KEY") - assert.notInclude((failure as WarehouseTokenConfigError).message, "managed-token") + assert.instanceOf(failure, TinybirdOrgTokenConfigError) + assert.include((failure as TinybirdOrgTokenConfigError).message, "TINYBIRD_SIGNING_KEY") + assert.notInclude((failure as TinybirdOrgTokenConfigError).message, "managed-token") }).pipe(Effect.provide(layer)) }) @@ -530,6 +518,7 @@ describe("WarehouseQueryService.compiledQuery", () => { assert.isTrue(Exit.isFailure(exit)) const failure = getError(exit) + assert.instanceOf(failure, WarehouseScopeError) assert.strictEqual( (failure as { message?: string } | undefined)?.message, "compiled query is not tenant-scoped: no top-level OrgId predicate (compiledQuery). " + diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.ts b/apps/api/src/services/warehouse/WarehouseQueryService.ts index f853d374a..de5dc2f07 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.ts @@ -1,26 +1,23 @@ import { createClient as createClickHouseClient } from "@clickhouse/client-web" import { Tinybird } from "@tinybirdco/sdk" import { Context, Effect, Layer, Option, Redacted } from "effect" -import { - WarehouseConfigError, - WarehouseConfigDecryptionError, - WarehouseConfigLookupError, - WarehouseStoredConfigInvalidError, - WarehouseTokenConfigError, - WarehouseTokenMintError, - type WarehouseQueryRequest, -} from "@maple/domain/http" +import { WarehouseConfigError, type WarehouseQueryRequest } from "@maple/domain/http" import { BackendDialect, makeWarehouseExecutor, WarehouseResponseLimitError, type ClickHouseProtocolBackendConfig, + type ExecutionTenant, type ResolvedWarehouseConfig, + type RoutePurpose, type SqlQueryOptions, type TinybirdBackendConfig, type WarehouseExecutorDeps, type WarehouseQueryServiceShape, + type WarehouseRawRouteError, + type WarehouseRoute, type WarehouseSqlClient, + type WarehouseTrustedRouteError, } from "@maple/query-engine/execution" import type { CompiledQuery } from "@maple/query-engine/ch" import { WarehouseExecutor } from "@maple/query-engine/observability" @@ -330,126 +327,113 @@ export class WarehouseQueryService extends Context.Service< * gateway); a shared vanilla ClickHouse credential has no DB-enforced OrgId * scope, so raw SQL there is allowed only in single-org self-hosted mode. */ - const resolveRoute: WarehouseExecutorDeps["resolveRoute"] = Effect.fn( - "WarehouseQueryService.resolveRoute", - )(function* (tenant, purpose, label) { - yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) - yield* Effect.annotateCurrentSpan("warehouse.route", purpose) + const resolveRouteEffect = Effect.fn("WarehouseQueryService.resolveRoute")( + function* (tenant, purpose, label) { + yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) + yield* Effect.annotateCurrentSpan("warehouse.route", purpose) - if (purpose === "ingest") { - // Legacy attrs, dual-emitted until dashboards move to `warehouse.*`. - yield* Effect.annotateCurrentSpan("clientSource", "managed") - yield* Effect.annotateCurrentSpan("query.routing", "ingest") - yield* Effect.annotateCurrentSpan("db.client", "tinybird-sdk") - return { - source: "managed" as const, - config: { - kind: "tinybird" as const, - host: env.TINYBIRD_HOST, - token: Redacted.value(env.TINYBIRD_TOKEN), - }, - clientCacheKey: "write:managed", + if (purpose === "ingest") { + // Legacy attrs, dual-emitted until dashboards move to `warehouse.*`. + yield* Effect.annotateCurrentSpan("clientSource", "managed") + yield* Effect.annotateCurrentSpan("query.routing", "ingest") + yield* Effect.annotateCurrentSpan("db.client", "tinybird-sdk") + return { + source: "managed" as const, + config: { + kind: "tinybird" as const, + host: env.TINYBIRD_HOST, + token: Redacted.value(env.TINYBIRD_TOKEN), + }, + clientCacheKey: "write:managed", + } } - } - // A per-org BYO ClickHouse row (`org_clickhouse_settings`) overrides the - // managed upstream for that org's reads AND raw SQL (the credentials are - // already tenant-isolated). - const override = yield* orgClickHouseSettings.resolveRuntimeConfig(tenant.orgId).pipe( - Effect.catchTags({ - "@maple/http/errors/OrgClickHouseSettingsPersistenceError": (error) => - Effect.fail( - new WarehouseConfigLookupError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), - "@maple/http/errors/OrgClickHouseSettingsEncryptionError": (error) => - Effect.fail( - new WarehouseConfigDecryptionError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), - "@maple/http/errors/OrgClickHouseSettingsValidationError": (error) => - Effect.fail( - new WarehouseStoredConfigInvalidError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), - }), - ) - if (Option.isSome(override)) { - yield* Effect.annotateCurrentSpan("clientSource", "org_override") - yield* Effect.annotateCurrentSpan("db.client", "clickhouse") - return { - source: "org-byo" as const, - config: { - kind: "clickhouse" as const, - url: override.value.url, - username: override.value.user, - password: override.value.password, - database: override.value.database, - }, - clientCacheKey: purpose === "raw" ? `raw:${tenant.orgId}` : `read:${tenant.orgId}`, + // A per-org BYO ClickHouse row (`org_clickhouse_settings`) overrides the + // managed upstream for that org's reads AND raw SQL (the credentials are + // already tenant-isolated). + const override = yield* orgClickHouseSettings.resolveRuntimeConfig(tenant.orgId) + if (Option.isSome(override)) { + yield* Effect.annotateCurrentSpan("clientSource", "org_override") + yield* Effect.annotateCurrentSpan("db.client", "clickhouse") + return { + source: "org-byo" as const, + config: { + kind: "clickhouse" as const, + url: override.value.url, + username: override.value.user, + password: override.value.password, + database: override.value.database, + }, + clientCacheKey: purpose === "raw" ? `raw:${tenant.orgId}` : `read:${tenant.orgId}`, + } } - } - yield* Effect.annotateCurrentSpan("clientSource", "managed") - const managed = yield* resolveManagedConfig() - if (purpose === "read") return { source: "managed" as const, ...managed } + yield* Effect.annotateCurrentSpan("clientSource", "managed") + const managed = yield* resolveManagedConfig() + if (purpose === "read") return { source: "managed" as const, ...managed } - // Raw SQL on the shared warehouse needs tenant isolation. Shared Tinybird - // is isolated with a datasource-scoped JWT; the same token works through - // both the SDK and Tinybird's ClickHouse-compatible gateway. - const clientCacheKey = `raw:${tenant.orgId}` - if (managed.config.kind === "tinybird" || managed.config.kind === "tinybird-gateway") { - const jwt = yield* orgTokens.getOrgReadToken(tenant.orgId).pipe( - Effect.catchTags({ - "@maple/api/services/TinybirdOrgTokenConfigError": (error) => - Effect.fail( - new WarehouseTokenConfigError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), - "@maple/api/services/TinybirdOrgTokenMintError": (error) => - Effect.fail( - new WarehouseTokenMintError({ - pipeName: label, - message: error.message, - cause: error, - }), - ), - }), - ) - yield* Effect.annotateCurrentSpan("maple.tinybird.token.scope", "org_jwt") - return { - source: "org-jwt" as const, - config: - managed.config.kind === "tinybird" - ? { ...managed.config, token: jwt } - : { ...managed.config, password: jwt }, - clientCacheKey, + // Raw SQL on the shared warehouse needs tenant isolation. Shared Tinybird + // is isolated with a datasource-scoped JWT; the same token works through + // both the SDK and Tinybird's ClickHouse-compatible gateway. + const clientCacheKey = `raw:${tenant.orgId}` + if (managed.config.kind === "tinybird" || managed.config.kind === "tinybird-gateway") { + const jwt = yield* orgTokens.getOrgReadToken(tenant.orgId) + yield* Effect.annotateCurrentSpan("maple.tinybird.token.scope", "org_jwt") + return { + source: "org-jwt" as const, + config: + managed.config.kind === "tinybird" + ? { ...managed.config, token: jwt } + : { ...managed.config, password: jwt }, + clientCacheKey, + } } - } - // A shared vanilla ClickHouse credential has no database-enforced OrgId - // scope. It is safe only in Maple's single-org self-hosted deployment mode. - if (env.MAPLE_AUTH_MODE.toLowerCase() !== "self_hosted") { - return yield* new WarehouseConfigError({ - pipeName: label, - message: - "Raw SQL on managed vanilla ClickHouse is available only in single-org self-hosted mode", - }) - } - return { source: "managed" as const, config: managed.config, clientCacheKey } - }) + // A shared vanilla ClickHouse credential has no database-enforced OrgId + // scope. It is safe only in Maple's single-org self-hosted deployment mode. + if (env.MAPLE_AUTH_MODE.toLowerCase() !== "self_hosted") { + return yield* new WarehouseConfigError({ + pipeName: label, + message: + "Raw SQL on managed vanilla ClickHouse is available only in single-org self-hosted mode", + }) + } + return { source: "managed" as const, config: managed.config, clientCacheKey } + }, + ) + + function resolveRoute( + tenant: ExecutionTenant, + purpose: "read", + label: string, + ): Effect.Effect + function resolveRoute( + tenant: ExecutionTenant, + purpose: "ingest", + label: string, + ): Effect.Effect + function resolveRoute( + tenant: ExecutionTenant, + purpose: "read" | "ingest", + label: string, + ): Effect.Effect + function resolveRoute( + tenant: ExecutionTenant, + purpose: "raw", + label: string, + ): Effect.Effect + function resolveRoute( + tenant: ExecutionTenant, + purpose: RoutePurpose, + label: string, + ): Effect.Effect + function resolveRoute( + tenant: ExecutionTenant, + purpose: RoutePurpose, + label: string, + ): Effect.Effect { + return resolveRouteEffect(tenant, purpose, label) + } // Credential-rotation self-heal. `resolveRuntimeConfig` answers from a // stale-tolerant memo, so a rotated BYO ClickHouse password keeps resolving diff --git a/apps/api/src/services/warehouse/warehouse-error-handlers.ts b/apps/api/src/services/warehouse/warehouse-error-handlers.ts index 46f922c3e..56fc09d2e 100644 --- a/apps/api/src/services/warehouse/warehouse-error-handlers.ts +++ b/apps/api/src/services/warehouse/warehouse-error-handlers.ts @@ -1,9 +1,23 @@ import type { Effect } from "effect" -import { warehouseErrorTags, type WarehouseError } from "@maple/domain" +import { + warehouseErrorTags, + warehouseReadErrorTags, + type WarehouseError, + type WarehouseReadError, +} from "@maple/domain" + +const handlersFor = ( + tags: ReadonlyArray, + f: (error: Error) => Effect.Effect, +) => Object.fromEntries(tags.map((tag) => [tag, f])) as Record /** * Derive an exhaustive `Effect.catchTags` table from the domain's canonical * class tuple. Adding a warehouse error automatically updates every consumer. */ export const warehouseHandlers = (f: (error: WarehouseError) => Effect.Effect) => - Object.fromEntries(warehouseErrorTags.map((tag) => [tag, f])) as Record + handlersFor(warehouseErrorTags, f) + +/** Exhaustive handler table for compiled/read queries, excluding raw-SQL token failures. */ +export const warehouseReadHandlers = (f: (error: WarehouseReadError) => Effect.Effect) => + handlersFor(warehouseReadErrorTags, f) diff --git a/packages/alchemy-maple/src/MapleApi.ts b/packages/alchemy-maple/src/MapleApi.ts index 186ec3519..022f099f8 100644 --- a/packages/alchemy-maple/src/MapleApi.ts +++ b/packages/alchemy-maple/src/MapleApi.ts @@ -12,7 +12,6 @@ import { MapleApiResponseDecodeError, MapleApiResponseReadError, MapleApiTransportError, - MapleErrorTags, MaplePublicErrorBodySchema, isMapleApiResponseError, makeMapleApiResponseError, @@ -70,21 +69,18 @@ const errorTypeForStatus = (status: number): MaplePublicErrorType | undefined => } } -const notFoundTagForPath = (path: string): string | undefined => { - const pathname = path.split("?", 1)[0] ?? path - if (pathname.startsWith("/v2/api_keys/")) return MapleErrorTags.apiKeyNotFound - if (pathname.startsWith("/v2/dashboards/")) return MapleErrorTags.dashboardNotFound - if (pathname.startsWith("/v2/alerts/rules/") || pathname.startsWith("/v2/alerts/destinations/")) { - return pathname.startsWith("/v2/alerts/rules/") - ? MapleErrorTags.alertRuleNotFound - : MapleErrorTags.alertDestinationNotFound - } - return undefined +const hasCoherentRetryPolicy = (error: { + readonly retryable: boolean + readonly recovery: string + readonly retry_after_seconds?: number + readonly retry_at?: string +}): boolean => { + if (error.retryable !== (error.recovery === "retry")) return false + return error.retryable || (error.retry_after_seconds === undefined && error.retry_at === undefined) } const errorFromResponse = Effect.fn("MapleApi.errorFromResponse")(function* ( status: number, - path: string, bodyText: string, ) { const envelope = yield* decodeErrorEnvelope(bodyText).pipe( @@ -103,15 +99,10 @@ const errorFromResponse = Effect.fn("MapleApi.errorFromResponse")(function* ( message: `Maple API error type ${envelope.error.type} does not match status ${status}`, }) } - const expectedNotFoundTag = notFoundTagForPath(path) - if ( - envelope.error.type === "not_found_error" && - expectedNotFoundTag !== undefined && - envelope.error._tag !== expectedNotFoundTag - ) { + if (!hasCoherentRetryPolicy(envelope.error)) { return yield* new MapleApiProtocolError({ status, - message: `Maple API returned ${envelope.error._tag} for ${path}; expected ${expectedNotFoundTag}`, + message: `Maple API returned contradictory retry metadata with status ${status}`, }) } return makeMapleApiResponseError(status, envelope.error) @@ -193,7 +184,7 @@ export const make = Effect.gen(function* () { ), ) } - return yield* Effect.fail(yield* errorFromResponse(response.status, path, text)) + return yield* Effect.fail(yield* errorFromResponse(response.status, text)) }).pipe( Effect.catchIf(isMapleApiResponseError, (error) => canAutomaticallyRetry && error.error.retryable && attempt < 6 diff --git a/packages/alchemy-maple/src/errors.ts b/packages/alchemy-maple/src/errors.ts index f721c32d6..aeafb8c3c 100644 --- a/packages/alchemy-maple/src/errors.ts +++ b/packages/alchemy-maple/src/errors.ts @@ -1,4 +1,11 @@ import { Schema } from "effect" +import type { + AlertDestinationNotFoundError, + AlertRuleNotFoundError, + ApiKeyNotFoundError, + DashboardNotFoundError, + PublicHttpErrorTag, +} from "@maple/domain/http" export const MaplePublicErrorType = Schema.Literals([ "invalid_request_error", @@ -24,7 +31,7 @@ export const MapleErrorRecovery = Schema.Literals([ /** Public HTTP tags are disjoint from this package's client-side error tags. */ export const MapleHttpErrorTagSchema = Schema.TemplateLiteral(["@maple/http/", Schema.String]) -export type MapleHttpErrorTag = Schema.Schema.Type +export type MapleHttpErrorTag = PublicHttpErrorTag /** Stable tags used for provider lifecycle decisions. */ export const MapleErrorTags = { @@ -32,7 +39,12 @@ export const MapleErrorTags = { dashboardNotFound: "@maple/http/errors/DashboardNotFoundError", alertRuleNotFound: "@maple/http/errors/AlertRuleNotFoundError", alertDestinationNotFound: "@maple/http/errors/AlertDestinationNotFoundError", -} as const satisfies Record +} as const satisfies { + readonly apiKeyNotFound: ApiKeyNotFoundError["_tag"] + readonly dashboardNotFound: DashboardNotFoundError["_tag"] + readonly alertRuleNotFound: AlertRuleNotFoundError["_tag"] + readonly alertDestinationNotFound: AlertDestinationNotFoundError["_tag"] +} /** Published mirror of Maple's canonical v2 error body. Kept honest by contract tests. */ export const MaplePublicErrorBodySchema = Schema.Struct({ diff --git a/packages/alchemy-maple/test/maple-api.test.ts b/packages/alchemy-maple/test/maple-api.test.ts index 7d4f72d30..f1ad14a2e 100644 --- a/packages/alchemy-maple/test/maple-api.test.ts +++ b/packages/alchemy-maple/test/maple-api.test.ts @@ -205,14 +205,33 @@ describe("MapleApi errors", () => { ) }) - it.effect("rejects a not-found tag for the wrong endpoint", () => { - const wrong = errorEnvelope({ retryable: false }) - wrong.error._tag = "@maple/http/errors/DashboardNotFoundError" - const http = clientLayer(() => new Response(JSON.stringify(wrong), { status: 404 })) + it.effect("rejects contradictory retry metadata", () => { + const inconsistent = errorEnvelope({ + retryable: false, + retry_after_seconds: 5, + }) + const http = clientLayer(() => new Response(JSON.stringify(inconsistent), { status: 404 })) return Effect.gen(function* () { const api = yield* MapleApi const error = yield* Effect.flip(api.get("/v2/api_keys/key_missing")) expect(error._tag).toBe("@maple/alchemy/errors/ProtocolError") + expect(isMapleApiResponseError(error)).toBe(false) + }).pipe( + Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), + ) + }) + + it.effect("preserves endpoint-specific tags without client-side remapping", () => { + const nested = errorEnvelope({ retryable: false }) + nested.error._tag = "@maple/http/errors/DashboardVersionNotFoundError" + nested.error.code = "dashboard_version_not_found" + const http = clientLayer(() => new Response(JSON.stringify(nested), { status: 404 })) + return Effect.gen(function* () { + const api = yield* MapleApi + const error = yield* Effect.flip(api.get("/v2/dashboards/dash_123/versions/dbv_456")) + expect(isMapleApiResponseError(error)).toBe(true) + if (!isMapleApiResponseError(error)) return + expect(error._tag).toBe("@maple/http/errors/DashboardVersionNotFoundError") }).pipe( Effect.provide(MapleApiFromHttpClient().pipe(Layer.provide(environment), Layer.provide(http))), ) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 533f7a027..f6cd8e600 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -214,7 +214,7 @@ export const MAX_EMAIL_RECIPIENTS = 10 * each id to the member's email via the auth provider (Clerk) at save time, so * clients can never route alerts to arbitrary addresses. */ -const MemberUserIdList = Schema.Array(NonEmptyString).check( +const MemberUserIdList = Schema.Array(UserId).check( Schema.isMinLength(1), Schema.isMaxLength(MAX_EMAIL_RECIPIENTS), ) @@ -678,6 +678,109 @@ export class AlertValidationError extends HttpTaggedError( }, ) {} +/** Maple could not encrypt a destination secret before storing it. */ +export class AlertDestinationEncryptionError extends HttpTaggedError()( + "@maple/http/errors/AlertDestinationEncryptionError", + { + message: Schema.String, + destinationId: AlertDestinationId, + }, + { + status: 500, + code: "alert_destination_encryption_failed", + title: "Alert destination could not be secured", + message: "Maple could not securely store the alert destination.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** Maple could not decrypt the secret already stored for a destination. */ +export class AlertDestinationDecryptionError extends HttpTaggedError()( + "@maple/http/errors/AlertDestinationDecryptionError", + { + message: Schema.String, + destinationId: AlertDestinationId, + }, + { + status: 500, + code: "alert_destination_decryption_failed", + title: "Alert destination credentials could not be read", + message: "Maple could not read the stored alert destination credentials.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** A saved destination no longer decodes as the configuration shape Maple expects. */ +export class AlertDestinationStoredConfigInvalidError extends HttpTaggedError()( + "@maple/http/errors/AlertDestinationStoredConfigInvalidError", + { + message: Schema.String, + destinationId: AlertDestinationId, + component: Schema.Literals(["document", "public_config", "secret_config"]), + cause: Schema.Defect(), + }, + { + status: 500, + code: "alert_destination_stored_config_invalid", + title: "Stored alert destination is invalid", + message: "The stored alert destination configuration could not be read.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** One or more requested email recipients are not members of the workspace. */ +export class AlertRecipientSelectionError extends HttpTaggedError()( + "@maple/http/errors/AlertRecipientSelectionError", + { + message: Schema.String, + unknownUserIds: Schema.Array(UserId), + }, + { + status: 400, + code: "alert_recipient_invalid", + title: "Invalid alert recipient", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + +/** This deployment has no workspace-member directory for email destinations. */ +export class AlertMemberDirectoryNotConfiguredError extends HttpTaggedError()( + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError", + { message: Schema.String }, + { + status: 500, + code: "alert_member_directory_not_configured", + title: "Workspace member lookup is not configured", + message: "Workspace member lookup is not configured for this Maple deployment.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** The configured workspace-member directory could not be reached. */ +export class AlertMemberDirectoryUnavailableError extends HttpTaggedError()( + "@maple/http/errors/AlertMemberDirectoryUnavailableError", + { message: Schema.String, cause: Schema.Defect() }, + { + status: 503, + code: "alert_member_directory_unavailable", + title: "Workspace members are temporarily unavailable", + message: "Workspace members could not be loaded. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, +) {} + export class AlertPersistenceError extends HttpTaggedError()( "@maple/http/errors/AlertPersistenceError", { @@ -725,6 +828,53 @@ export class AlertDestinationNotFoundError extends HttpTaggedError()( + "@maple/http/errors/AlertRuleDestinationNotFoundError", + { message: Schema.String, destinationId: AlertDestinationId }, + { + status: 404, + code: "alert_rule_destination_not_found", + title: "Alert rule destination not found", + message: "An alert destination referenced by this rule does not exist.", + param: "destination_ids", + retry: "never", + recovery: "fix_request", + exposure: "redacted", + }, +) {} + +/** A saved alert rule no longer decodes as the configuration shape Maple expects. */ +export class AlertRuleStoredConfigInvalidError extends HttpTaggedError()( + "@maple/http/errors/AlertRuleStoredConfigInvalidError", + { + message: Schema.String, + ruleId: AlertRuleId, + component: Schema.Literals([ + "document", + "destination_ids", + "compiled_plan", + "service_names", + "exclude_service_names", + "environments", + "tags", + "group_by", + "notification_template", + "query_builder_draft", + ]), + cause: Schema.Defect(), + }, + { + status: 500, + code: "alert_rule_stored_config_invalid", + title: "Stored alert rule is invalid", + message: "The stored alert rule configuration could not be read.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + export class AlertIncidentNotFoundError extends HttpTaggedError()( "@maple/http/errors/AlertIncidentNotFoundError", { message: Schema.String, incidentId: AlertIncidentId }, diff --git a/packages/domain/src/http/dashboards.ts b/packages/domain/src/http/dashboards.ts index 572ca630a..b2e052c4a 100644 --- a/packages/domain/src/http/dashboards.ts +++ b/packages/domain/src/http/dashboards.ts @@ -194,6 +194,27 @@ export class DashboardPersistenceError extends HttpTaggedError()( + "@maple/http/errors/DashboardStoredConfigInvalidError", + { + message: Schema.String, + dashboardId: DashboardId, + component: Schema.Literals(["document", "version_snapshot"]), + versionId: Schema.optionalKey(DashboardVersionId), + cause: Schema.Defect(), + }, + { + status: 500, + code: "dashboard_stored_config_invalid", + title: "Stored dashboard is invalid", + message: "The stored dashboard configuration could not be read.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + export class DashboardNotFoundError extends HttpTaggedError()( "@maple/http/errors/DashboardNotFoundError", { diff --git a/packages/domain/src/http/error-policy.ts b/packages/domain/src/http/error-policy.ts index 3af4a008d..8b9af866d 100644 --- a/packages/domain/src/http/error-policy.ts +++ b/packages/domain/src/http/error-policy.ts @@ -27,6 +27,7 @@ export type PublicHttpErrorType = Schema.Schema.Type export type HttpErrorRetry = "never" | "backoff" | "after" export type PublicHttpErrorStatus = 400 | 401 | 403 | 404 | 409 | 413 | 429 | 500 | 502 | 503 | 504 +export type PublicHttpErrorTag = `@maple/http/${string}` export type PublicHttpErrorTypeForStatus = Status extends 400 | 413 ? "invalid_request_error" @@ -146,22 +147,37 @@ interface PublicHttpErrorPolicyBase readonly status: Status readonly code: ErrorValue readonly title: ErrorValue - readonly retry: HttpErrorRetry - readonly recovery: HttpErrorRecovery readonly param?: ErrorValue readonly retryAfterSeconds?: ErrorValue readonly retryAt?: ErrorValue } -/** Public HTTP presentation owned by the tagged error class itself. */ -export type PublicHttpErrorPolicy = - | (PublicHttpErrorPolicyBase & { +type PublicHttpRetryPolicy = + | { + readonly retry: "never" + readonly recovery: Exclude + } + | { + readonly retry: Exclude + readonly recovery: "retry" + } + +type PublicHttpMessagePolicy = + | { readonly exposure: "public_message" - }) - | (PublicHttpErrorPolicyBase & { + } + | { readonly exposure: "redacted" readonly message: ErrorValue - }) + } + +/** Public HTTP presentation owned by the tagged error class itself. */ +export type PublicHttpErrorPolicy = PublicHttpErrorPolicyBase< + Error, + Status +> & + PublicHttpRetryPolicy & + PublicHttpMessagePolicy /** Type-level and runtime link from an error to its class-owned HTTP definition. */ export const PublicHttpErrorPolicyTypeId: unique symbol = Symbol.for("@maple/http/PublicHttpErrorPolicy") @@ -213,7 +229,7 @@ export interface SelfDescribingHttpErrorClass() => < - const Tag extends string, + const Tag extends PublicHttpErrorTag, const Fields extends Schema.Struct.Fields, const Policy extends PublicHttpErrorPolicy< Schema.Struct.Type & PublicTaggedError, diff --git a/packages/domain/src/http/investigations.ts b/packages/domain/src/http/investigations.ts index 643024824..5f9992099 100644 --- a/packages/domain/src/http/investigations.ts +++ b/packages/domain/src/http/investigations.ts @@ -607,6 +607,7 @@ export class InvestigationStartFailedError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsForbiddenError", + { message: Schema.String }, + { + status: 403, + code: "clickhouse_settings_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, +) {} + +/** Caller-supplied ClickHouse settings do not pass validation. */ +export class OrgClickHouseSettingsValidationError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsValidationError", + { message: Schema.String }, + { + status: 400, + code: "clickhouse_settings_invalid", + title: "Invalid ClickHouse settings", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + +/** Maple could not load or persist the organization's ClickHouse settings. */ +export class OrgClickHouseSettingsPersistenceError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsPersistenceError", + { message: Schema.String }, + { + status: 503, + code: "clickhouse_settings_unavailable", + title: "ClickHouse settings are temporarily unavailable", + message: "ClickHouse settings are temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, +) {} + +/** Maple could not decrypt the organization's saved ClickHouse credentials. */ +export class OrgClickHouseSettingsEncryptionError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsEncryptionError", + { message: Schema.String }, + { + status: 500, + code: "clickhouse_settings_encryption_failed", + title: "Maple could not read these settings", + message: "Maple could not securely read the saved ClickHouse settings.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + +/** Saved settings no longer satisfy the runtime connection invariants. */ +export class OrgClickHouseSettingsStoredConfigInvalidError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsStoredConfigInvalidError", + { message: Schema.String, cause: Schema.Defect() }, + { + status: 502, + code: "clickhouse_stored_settings_invalid", + title: "Saved ClickHouse settings are invalid", + message: "The saved ClickHouse settings are invalid. Reconnect the database in settings.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, +) {} + +/** ClickHouse accepted the request but rejected the supplied connection settings. */ +export class OrgClickHouseSettingsUpstreamRejectedError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsUpstreamRejectedError", + { + message: Schema.String, + statusCode: Schema.NullOr(Schema.Number), + }, + { + status: 400, + code: "clickhouse_connection_rejected", + title: "ClickHouse rejected the connection", + retry: "never", + recovery: "reconnect", + exposure: "public_message", + }, +) {} + +/** The configured ClickHouse service could not be reached. */ +export class OrgClickHouseSettingsUpstreamUnavailableError extends HttpTaggedError()( + "@maple/http/errors/OrgClickHouseSettingsUpstreamUnavailableError", + { + message: Schema.String, + statusCode: Schema.NullOr(Schema.Number), + }, + { + status: 503, + code: "clickhouse_connection_unavailable", + title: "ClickHouse is temporarily unavailable", + message: "The configured ClickHouse service is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, +) {} diff --git a/packages/domain/src/http/org-clickhouse-settings.ts b/packages/domain/src/http/org-clickhouse-settings.ts index a16a4a156..b402dec26 100644 --- a/packages/domain/src/http/org-clickhouse-settings.ts +++ b/packages/domain/src/http/org-clickhouse-settings.ts @@ -1,8 +1,17 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { Authorization } from "./current-tenant" -import { HttpTaggedError } from "./error-policy" import { IsoDateTimeString } from "../primitives" +import { + OrgClickHouseSettingsEncryptionError, + OrgClickHouseSettingsForbiddenError, + OrgClickHouseSettingsPersistenceError, + OrgClickHouseSettingsUpstreamRejectedError, + OrgClickHouseSettingsUpstreamUnavailableError, + OrgClickHouseSettingsValidationError, +} from "./org-clickhouse-settings-errors" + +export * from "./org-clickhouse-settings-errors" /** * Connection-level status for a per-org BYO ClickHouse row. @@ -197,93 +206,6 @@ export class OrgClickHouseCollectorConfigResponse extends Schema.Class()( - "@maple/http/errors/OrgClickHouseSettingsForbiddenError", - { message: Schema.String }, - { - status: 403, - code: "clickhouse_settings_forbidden", - title: "Permission required", - retry: "never", - recovery: "request_access", - exposure: "public_message", - }, -) {} - -export class OrgClickHouseSettingsValidationError extends HttpTaggedError()( - "@maple/http/errors/OrgClickHouseSettingsValidationError", - { message: Schema.String }, - { - status: 400, - code: "clickhouse_settings_invalid", - title: "Invalid ClickHouse settings", - retry: "never", - recovery: "fix_request", - exposure: "public_message", - }, -) {} - -export class OrgClickHouseSettingsPersistenceError extends HttpTaggedError()( - "@maple/http/errors/OrgClickHouseSettingsPersistenceError", - { message: Schema.String }, - { - status: 503, - code: "clickhouse_settings_unavailable", - title: "ClickHouse settings are temporarily unavailable", - message: "ClickHouse settings are temporarily unavailable. Retry in a few seconds.", - retry: "backoff", - recovery: "retry", - exposure: "redacted", - }, -) {} - -export class OrgClickHouseSettingsEncryptionError extends HttpTaggedError()( - "@maple/http/errors/OrgClickHouseSettingsEncryptionError", - { message: Schema.String }, - { - status: 500, - code: "clickhouse_settings_encryption_failed", - title: "Maple could not read these settings", - message: "Maple could not securely read the saved ClickHouse settings.", - retry: "never", - recovery: "contact_support", - exposure: "redacted", - }, -) {} - -export class OrgClickHouseSettingsUpstreamRejectedError extends HttpTaggedError()( - "@maple/http/errors/OrgClickHouseSettingsUpstreamRejectedError", - { - message: Schema.String, - statusCode: Schema.NullOr(Schema.Number), - }, - { - status: 400, - code: "clickhouse_connection_rejected", - title: "ClickHouse rejected the connection", - retry: "never", - recovery: "reconnect", - exposure: "public_message", - }, -) {} - -export class OrgClickHouseSettingsUpstreamUnavailableError extends HttpTaggedError()( - "@maple/http/errors/OrgClickHouseSettingsUpstreamUnavailableError", - { - message: Schema.String, - statusCode: Schema.NullOr(Schema.Number), - }, - { - status: 503, - code: "clickhouse_connection_unavailable", - title: "ClickHouse is temporarily unavailable", - message: "The configured ClickHouse service is temporarily unavailable. Retry in a few seconds.", - retry: "backoff", - recovery: "retry", - exposure: "redacted", - }, -) {} - export class OrgClickHouseSettingsApiGroup extends HttpApiGroup.make("orgClickHouseSettings") .add( HttpApiEndpoint.get("get", "/", { diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index f298683b8..6a9490ed3 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -1684,6 +1684,10 @@ export class QueryEngineValidationError extends HttpTaggedError()( "@maple/http/errors/QueryEngineExecutionError", { diff --git a/packages/domain/src/http/scrape-targets.ts b/packages/domain/src/http/scrape-targets.ts index baf527f25..45fe86ed5 100644 --- a/packages/domain/src/http/scrape-targets.ts +++ b/packages/domain/src/http/scrape-targets.ts @@ -196,6 +196,35 @@ export class ScrapeTargetEncryptionError extends HttpTaggedError()( + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError", + { + rawTargetId: Schema.String, + component: Schema.Literals([ + "id", + "target_type", + "discovery_config", + "scrape_interval", + "auth_type", + "last_scrape_at", + "created_at", + "updated_at", + ]), + message: Schema.String, + cause: Schema.Defect(), + }, + { + status: 502, + code: "scrape_target_stored_config_invalid", + title: "Saved scrape target is invalid", + message: "The saved scrape target configuration is invalid. Recreate the target.", + retry: "never", + recovery: "reconnect", + exposure: "redacted", + }, +) {} + /** * Legacy v1 scrape-auth envelope. V2 preserves managed OAuth failures as their * exact integration tags; this remains for v1 compatibility and direct manual diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts index c2a248457..a8c5f6080 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -3,14 +3,28 @@ import { Schema } from "effect" import { HazelChannelId, HazelOrganizationId, PostgresTransactionId, UserId } from "../../primitives" import { AlertDeliveryError, + AlertDestinationDecryptionError, + AlertDestinationEncryptionError, AlertDestinationNotFoundError, + AlertDestinationStoredConfigInvalidError, AlertDestinationInUseError, AlertDestinationType, AlertForbiddenError, + AlertMemberDirectoryNotConfiguredError, + AlertMemberDirectoryUnavailableError, AlertPersistenceError, + AlertRecipientSelectionError, + AlertRuleStoredConfigInvalidError, AlertValidationError, MAX_EMAIL_RECIPIENTS, } from "../alerts" +import { + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsValidationError, +} from "../integrations" import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" @@ -348,6 +362,25 @@ const [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDe AlertDestinationNotFoundError, AlertDeliveryError, ) +const hazelWebhookProvisionErrors = publicErrors( + IntegrationsNotConnectedError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsPersistenceError, + IntegrationsValidationError, +) +const emailRecipientErrors = publicErrors( + AlertRecipientSelectionError, + AlertMemberDirectoryNotConfiguredError, + AlertMemberDirectoryUnavailableError, +) +const [destinationEncryption, destinationDecryption, destinationStoredConfigInvalid] = publicErrors( + AlertDestinationEncryptionError, + AlertDestinationDecryptionError, + AlertDestinationStoredConfigInvalidError, +) +const destinationReadErrors = [destinationDecryption, destinationStoredConfigInvalid] as const +const ruleStoredConfigInvalid = publicError(AlertRuleStoredConfigInvalidError) const AlertDestinationList = ListOf(V2AlertDestination).annotate({ identifier: "AlertDestinationList", @@ -360,7 +393,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.get("list", "/", { query: ListQuery, success: AlertDestinationList, - error: [V2ParameterInvalid.schema, alertPersistence], + error: [V2ParameterInvalid.schema, alertPersistence, destinationStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertDestinations", @@ -374,7 +407,14 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.post("create", "/", { payload: V2AlertDestinationCreateParams, success: V2AlertDestinationMutationResponse, - error: [alertForbidden, alertValidation, alertPersistence, alertDelivery], + error: [ + alertForbidden, + alertValidation, + alertPersistence, + destinationEncryption, + ...hazelWebhookProvisionErrors, + ...emailRecipientErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "createAlertDestination", @@ -388,7 +428,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertDestinationPublicId }, success: V2AlertDestination, - error: [alertNotFound, alertPersistence], + error: [alertNotFound, alertPersistence, destinationStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertDestination", @@ -403,7 +443,16 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina params: { id: AlertDestinationPublicId }, payload: V2AlertDestinationUpdateParams, success: V2AlertDestinationMutationResponse, - error: [alertForbidden, alertValidation, alertPersistence, alertNotFound], + error: [ + alertForbidden, + alertValidation, + alertPersistence, + alertNotFound, + destinationEncryption, + ...destinationReadErrors, + ...hazelWebhookProvisionErrors, + ...emailRecipientErrors, + ], }).annotateMerge( OpenApi.annotations({ identifier: "updateAlertDestination", @@ -417,7 +466,13 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.delete("delete", "/:id", { params: { id: AlertDestinationPublicId }, success: V2AlertDestinationDeleteResponse, - error: [alertForbidden, alertPersistence, alertNotFound, publicError(AlertDestinationInUseError)], + error: [ + alertForbidden, + alertPersistence, + alertNotFound, + publicError(AlertDestinationInUseError), + ruleStoredConfigInvalid, + ], }).annotateMerge( OpenApi.annotations({ identifier: "deleteAlertDestination", @@ -431,7 +486,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina HttpApiEndpoint.post("test", "/:id/test", { params: { id: AlertDestinationPublicId }, success: V2AlertDestinationTestResult, - error: [alertForbidden, alertValidation, alertPersistence, alertNotFound, alertDelivery], + error: [alertForbidden, alertPersistence, alertNotFound, alertDelivery, ...destinationReadErrors], }).annotateMerge( OpenApi.annotations({ identifier: "testAlertDestination", diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts index 95804535b..0bf0c9998 100644 --- a/packages/domain/src/http/v2/alert-rules.ts +++ b/packages/domain/src/http/v2/alert-rules.ts @@ -9,10 +9,13 @@ import { AlertIncidentTransition, AlertNotificationTemplate, AlertDeliveryError, - AlertDestinationNotFoundError, + AlertDestinationDecryptionError, + AlertDestinationStoredConfigInvalidError, AlertForbiddenError, AlertPersistenceError, + AlertRuleDestinationNotFoundError, AlertRuleNotFoundError, + AlertRuleStoredConfigInvalidError, AlertSeverity, AlertSignalType, AlertValidationError, @@ -634,7 +637,12 @@ const [alertForbidden, alertValidation, alertPersistence, alertRuleNotFound, ale AlertRuleNotFoundError, AlertDeliveryError, ) -const alertDestinationNotFound = publicError(AlertDestinationNotFoundError) +const alertRuleDestinationNotFound = publicError(AlertRuleDestinationNotFoundError) +const alertRuleStoredConfigInvalid = publicError(AlertRuleStoredConfigInvalidError) +const alertDestinationStorageErrors = publicErrors( + AlertDestinationDecryptionError, + AlertDestinationStoredConfigInvalidError, +) const AlertRuleList = ListOf(V2AlertRule).annotate({ identifier: "AlertRuleList", @@ -700,7 +708,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.get("list", "/", { query: ListQuery, success: AlertRuleList, - error: [V2ParameterInvalid.schema, alertPersistence], + error: [V2ParameterInvalid.schema, alertPersistence, alertRuleStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "listAlertRules", @@ -719,7 +727,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertForbidden, alertValidation, alertPersistence, - alertRuleNotFound, + alertRuleDestinationNotFound, ], }).annotateMerge( OpenApi.annotations({ @@ -734,7 +742,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertRulePublicId }, success: V2AlertRule, - error: [alertPersistence, alertRuleNotFound], + error: [alertPersistence, alertRuleNotFound, alertRuleStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "getAlertRule", @@ -755,6 +763,8 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertValidation, alertPersistence, alertRuleNotFound, + alertRuleDestinationNotFound, + alertRuleStoredConfigInvalid, ], }).annotateMerge( OpenApi.annotations({ @@ -788,8 +798,9 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") alertForbidden, alertValidation, alertPersistence, - alertDestinationNotFound, + alertRuleDestinationNotFound, alertDelivery, + ...alertDestinationStorageErrors, ...V2WarehouseErrors, ...V2QueryEngineRouteErrors, ], @@ -810,7 +821,6 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules") V2ParameterInvalid.schema, alertForbidden, alertValidation, - alertPersistence, ...V2WarehouseErrors, ...V2QueryEngineRouteErrors, ], diff --git a/packages/domain/src/http/v2/anomalies.ts b/packages/domain/src/http/v2/anomalies.ts index 2ea307e52..ab77c21a0 100644 --- a/packages/domain/src/http/v2/anomalies.ts +++ b/packages/domain/src/http/v2/anomalies.ts @@ -21,7 +21,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError, publicErrors } from "./public-error" -import { V2WarehouseErrors } from "./query-errors" +import { V2WarehouseReadErrors } from "./query-errors" import { AnomalyIncidentPublicId, ErrorIssuePublicId } from "./resource-ids" export { AnomalyIncidentPublicId } from "./resource-ids" @@ -333,7 +333,7 @@ export class V2AnomaliesApiGroup extends HttpApiGroup.make("anomalies") params: { id: AnomalyIncidentPublicId }, query: V2AnomalyTimeseriesQuery, success: V2AnomalyIncidentTimeseries, - error: [anomalyPersistence, anomalyNotFound, ...V2WarehouseErrors], + error: [anomalyPersistence, anomalyNotFound, ...V2WarehouseReadErrors], }).annotateMerge( OpenApi.annotations({ identifier: "getAnomalyIncidentTimeseries", diff --git a/packages/domain/src/http/v2/dashboards.ts b/packages/domain/src/http/v2/dashboards.ts index 6f368c36c..1268326f6 100644 --- a/packages/domain/src/http/v2/dashboards.ts +++ b/packages/domain/src/http/v2/dashboards.ts @@ -14,6 +14,7 @@ import { DashboardConcurrencyError, DashboardNotFoundError, DashboardPersistenceError, + DashboardStoredConfigInvalidError, DashboardRefreshIntervalSeconds, DashboardTemplateNotFoundError, DashboardTemplatePreviewKind, @@ -641,6 +642,7 @@ const [ dashboardValidation, dashboardConcurrency, dashboardTemplateNotFound, + dashboardStoredConfigInvalid, ] = publicErrors( DashboardVersionNotFoundError, DashboardPersistenceError, @@ -648,16 +650,23 @@ const [ DashboardValidationError, DashboardConcurrencyError, DashboardTemplateNotFoundError, + DashboardStoredConfigInvalidError, ) -const dashboardMutationErrors = [dashboardValidation, dashboardPersistence, dashboardConcurrency] as const +const dashboardCreateErrors = [dashboardValidation, dashboardPersistence] as const +const dashboardUpdateErrors = [ + dashboardValidation, + dashboardPersistence, + dashboardConcurrency, + dashboardStoredConfigInvalid, +] as const export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") .add( HttpApiEndpoint.get("list", "/", { query: ListQuery, success: DashboardList, - error: [V2ParameterInvalid.schema, dashboardPersistence], + error: [V2ParameterInvalid.schema, dashboardPersistence, dashboardStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "listDashboards", @@ -670,7 +679,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.post("create", "/", { payload: V2DashboardCreateParams, success: V2DashboardMutation, - error: dashboardMutationErrors, + error: dashboardCreateErrors, }).annotateMerge( OpenApi.annotations({ identifier: "createDashboard", @@ -684,7 +693,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.post("importPerses", "/import/perses", { payload: V2DashboardPersesImportParams, success: V2DashboardPersesImportResponse, - error: dashboardMutationErrors, + error: dashboardCreateErrors, }).annotateMerge( OpenApi.annotations({ identifier: "importPersesDashboard", @@ -732,7 +741,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") V2ParameterInvalid.schema, V2ParameterMissing.schema, dashboardTemplateNotFound, - ...dashboardMutationErrors, + ...dashboardCreateErrors, ], }).annotateMerge( OpenApi.annotations({ @@ -747,7 +756,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: DashboardPublicId }, success: V2Dashboard, - error: [dashboardPersistence, dashboardNotFound], + error: [dashboardPersistence, dashboardNotFound, dashboardStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "getDashboard", @@ -761,7 +770,7 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { id: DashboardPublicId }, payload: V2DashboardUpdateParams, success: V2DashboardMutation, - error: [dashboardNotFound, ...dashboardMutationErrors], + error: [dashboardNotFound, ...dashboardUpdateErrors], }).annotateMerge( OpenApi.annotations({ identifier: "updateDashboard", @@ -789,7 +798,12 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { id: DashboardPublicId }, query: ListQuery, success: DashboardVersionList, - error: [V2ParameterInvalid.schema, dashboardPersistence, dashboardNotFound], + error: [ + V2ParameterInvalid.schema, + dashboardPersistence, + dashboardNotFound, + dashboardStoredConfigInvalid, + ], }).annotateMerge( OpenApi.annotations({ identifier: "listDashboardVersions", @@ -802,7 +816,12 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") HttpApiEndpoint.get("retrieveVersion", "/:id/versions/:version_id", { params: { id: DashboardPublicId, version_id: DashboardVersionPublicId }, success: V2DashboardVersionDetail, - error: [dashboardPersistence, dashboardNotFound, dashboardVersionNotFound], + error: [ + dashboardPersistence, + dashboardNotFound, + dashboardVersionNotFound, + dashboardStoredConfigInvalid, + ], }).annotateMerge( OpenApi.annotations({ identifier: "getDashboardVersion", @@ -815,7 +834,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: [dashboardNotFound, dashboardVersionNotFound, ...dashboardMutationErrors], + error: [dashboardNotFound, dashboardVersionNotFound, ...dashboardUpdateErrors], }).annotateMerge( OpenApi.annotations({ identifier: "restoreDashboardVersion", diff --git a/packages/domain/src/http/v2/error-issues.ts b/packages/domain/src/http/v2/error-issues.ts index 16d460d87..fb7a0d335 100644 --- a/packages/domain/src/http/v2/error-issues.ts +++ b/packages/domain/src/http/v2/error-issues.ts @@ -14,7 +14,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { V2CursorInvalid, V2CursorSortMismatch } from "./errors" import { publicErrors } from "./public-error" -import { V2WarehouseErrors } from "./query-errors" +import { V2WarehouseReadErrors } from "./query-errors" import { ActorPublicId, ErrorIncidentPublicId, ErrorIssuePublicId } from "./resource-ids" export const V2ErrorIssueActor = Schema.Struct({ @@ -170,7 +170,7 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") V2CursorInvalid.schema, V2CursorSortMismatch.schema, errorPersistence, - ...V2WarehouseErrors, + ...V2WarehouseReadErrors, ], }).annotateMerge( OpenApi.annotations({ @@ -200,7 +200,7 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") params: { id: ErrorIssuePublicId }, query: V2ErrorIssueDetailQuery, success: V2ErrorIssueDetail, - error: [errorIssueNotFound, errorPersistence, ...V2WarehouseErrors], + error: [errorIssueNotFound, errorPersistence, ...V2WarehouseReadErrors], }).annotateMerge( OpenApi.annotations({ identifier: "getErrorIssue", diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts index 23b28dbd2..539a15063 100644 --- a/packages/domain/src/http/v2/errors.ts +++ b/packages/domain/src/http/v2/errors.ts @@ -5,6 +5,7 @@ import { PublicHttpErrorType, publicHttpErrorTypeForStatus, type HttpErrorRetry, + type PublicHttpErrorTag, type PublicHttpErrorStatus, } from "../error-policy" import { publicError } from "./public-error" @@ -22,8 +23,8 @@ export type V2ErrorRecovery = Schema.Schema.Type export const errorTypeForStatus = publicHttpErrorTypeForStatus -export interface V2ErrorDefinitionOptions< - Tag extends string, +interface V2ErrorDefinitionBase< + Tag extends PublicHttpErrorTag, Status extends PublicHttpErrorStatus, Code extends string, > { @@ -32,12 +33,26 @@ export interface V2ErrorDefinitionOptions< readonly code: Code readonly title: string readonly message: string - readonly retry: HttpErrorRetry - readonly recovery: V2ErrorRecovery readonly identifier: string readonly retryAfterSeconds?: number } +export type V2ErrorDefinitionOptions< + Tag extends PublicHttpErrorTag, + Status extends PublicHttpErrorStatus, + Code extends string, +> = V2ErrorDefinitionBase & + ( + | { + readonly retry: "never" + readonly recovery: Exclude + } + | { + readonly retry: Exclude + readonly recovery: "retry" + } + ) + export interface V2ErrorMakeOptions { readonly param?: string readonly retryAfterSeconds?: number @@ -49,13 +64,17 @@ export interface V2ErrorMakeOptions { * recovery metadata come from this one definition. */ export const defineV2Error = < - const Tag extends string, + const Tag extends PublicHttpErrorTag, const Status extends PublicHttpErrorStatus, const Code extends string, >( definition: V2ErrorDefinitionOptions, ) => { const type = errorTypeForStatus(definition.status) + const retryPolicy = + definition.retry === "never" + ? { retry: "never" as const, recovery: definition.recovery } + : { retry: definition.retry, recovery: "retry" as const } class BoundaryError extends HttpTaggedError()( definition.tag, { @@ -70,8 +89,7 @@ export const defineV2Error = < status: definition.status, code: definition.code, title: definition.title, - retry: definition.retry, - recovery: definition.recovery, + ...retryPolicy, exposure: "public_message", param: (error) => error.param, retryAfterSeconds: (error) => error.retryAfterSeconds, diff --git a/packages/domain/src/http/v2/integrations-planetscale.ts b/packages/domain/src/http/v2/integrations-planetscale.ts index 8c022ea7e..e9e0322b2 100644 --- a/packages/domain/src/http/v2/integrations-planetscale.ts +++ b/packages/domain/src/http/v2/integrations-planetscale.ts @@ -9,6 +9,13 @@ import { IntegrationsUpstreamError, IntegrationsValidationError, } from "../integrations" +import { + ScrapeTargetEncryptionError, + ScrapeTargetNotFoundError, + ScrapeTargetPersistenceError, + ScrapeTargetStoredConfigInvalidError, + ScrapeTargetValidationError, +} from "../scrape-targets" import { AuthorizationV2 } from "./auth" import { Timestamp } from "./envelopes" import { V2CallbackHostUnavailable, V2InsufficientPermissions, V2TimeRangeInvalid } from "./errors" @@ -790,11 +797,32 @@ const organizationErrors = [ integrationPersistence, ] as const +const [ + scrapeTargetNotFound, + scrapeTargetValidation, + scrapeTargetPersistence, + scrapeTargetEncryption, + scrapeTargetStoredConfigInvalid, +] = publicErrors( + ScrapeTargetNotFoundError, + ScrapeTargetValidationError, + ScrapeTargetPersistenceError, + ScrapeTargetEncryptionError, + ScrapeTargetStoredConfigInvalidError, +) +const scrapeTargetMutationErrors = [ + scrapeTargetNotFound, + scrapeTargetValidation, + scrapeTargetPersistence, + scrapeTargetEncryption, + scrapeTargetStoredConfigInvalid, +] as const + export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planetscaleIntegration") .add( HttpApiEndpoint.get("status", "/", { success: V2PlanetScaleIntegration, - error: [integrationPersistence], + error: [integrationPersistence, scrapeTargetStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "getPlanetScaleIntegration", @@ -842,7 +870,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet HttpApiEndpoint.post("selectOrganization", "/select_organization", { payload: V2PlanetScaleSelectOrganizationRequest, success: V2PlanetScaleIntegration, - error: [V2InsufficientPermissions.schema, ...organizationErrors], + error: [V2InsufficientPermissions.schema, ...organizationErrors, ...scrapeTargetMutationErrors], }).annotateMerge( OpenApi.annotations({ identifier: "selectPlanetScaleOrganization", @@ -862,6 +890,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet integrationValidation, integrationUpstream, integrationPersistence, + ...scrapeTargetMutationErrors, ], }).annotateMerge( OpenApi.annotations({ @@ -875,7 +904,7 @@ export class V2PlanetScaleIntegrationsApiGroup extends HttpApiGroup.make("planet .add( HttpApiEndpoint.delete("disconnect", "/", { success: V2PlanetScaleDisconnectResponse, - error: [V2InsufficientPermissions.schema, integrationPersistence], + error: [V2InsufficientPermissions.schema, integrationPersistence, scrapeTargetPersistence], }).annotateMerge( OpenApi.annotations({ identifier: "disconnectPlanetScaleIntegration", diff --git a/packages/domain/src/http/v2/investigations.ts b/packages/domain/src/http/v2/investigations.ts index f203fbb08..7306be53d 100644 --- a/packages/domain/src/http/v2/investigations.ts +++ b/packages/domain/src/http/v2/investigations.ts @@ -5,13 +5,10 @@ import { IssueSeverity } from "../errors" import { InvestigationConfidence, InvestigationAgentUnavailableError, - InvestigationAutomationDisabledError, InvestigationDataCorruptionError, InvestigationFanoutState, InvestigationNotFoundError, InvestigationPersistenceError, - InvestigationQuotaError, - InvestigationRejectedError, InvestigationSeededBy, InvestigationStartFailedError, InvestigationStatus, @@ -500,30 +497,21 @@ export type V2InvestigationsListQuery = Schema.Schema.Type { const op = candidate as Record for (const status of Object.keys(op.responses)) { if (Number(status) < 400) continue + const response = (candidate as Record).responses[status] as Record< + string, + any + > + const responseSchema = response.content["application/json"].schema as Record + const branches = schemaBranches(responseSchema) const tags = responseErrorTags(method, path, status) expect(tags.length, `${method.toUpperCase()} ${path} ${status} has tags`).toBeGreaterThan( 0, @@ -471,7 +478,46 @@ describe("MapleApiV2 OpenAPI", () => { `${method.toUpperCase()} ${path} ${status} tags are unique`, ).toBe(tags.length) for (const tag of tags) { - expect(tag, `${method.toUpperCase()} ${path} ${status} tag`).toMatch(/^@maple\//) + expect(tag, `${method.toUpperCase()} ${path} ${status} tag`).toMatch( + /^@maple\/http\//, + ) + } + for (const branch of branches) { + const envelope = resolveSchema(branch) + const body = envelope.properties.error as Record + const label = `${method.toUpperCase()} ${path} ${status}` + expect(envelope.required, `${label} envelope fields`).toEqual(["error"]) + expect(envelope.additionalProperties, `${label} envelope is closed`).toBe(false) + expect(body.required, `${label} body fields`).toEqual([ + "_tag", + "type", + "code", + "title", + "message", + "retryable", + "recovery", + ]) + expect(body.additionalProperties, `${label} body is closed`).toBe(false) + for (const field of ["_tag", "type", "code", "title", "retryable", "recovery"]) { + expect(body.properties[field].enum, `${label} ${field} is exact`).toHaveLength(1) + } + expect(body.properties.message.type, `${label} message`).toBe("string") + expect(body.properties.type.enum, `${label} status category`).toEqual([ + errorTypeForStatus( + Number(status) as + | 400 + | 401 + | 403 + | 404 + | 409 + | 413 + | 429 + | 500 + | 502 + | 503 + | 504, + ), + ]) } } } @@ -496,9 +542,8 @@ describe("MapleApiV2 OpenAPI", () => { ]) expect(responseErrorTags("post", "/v2/traces/timeseries", "500")).toEqual([ "@maple/http/errors/WarehouseMalformedQueryError", - "@maple/http/errors/WarehouseConfigDecryptionError", - "@maple/http/errors/WarehouseTokenConfigError", - "@maple/http/errors/WarehouseTokenMintError", + "@maple/http/errors/WarehouseScopeError", + "@maple/http/errors/OrgClickHouseSettingsEncryptionError", "@maple/http/errors/QueryEngineResultMismatchError", "@maple/http/v2/ResponseSchemaError", "@maple/http/v2/UnexpectedError", @@ -506,6 +551,9 @@ describe("MapleApiV2 OpenAPI", () => { }) it("does not advertise service errors that v2 handlers cannot emit", () => { + const serializedSpec = JSON.stringify(spec) + expect(serializedSpec).not.toContain("@maple/http/errors/QueryEngineExecutionError") + expect(serializedSpec).not.toContain("@maple/http/errors/WarehouseValidationError") expect(responseErrorTags("post", "/v2/api_keys", "403")).toEqual([ "@maple/http/v2/InsufficientPermissionsError", "@maple/http/v2/InsufficientScopeError", @@ -533,6 +581,48 @@ describe("MapleApiV2 OpenAPI", () => { expect(declaredTags).not.toContain("@maple/http/errors/ScrapeTargetAuthError") }) + it("preserves managed scrape-target failures on PlanetScale mutations", () => { + for (const path of [ + "/v2/integrations/planetscale/select_organization", + "/v2/integrations/planetscale/metrics_token", + ]) { + expect(responseErrorTags("post", path, "400")).toContain( + "@maple/http/errors/ScrapeTargetValidationError", + ) + expect(responseErrorTags("post", path, "404")).toContain( + "@maple/http/errors/ScrapeTargetNotFoundError", + ) + expect(responseErrorTags("post", path, "500")).toContain( + "@maple/http/errors/ScrapeTargetEncryptionError", + ) + expect(responseErrorTags("post", path, "502")).toContain( + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError", + ) + expect(responseErrorTags("post", path, "503")).toContain( + "@maple/http/errors/ScrapeTargetPersistenceError", + ) + } + expect(responseErrorTags("delete", "/v2/integrations/planetscale", "503")).toContain( + "@maple/http/errors/ScrapeTargetPersistenceError", + ) + }) + + it("distinguishes malformed stored scrape targets from persistence outages", () => { + for (const [method, path] of [ + ["get", "/v2/scrape_targets"], + ["get", "/v2/scrape_targets/{id}"], + ["patch", "/v2/scrape_targets/{id}"], + ] as const) { + expect(responseErrorTags(method, path, "502"), path).toContain( + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError", + ) + } + expect(operation("post", "/v2/scrape_targets").responses["502"]).toBeUndefined() + expect(responseErrorTags("get", "/v2/integrations/planetscale", "502")).toContain( + "@maple/http/errors/ScrapeTargetStoredConfigInvalidError", + ) + }) + it("preserves warehouse failures on v2 read-model endpoints", () => { for (const [method, path] of [ ["get", "/v2/error_issues"], @@ -543,8 +633,15 @@ describe("MapleApiV2 OpenAPI", () => { "@maple/http/errors/WarehouseQuotaExceededError", ) expect(responseErrorTags(method, path, "503")).toContain( - "@maple/http/errors/WarehouseConfigLookupError", + "@maple/http/errors/OrgClickHouseSettingsPersistenceError", ) + const declaredTags = ["400", "401", "403", "404", "409", "429", "500", "502", "503", "504"] + .filter((status) => operation(method, path).responses[status] !== undefined) + .flatMap((status) => responseErrorTags(method, path, status)) + expect(declaredTags, path).not.toContain("@maple/http/errors/TinybirdOrgTokenConfigError") + expect(declaredTags, path).not.toContain("@maple/http/errors/TinybirdOrgTokenMintError") + expect(declaredTags, path).not.toContain("@maple/http/errors/WarehouseValidationError") + expect(declaredTags, path).toContain("@maple/http/errors/WarehouseScopeError") } for (const path of ["/v2/alerts/rules/{id}/checks", "/v2/alerts/rules/{id}/checks/summary"]) { expect(responseErrorTags("get", path, "429")).toContain( @@ -554,11 +651,11 @@ describe("MapleApiV2 OpenAPI", () => { responseErrorTags("get", path, status), ) for (const impossibleRoutingTag of [ - "@maple/http/errors/WarehouseConfigLookupError", - "@maple/http/errors/WarehouseConfigDecryptionError", - "@maple/http/errors/WarehouseStoredConfigInvalidError", - "@maple/http/errors/WarehouseTokenConfigError", - "@maple/http/errors/WarehouseTokenMintError", + "@maple/http/errors/OrgClickHouseSettingsPersistenceError", + "@maple/http/errors/OrgClickHouseSettingsEncryptionError", + "@maple/http/errors/OrgClickHouseSettingsStoredConfigInvalidError", + "@maple/http/errors/TinybirdOrgTokenConfigError", + "@maple/http/errors/TinybirdOrgTokenMintError", ]) { expect(declaredTags, path).not.toContain(impossibleRoutingTag) } @@ -566,16 +663,26 @@ describe("MapleApiV2 OpenAPI", () => { }) it("declares exact alert not-found and query-engine failures", () => { + expect(responseErrorTags("post", "/v2/alerts/rules", "404")).toEqual([ + "@maple/http/errors/AlertRuleDestinationNotFoundError", + ]) expect(responseErrorTags("get", "/v2/alerts/rules/{id}", "404")).toEqual([ "@maple/http/errors/AlertRuleNotFoundError", ]) + expect(responseErrorTags("patch", "/v2/alerts/rules/{id}", "404")).toEqual([ + "@maple/http/errors/AlertRuleNotFoundError", + "@maple/http/errors/AlertRuleDestinationNotFoundError", + ]) + expect(responseErrorTags("post", "/v2/alerts/rules/test", "404")).toEqual([ + "@maple/http/errors/AlertRuleDestinationNotFoundError", + ]) expect(responseErrorTags("get", "/v2/alerts/destinations/{id}", "404")).toEqual([ "@maple/http/errors/AlertDestinationNotFoundError", ]) expect(responseErrorTags("get", "/v2/alerts/incidents/{id}", "404")).toEqual([ "@maple/http/errors/AlertIncidentNotFoundError", ]) - expect(responseErrorTags("post", "/v2/alerts/rules/preview", "502")).toContain( + expect(responseErrorTags("post", "/v2/alerts/rules/preview", "502")).not.toContain( "@maple/http/errors/QueryEngineExecutionError", ) expect(responseErrorTags("post", "/v2/alerts/rules/preview", "502")).not.toContain( @@ -584,6 +691,155 @@ describe("MapleApiV2 OpenAPI", () => { expect(responseErrorTags("post", "/v2/alerts/rules/preview", "504")).toContain( "@maple/http/errors/QueryEngineTimeoutError", ) + expect(responseErrorTags("post", "/v2/alerts/rules/preview", "500")).toContain( + "@maple/http/errors/TinybirdOrgTokenConfigError", + ) + for (const [method, path] of [ + ["get", "/v2/alerts/rules"], + ["get", "/v2/alerts/rules/{id}"], + ["patch", "/v2/alerts/rules/{id}"], + ["delete", "/v2/alerts/destinations/{id}"], + ] as const) { + expect(responseErrorTags(method, path, "500")).toContain( + "@maple/http/errors/AlertRuleStoredConfigInvalidError", + ) + } + }) + + it("distinguishes corrupt stored dashboards from persistence outages", () => { + for (const [method, path] of [ + ["get", "/v2/dashboards"], + ["get", "/v2/dashboards/{id}"], + ["patch", "/v2/dashboards/{id}"], + ["get", "/v2/dashboards/{id}/versions"], + ["get", "/v2/dashboards/{id}/versions/{version_id}"], + ["post", "/v2/dashboards/{id}/versions/{version_id}/restore"], + ] as const) { + expect(responseErrorTags(method, path, "500"), path).toContain( + "@maple/http/errors/DashboardStoredConfigInvalidError", + ) + } + + for (const [method, path] of [ + ["delete", "/v2/dashboards/{id}"], + ["post", "/v2/dashboards"], + ["post", "/v2/dashboards/import/perses"], + ["post", "/v2/dashboards/templates/{template_id}/instantiate"], + ["get", "/v2/dashboards/templates"], + ["post", "/v2/dashboards/templates/{template_id}/preview"], + ] as const) { + const tags = Object.keys(operation(method, path).responses) + .filter((status) => Number(status) >= 400) + .flatMap((status) => responseErrorTags(method, path, status)) + expect(tags, path).not.toContain("@maple/http/errors/DashboardStoredConfigInvalidError") + } + }) + + it("keeps automatic-investigation policy errors off manual endpoints", () => { + for (const [method, path] of [ + ["post", "/v2/investigations"], + ["post", "/v2/investigations/{id}/restart"], + ] as const) { + const tags = Object.keys(operation(method, path).responses) + .filter((status) => Number(status) >= 400) + .flatMap((status) => responseErrorTags(method, path, status)) + for (const impossibleTag of [ + "@maple/http/investigations/InvestigationQuotaError", + "@maple/http/investigations/InvestigationAutomationDisabledError", + "@maple/http/investigations/InvestigationRejectedError", + ]) { + expect(tags, path).not.toContain(impossibleTag) + } + } + }) + + it("preserves Hazel provisioning failures on alert destination mutations", () => { + for (const [method, path] of [ + ["post", "/v2/alerts/destinations"], + ["patch", "/v2/alerts/destinations/{id}"], + ] as const) { + expect(responseErrorTags(method, path, "401")).toContain( + "@maple/http/errors/IntegrationsRevokedError", + ) + expect(responseErrorTags(method, path, "409")).toContain( + "@maple/http/errors/IntegrationsNotConnectedError", + ) + expect(responseErrorTags(method, path, "502")).toContain( + "@maple/http/errors/IntegrationsUpstreamError", + ) + } + expect(responseErrorTags("post", "/v2/alerts/destinations", "502")).not.toContain( + "@maple/http/errors/AlertDeliveryError", + ) + }) + + it("declares exact alert-destination storage failures only where they can occur", () => { + expect(responseErrorTags("post", "/v2/alerts/destinations", "500")).toContain( + "@maple/http/errors/AlertDestinationEncryptionError", + ) + for (const [method, path] of [ + ["get", "/v2/alerts/destinations"], + ["get", "/v2/alerts/destinations/{id}"], + ] as const) { + expect(responseErrorTags(method, path, "500")).toContain( + "@maple/http/errors/AlertDestinationStoredConfigInvalidError", + ) + } + + for (const [method, path] of [ + ["patch", "/v2/alerts/destinations/{id}"], + ["post", "/v2/alerts/destinations/{id}/test"], + ["post", "/v2/alerts/rules/test"], + ] as const) { + const tags = responseErrorTags(method, path, "500") + expect(tags).toContain("@maple/http/errors/AlertDestinationDecryptionError") + expect(tags).toContain("@maple/http/errors/AlertDestinationStoredConfigInvalidError") + } + + for (const [method, path, tag] of [ + ["post", "/v2/alerts/rules", "@maple/http/errors/AlertRuleStoredConfigInvalidError"], + [ + "post", + "/v2/alerts/destinations", + "@maple/http/errors/AlertDestinationStoredConfigInvalidError", + ], + ] as const) { + const tags = Object.keys(operation(method, path).responses) + .filter((status) => Number(status) >= 400) + .flatMap((status) => responseErrorTags(method, path, status)) + expect(tags, path).not.toContain(tag) + } + + const previewTags = ["400", "401", "403", "404", "409", "429", "500", "502", "503", "504"] + .filter((status) => operation("post", "/v2/alerts/rules/preview").responses[status] !== undefined) + .flatMap((status) => responseErrorTags("post", "/v2/alerts/rules/preview", status)) + expect(previewTags).not.toContain("@maple/http/errors/AlertPersistenceError") + expect(previewTags).not.toContain("@maple/http/errors/AlertDestinationDecryptionError") + expect(previewTags).not.toContain("@maple/http/errors/AlertDestinationStoredConfigInvalidError") + const destinationTestTags = ["400", "401", "403", "404", "409", "429", "500", "502", "503", "504"] + .filter( + (status) => + operation("post", "/v2/alerts/destinations/{id}/test").responses[status] !== undefined, + ) + .flatMap((status) => responseErrorTags("post", "/v2/alerts/destinations/{id}/test", status)) + expect(destinationTestTags).not.toContain("@maple/http/errors/AlertValidationError") + }) + + it("distinguishes invalid email recipients from member-directory failures", () => { + for (const [method, path] of [ + ["post", "/v2/alerts/destinations"], + ["patch", "/v2/alerts/destinations/{id}"], + ] as const) { + expect(responseErrorTags(method, path, "400")).toContain( + "@maple/http/errors/AlertRecipientSelectionError", + ) + expect(responseErrorTags(method, path, "500")).toContain( + "@maple/http/errors/AlertMemberDirectoryNotConfiguredError", + ) + expect(responseErrorTags(method, path, "503")).toContain( + "@maple/http/errors/AlertMemberDirectoryUnavailableError", + ) + } }) it("decodes slack-bot destination create/update params and rejects a blank channel_id", () => { diff --git a/packages/domain/src/http/v2/public-error.test.ts b/packages/domain/src/http/v2/public-error.test.ts index 6205d6631..5b4e3e7a7 100644 --- a/packages/domain/src/http/v2/public-error.test.ts +++ b/packages/domain/src/http/v2/public-error.test.ts @@ -14,6 +14,7 @@ import { WarehouseMalformedQueryError, WarehouseQuotaExceededError, WarehouseResultDecodeError, + WarehouseScopeError, WarehouseSchemaDriftError, WarehouseUpstreamError, WarehouseValidationError, @@ -71,11 +72,17 @@ describe("HttpTaggedError public body", () => { pipeName: "traces_timeseries", message: "NO_COMMON_TYPE", }) + const scope = new WarehouseScopeError({ + pipeName: "compiledQuery", + message: "missing tenant scope", + }) expect(publicHttpErrorPolicy(validation).status).toBe(400) expect(publicHttpErrorPolicy(quota).status).toBe(429) expect(publicHttpErrorPolicy(upstream).status).toBe(503) expect(publicHttpErrorPolicy(malformed).status).toBe(500) + expect(publicHttpErrorPolicy(scope).status).toBe(500) + expect(scope.error.message).not.toContain("missing tenant scope") }) it("owns warehouse remediation copy without exposing diagnostics", () => { diff --git a/packages/domain/src/http/v2/query-errors.ts b/packages/domain/src/http/v2/query-errors.ts index e53ed49f5..9b3e6d543 100644 --- a/packages/domain/src/http/v2/query-errors.ts +++ b/packages/domain/src/http/v2/query-errors.ts @@ -1,28 +1,30 @@ import { - QueryEngineExecutionError, QueryEngineResultMismatchError, QueryEngineTimeoutError, QueryEngineValidationError, } from "../query-engine" -import { managedWarehouseHttpErrors, warehouseHttpErrors } from "../warehouse-errors" +import { + managedWarehouseHttpErrors, + warehouseQueryHttpErrors, + warehouseReadHttpErrors, +} from "../warehouse-errors" import { publicErrors } from "./public-error" -/** Exact public schemas for the complete WarehouseError union. */ -export const V2WarehouseErrors = publicErrors(...warehouseHttpErrors) +/** Exact public schemas for failures that can escape compiled or raw v2 queries. */ +export const V2WarehouseErrors = publicErrors(...warehouseQueryHttpErrors) /** Managed-only routes never consult the per-org warehouse configuration. */ export const V2ManagedWarehouseErrors = publicErrors(...managedWarehouseHttpErrors) +/** Ordinary reads resolve saved settings but never mint raw-SQL access tokens. */ +export const V2WarehouseReadErrors = publicErrors(...warehouseReadHttpErrors) + /** Exact public schemas for failures added by the higher-level query engine. */ -export const V2QueryEngineRouteErrors = publicErrors( - QueryEngineValidationError, - QueryEngineExecutionError, - QueryEngineTimeoutError, -) +export const V2QueryEngineRouteErrors = publicErrors(QueryEngineValidationError, QueryEngineTimeoutError) export const V2QueryEngineErrors = [ ...V2QueryEngineRouteErrors, ...publicErrors(QueryEngineResultMismatchError), ] as const -export const V2QueryErrors = [...V2WarehouseErrors, ...V2QueryEngineErrors] as const +export const V2QueryErrors = [...V2WarehouseReadErrors, ...V2QueryEngineErrors] as const diff --git a/packages/domain/src/http/v2/scrape-targets.ts b/packages/domain/src/http/v2/scrape-targets.ts index ecb5df531..2da5442dd 100644 --- a/packages/domain/src/http/v2/scrape-targets.ts +++ b/packages/domain/src/http/v2/scrape-targets.ts @@ -13,6 +13,7 @@ import { ScrapeTargetEncryptionError, ScrapeTargetNotFoundError, ScrapeTargetPersistenceError, + ScrapeTargetStoredConfigInvalidError, ScrapeTargetValidationError, } from "../scrape-targets" import { AuthorizationV2 } from "./auth" @@ -347,12 +348,14 @@ export const V2ScrapeTargetChecksQuery = Schema.Struct({ }) export type V2ScrapeTargetChecksQuery = Schema.Schema.Type -const [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption] = publicErrors( - ScrapeTargetNotFoundError, - ScrapeTargetValidationError, - ScrapeTargetPersistenceError, - ScrapeTargetEncryptionError, -) +const [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption, scrapeStoredConfigInvalid] = + publicErrors( + ScrapeTargetNotFoundError, + ScrapeTargetValidationError, + ScrapeTargetPersistenceError, + ScrapeTargetEncryptionError, + ScrapeTargetStoredConfigInvalidError, + ) const planetScaleAccessTokenErrors = publicErrors( IntegrationsNotConnectedError, IntegrationsRevokedError, @@ -379,7 +382,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.get("list", "/", { query: ListQuery, success: ScrapeTargetList, - error: [V2ParameterInvalid.schema, scrapePersistence], + error: [V2ParameterInvalid.schema, scrapePersistence, scrapeStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "listScrapeTargets", @@ -407,7 +410,7 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: ScrapeTargetPublicId }, success: V2ScrapeTarget, - error: [scrapeNotFound, scrapePersistence], + error: [scrapeNotFound, scrapePersistence, scrapeStoredConfigInvalid], }).annotateMerge( OpenApi.annotations({ identifier: "getScrapeTarget", @@ -422,7 +425,13 @@ export class V2ScrapeTargetsApiGroup extends HttpApiGroup.make("scrapeTargets") params: { id: ScrapeTargetPublicId }, payload: V2ScrapeTargetUpdateParams, success: V2ScrapeTarget, - error: [scrapeNotFound, scrapeValidation, scrapePersistence, scrapeEncryption], + error: [ + scrapeNotFound, + scrapeValidation, + scrapePersistence, + scrapeEncryption, + scrapeStoredConfigInvalid, + ], }).annotateMerge( OpenApi.annotations({ identifier: "updateScrapeTarget", diff --git a/packages/domain/src/http/v2/session-replays.ts b/packages/domain/src/http/v2/session-replays.ts index 75d0de7a6..9c701be97 100644 --- a/packages/domain/src/http/v2/session-replays.ts +++ b/packages/domain/src/http/v2/session-replays.ts @@ -5,7 +5,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { defineV2Error, V2ParameterInvalid } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" -import { V2WarehouseErrors } from "./query-errors" +import { V2WarehouseReadErrors } from "./query-errors" /** See api-keys.ts: examples are authored in wire (encoded) shape. */ const wireExample = (example: object): A => example as A @@ -437,7 +437,7 @@ export const V2SessionReplayRangeTooLarge = defineV2Error({ identifier: "SessionReplayRangeTooLargeError", }) -const commonErrors = [V2ParameterInvalid.schema, ...V2WarehouseErrors] as const +const commonErrors = [V2ParameterInvalid.schema, ...V2WarehouseReadErrors] as const const SessionReplayList = ListOf(V2SessionReplayListItem).annotate({ identifier: "SessionReplayList", diff --git a/packages/domain/src/http/v2/telemetry.ts b/packages/domain/src/http/v2/telemetry.ts index 4324d24c8..f864b347a 100644 --- a/packages/domain/src/http/v2/telemetry.ts +++ b/packages/domain/src/http/v2/telemetry.ts @@ -5,7 +5,7 @@ import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" import { defineV2Error, V2CursorInvalid, V2ParameterInvalid, V2TimeRangeInvalid } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" -import { V2QueryErrors, V2WarehouseErrors } from "./query-errors" +import { V2QueryErrors, V2WarehouseReadErrors } from "./query-errors" const wireExample = (example: object): A => example as A export const V2TelemetryRangeTooLarge = defineV2Error({ @@ -130,7 +130,7 @@ export const V2ServiceNotFound = defineV2Error({ }) const windowErrors = [V2TimeRangeInvalid.schema, V2TelemetryRangeTooLarge.schema] as const -const warehouseWindowErrors = [...windowErrors, ...V2WarehouseErrors] as const +const warehouseWindowErrors = [...windowErrors, ...V2WarehouseReadErrors] as const const traceTimeseriesErrors = [ ...windowErrors, V2TelemetryBucketCountTooLarge.schema, @@ -741,7 +741,7 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") HttpApiEndpoint.get("retrieve", "/:trace_id", { params: { trace_id: TraceId }, success: V2Trace, - error: [...V2WarehouseErrors, V2TraceNotFound.schema], + error: [...V2WarehouseReadErrors, V2TraceNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getTrace", @@ -755,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: [...V2WarehouseErrors, V2SpanNotFound.schema], + error: [...V2WarehouseReadErrors, V2SpanNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getSpan", @@ -857,7 +857,7 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") HttpApiEndpoint.get("retrieve", "/:id", { params: { id: LogPublicId }, success: V2Log, - error: [V2LogIdInvalid.schema, ...V2WarehouseErrors, V2LogNotFound.schema], + error: [V2LogIdInvalid.schema, ...V2WarehouseReadErrors, V2LogNotFound.schema], }).annotateMerge( OpenApi.annotations({ identifier: "getLog", diff --git a/packages/domain/src/http/warehouse-errors.ts b/packages/domain/src/http/warehouse-errors.ts index 976baa380..9d44c3cb7 100644 --- a/packages/domain/src/http/warehouse-errors.ts +++ b/packages/domain/src/http/warehouse-errors.ts @@ -1,8 +1,13 @@ import { Schema } from "effect" import { HttpTaggedError, publicHttpErrorDefinitionFor } from "./error-policy" +import { + OrgClickHouseSettingsEncryptionError, + OrgClickHouseSettingsPersistenceError, + OrgClickHouseSettingsStoredConfigInvalidError, +} from "./org-clickhouse-settings-errors" -// Pure error definitions for warehouse queries. This module imports ONLY -// `effect` Schema — never `effect/unstable/httpapi` — so non-HTTP consumers +// Pure error definitions for warehouse queries. This module imports only +// Effect Schema and other error-only modules — never `effect/unstable/httpapi` — so non-HTTP consumers // (`@maple/query-engine/observability`, the CLI executors) can import these // classes without pulling the HttpApi AST builder into their bundles. // `warehouse.ts` re-exports everything here and owns the `WarehouseApiGroup`. @@ -18,8 +23,9 @@ import { HttpTaggedError, publicHttpErrorDefinitionFor } from "./error-policy" // pass never evaluates these Schema ASTs (they build on the first request, // 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 the warehouse classifier. +// Fields common to errors created by the SQL classifier/executor. Route +// dependencies keep their own tagged errors instead of being copied into this +// shape. `cause` carries the original defect; `clickhouse*` carry diagnostics. const warehouseErrorBaseFields = { message: Schema.String, pipeName: Schema.String, @@ -94,58 +100,16 @@ export class WarehouseConfigError extends HttpTaggedError( }, ) {} -/** Maple could not read the per-org warehouse routing configuration. */ -export class WarehouseConfigLookupError extends HttpTaggedError()( - "@maple/http/errors/WarehouseConfigLookupError", - warehouseErrorBaseFields, +/** The deployment cannot mint the org-scoped token required by raw SQL. */ +export class TinybirdOrgTokenConfigError extends HttpTaggedError()( + "@maple/http/errors/TinybirdOrgTokenConfigError", { - status: 503, - code: "warehouse_config_lookup_unavailable", - title: "Database settings are temporarily unavailable", - message: "Maple could not load the database settings. Retry in a few seconds.", - retry: "backoff", - recovery: "retry", - exposure: "redacted", + setting: Schema.Literals(["SigningKey", "WorkspaceId"]), + message: Schema.String, }, -) {} - -/** Maple could not decrypt the credentials stored for a per-org warehouse. */ -export class WarehouseConfigDecryptionError extends HttpTaggedError()( - "@maple/http/errors/WarehouseConfigDecryptionError", - warehouseErrorBaseFields, { status: 500, - code: "warehouse_config_decryption_failed", - title: "Maple could not read database credentials", - message: "Maple could not securely read the saved database credentials.", - retry: "never", - recovery: "contact_support", - exposure: "redacted", - }, -) {} - -/** A saved per-org warehouse configuration no longer passes runtime validation. */ -export class WarehouseStoredConfigInvalidError extends HttpTaggedError()( - "@maple/http/errors/WarehouseStoredConfigInvalidError", - warehouseErrorBaseFields, - { - status: 502, - code: "warehouse_stored_config_invalid", - title: "Saved database settings are invalid", - message: "The saved database settings are invalid. Reconnect the database in settings.", - retry: "never", - recovery: "reconnect", - exposure: "redacted", - }, -) {} - -/** The deployment is missing configuration required to mint an org-scoped token. */ -export class WarehouseTokenConfigError extends HttpTaggedError()( - "@maple/http/errors/WarehouseTokenConfigError", - warehouseErrorBaseFields, - { - status: 500, - code: "warehouse_token_config_invalid", + code: "tinybird_org_token_config_invalid", title: "Maple warehouse access is not configured", message: "Maple could not configure secure access to the database.", retry: "never", @@ -154,13 +118,16 @@ export class WarehouseTokenConfigError extends HttpTaggedError()( - "@maple/http/errors/WarehouseTokenMintError", - warehouseErrorBaseFields, +/** Minting the org-scoped Tinybird token failed. */ +export class TinybirdOrgTokenMintError extends HttpTaggedError()( + "@maple/http/errors/TinybirdOrgTokenMintError", + { + message: Schema.String, + cause: Schema.Defect(), + }, { status: 500, - code: "warehouse_token_mint_failed", + code: "tinybird_org_token_mint_failed", title: "Maple could not authorize database access", message: "Maple could not authorize secure access to the database.", retry: "never", @@ -216,6 +183,24 @@ export class WarehouseResultDecodeError extends HttpTaggedError()( + "@maple/http/errors/WarehouseScopeError", + warehouseErrorBaseFields, + { + status: 500, + code: "warehouse_scope_invariant_failed", + title: "Maple could not safely run this query", + message: "Maple rejected a query that did not satisfy its tenant-scope invariant.", + retry: "never", + recovery: "contact_support", + exposure: "redacted", + }, +) {} + /** * ClickHouse's analyzer rejected the SQL Maple generated — a type mismatch * between `if()` arms or `UNION` branches, an illegal argument type, an @@ -271,9 +256,9 @@ export class WarehouseQuotaExceededError extends HttpTaggedError()( "@maple/http/errors/WarehouseValidationError", @@ -293,45 +278,81 @@ export class WarehouseValidationError extends HttpTaggedError = ErrorClass extends abstract new (...args: never[]) => infer Error ? Error : never -/** Every warehouse error. Use this as the error channel of warehouse-facing effects. */ +/** Every warehouse error, including legacy caller-operation validation. */ export type WarehouseError = ErrorInstance<(typeof warehouseHttpErrors)[number]> export type WarehouseErrorTag = WarehouseError["_tag"] +/** Errors that can escape compiled or raw query execution into v2 endpoints. */ +export type WarehouseQueryPathError = ErrorInstance<(typeof warehouseQueryHttpErrors)[number]> + +/** Errors produced by classifying a driver/upstream SQL failure. */ +export type WarehouseClassifiedError = ErrorInstance<(typeof classifiedWarehouseHttpErrors)[number]> + /** Errors possible on managed-only routes, which never read per-org routing config. */ export type ManagedWarehouseError = ErrorInstance<(typeof managedWarehouseHttpErrors)[number]> -export type WarehouseRouteError = ErrorInstance<(typeof warehouseRouteHttpErrors)[number]> +/** Errors possible on ordinary tenant reads. */ +export type WarehouseReadError = ErrorInstance<(typeof warehouseReadHttpErrors)[number]> + +export type WarehouseSettingsRouteError = ErrorInstance<(typeof warehouseSettingsRouteHttpErrors)[number]> +export type WarehouseTokenRouteError = ErrorInstance<(typeof warehouseTokenRouteHttpErrors)[number]> +export type WarehouseRouteError = WarehouseSettingsRouteError | WarehouseTokenRouteError /** Exact tags derived from the class tuple for tag-based consumers. */ export const warehouseErrorTags = warehouseHttpErrors.map( (errorClass) => publicHttpErrorDefinitionFor(errorClass).tag, ) as ReadonlyArray + +/** Exact tags for ordinary reads, derived from the same classes as the union and OpenAPI schemas. */ +export const warehouseReadErrorTags = warehouseReadHttpErrors.map( + (errorClass) => publicHttpErrorDefinitionFor(errorClass).tag, +) as ReadonlyArray diff --git a/packages/domain/src/setup-audit.ts b/packages/domain/src/setup-audit.ts index 11f61838a..f240a8f04 100644 --- a/packages/domain/src/setup-audit.ts +++ b/packages/domain/src/setup-audit.ts @@ -20,7 +20,7 @@ import { Schema } from "effect" import { HttpTaggedError } from "./http/error-policy" export class SetupAuditUnavailableError extends HttpTaggedError()( - "@maple/setup-audit/SetupAuditUnavailableError", + "@maple/http/errors/SetupAuditUnavailableError", { message: Schema.String, operation: Schema.String, diff --git a/packages/query-engine/src/execution/errors.ts b/packages/query-engine/src/execution/errors.ts index 23e57dd65..0de7320b6 100644 --- a/packages/query-engine/src/execution/errors.ts +++ b/packages/query-engine/src/execution/errors.ts @@ -2,16 +2,15 @@ import { WarehouseAuthError, WarehouseClientError, WarehouseConfigError, - WarehouseConfigLookupError, WarehouseMalformedQueryError, WarehouseQueryError, WarehouseQuotaExceededError, - type WarehouseResultDecodeError, WarehouseSchemaDriftError, WarehouseUpstreamError, - type WarehouseError, - type WarehouseRouteError, - type WarehouseValidationError, + type WarehouseClassifiedError as DomainWarehouseClassifiedError, + type WarehouseReadError, + type WarehouseSettingsRouteError, + type WarehouseTokenRouteError, } from "@maple/domain/http" import { detectQuotaSetting } from "../profiles" @@ -40,16 +39,16 @@ const extractUpstreamStatus = (message: string): number | undefined => { * Every warehouse error `mapWarehouseError` can produce. Precondition and row * decode failures are raised elsewhere in the executor, so they are absent. */ -export type WarehouseClassifiedError = Exclude< - WarehouseError, - WarehouseRouteError | WarehouseValidationError | WarehouseResultDecodeError -> +export type WarehouseClassifiedError = DomainWarehouseClassifiedError -/** Failures while routing or executing SQL, before decoding a declared row schema. */ -export type WarehouseExecutionError = WarehouseClassifiedError | WarehouseRouteError +/** Failures while resolving settings or executing an ordinary read. */ +export type WarehouseReadExecutionError = WarehouseClassifiedError | WarehouseSettingsRouteError + +/** Raw SQL adds org-token failures to the normal read execution set. */ +export type WarehouseExecutionError = WarehouseReadExecutionError | WarehouseTokenRouteError /** SQL execution plus the result-schema failure unique to compiled queries. */ -export type WarehouseCompiledQueryError = WarehouseExecutionError | WarehouseResultDecodeError +export type WarehouseCompiledQueryError = WarehouseReadError type ClickHouseErrorDetails = { readonly message: string diff --git a/packages/query-engine/src/execution/executor.test.ts b/packages/query-engine/src/execution/executor.test.ts index 73c5ebc8b..be516693a 100644 --- a/packages/query-engine/src/execution/executor.test.ts +++ b/packages/query-engine/src/execution/executor.test.ts @@ -344,6 +344,7 @@ describe("makeWarehouseExecutor compiled-query defaults", () => { .compiledQuery(tenant, withSchema, { context: "serviceOverview" }) .pipe(Effect.flip) assert.strictEqual(error._tag, "@maple/http/errors/WarehouseResultDecodeError") + if (error._tag !== "@maple/http/errors/WarehouseResultDecodeError") return // The real query identity, not the old constant "compiledQuery". assert.strictEqual(error.pipeName, "serviceOverview") }), diff --git a/packages/query-engine/src/execution/executor.ts b/packages/query-engine/src/execution/executor.ts index 120c51a5b..f8f14a727 100644 --- a/packages/query-engine/src/execution/executor.ts +++ b/packages/query-engine/src/execution/executor.ts @@ -6,6 +6,7 @@ import { type WarehouseQueryRequest, WarehouseQueryResponse, WarehouseResultDecodeError, + WarehouseScopeError, WarehouseUpstreamError, WarehouseValidationError, } from "@maple/domain/http" @@ -18,8 +19,13 @@ import { resolveSettings, stripTinybirdRestrictedSettings, } from "../profiles" -import { mapWarehouseError, toWarehouseQueryError } from "./errors" -import { WarehouseResponseLimitError } from "./response-limits" +import { + mapWarehouseError, + toWarehouseQueryError, + type WarehouseExecutionError, + type WarehouseReadExecutionError, +} from "./errors" +import { WarehouseResponseLimitError, type WarehouseResponseLimits } from "./response-limits" import { SQL_LOG_MAX, SQL_TRACE_MAX, @@ -91,6 +97,10 @@ interface CachedCapabilities { readonly expiresAt: number } +type TrustedSqlError = WarehouseReadExecutionError +type BoundedTrustedSqlError = TrustedSqlError | WarehouseResponseLimitError +type RawSqlError = WarehouseExecutionError | RawSqlValidationError + const sqlClientCacheKey = (config: ResolvedWarehouseConfig): string => config.kind === "tinybird" ? `tinybird:${config.host}:${config.token}` @@ -280,7 +290,7 @@ WHERE name = 'enable_full_text_index'`, tenant: ExecutionTenant, options?: SqlQueryOptions, ) { - const purpose: RoutePurpose = options?.route === "ingest" ? "ingest" : "read" + const purpose: "read" | "ingest" = options?.route === "ingest" ? "ingest" : "read" const resolved = yield* deps.resolveRoute(tenant, purpose, "capabilities") // Backends running the schema we deploy answer from the generated @@ -357,12 +367,13 @@ WHERE name = 'enable_full_text_index'`, // Client-kind is load-bearing: the service-map DB-edge MV // (service_map_db_edges_hourly_mv) only counts SpanKind IN ('Client','Producer'). - const executeSqlOnce = Effect.fn("WarehouseQueryService.executeSql", { kind: "client" })(function* ( + const executeSqlOnceEffect = Effect.fn("WarehouseQueryService.executeSql", { kind: "client" })(function* ( tenant: ExecutionTenant, sql: string, pipe: string, options?: SqlQueryOptions, execution: "trusted" | "raw" = "trusted", + responseLimits?: WarehouseResponseLimits, ) { const startedAtMs = yield* Clock.currentTimeMillis yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) @@ -450,13 +461,19 @@ WHERE name = 'enable_full_text_index'`, // A caller-supplied budget wins: a trusted query that knows its own response // can blow the Worker heap (session replay's rrweb payloads) opts in // explicitly. Raw SQL keeps its standing caps. - const responseLimits = - options?.responseLimits ?? + const effectiveResponseLimits = + responseLimits ?? (execution === "raw" ? { maxRows: MAX_RAW_SQL_RESULT_ROWS, maxBytes: MAX_RAW_SQL_RESULT_BYTES } : undefined) const queryAttempt = Effect.tryPromise({ - try: () => client.sql(finalSql, responseLimits === undefined ? undefined : { responseLimits }), + try: () => + client.sql( + finalSql, + effectiveResponseLimits === undefined + ? undefined + : { responseLimits: effectiveResponseLimits }, + ), catch: (error) => error instanceof WarehouseResponseLimitError ? // Only raw SQL restates this as a validation error — there the @@ -546,6 +563,49 @@ WHERE name = 'enable_full_text_index'`, return result.data }) + function executeSqlOnce( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted", + responseLimits?: undefined, + ): Effect.Effect>, TrustedSqlError> + function executeSqlOnce( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted", + responseLimits: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError> + function executeSqlOnce( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "raw", + responseLimits?: undefined, + ): Effect.Effect>, RawSqlError> + function executeSqlOnce( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted" | "raw", + responseLimits?: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError | RawSqlError> + function executeSqlOnce( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted" | "raw", + responseLimits?: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError | RawSqlError> { + return executeSqlOnceEffect(tenant, sql, pipe, options, execution, responseLimits) + } + /** * `executeSqlOnce`, plus a single self-heal retry when the warehouse rejects * our credentials. @@ -568,14 +628,15 @@ WHERE name = 'enable_full_text_index'`, * flagged operation. A non-zero rate here that doesn't line up with a * rotation means the staleness window is too long. */ - const executeSql = ( + const executeSqlEffect = ( tenant: ExecutionTenant, sql: string, pipe: string, options?: SqlQueryOptions, execution: "trusted" | "raw" = "trusted", + responseLimits?: WarehouseResponseLimits, ) => { - const attempt = executeSqlOnce(tenant, sql, pipe, options, execution) + const attempt = executeSqlOnce(tenant, sql, pipe, options, execution, responseLimits) const invalidateRoute = deps.invalidateRoute if (invalidateRoute === undefined) return attempt return attempt.pipe( @@ -584,7 +645,9 @@ WHERE name = 'enable_full_text_index'`, Effect.flatMap((invalidated) => invalidated ? Effect.annotateCurrentSpan("warehouse.config.auth_retry", true).pipe( - Effect.andThen(executeSqlOnce(tenant, sql, pipe, options, execution)), + Effect.andThen( + executeSqlOnce(tenant, sql, pipe, options, execution, responseLimits), + ), ) : Effect.fail(error), ), @@ -593,17 +656,65 @@ WHERE name = 'enable_full_text_index'`, ) } + function executeSql( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted", + responseLimits?: undefined, + ): Effect.Effect>, TrustedSqlError> + function executeSql( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted", + responseLimits: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError> + function executeSql( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "raw", + responseLimits?: undefined, + ): Effect.Effect>, RawSqlError> + function executeSql( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted" | "raw", + responseLimits?: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError | RawSqlError> + function executeSql( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + execution: "trusted" | "raw", + responseLimits?: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError | RawSqlError> { + return executeSqlEffect(tenant, sql, pipe, options, execution, responseLimits) + } + const executeTrustedSql = ( tenant: ExecutionTenant, sql: string, pipe: string, options?: SqlQueryOptions, - ) => - executeSql(tenant, sql, pipe, options, "trusted").pipe( - // A trusted driver call never receives response limits, so this branch is - // an impossible implementation defect rather than part of its error API. - Effect.catchTag("@maple/http/errors/RawSqlValidationError", Effect.die), - ) + ): Effect.Effect>, TrustedSqlError> => + executeSql(tenant, sql, pipe, options, "trusted") + + const executeTrustedSqlBounded = ( + tenant: ExecutionTenant, + sql: string, + pipe: string, + options: SqlQueryOptions | undefined, + responseLimits: WarehouseResponseLimits, + ): Effect.Effect>, BoundedTrustedSqlError> => + executeSql(tenant, sql, pipe, options, "trusted", responseLimits) const withCapabilitySettings = ( capabilities: WarehouseCapabilities | undefined, @@ -625,29 +736,6 @@ WHERE name = 'enable_full_text_index'`, "maple.query.plan.full_text_setting": capabilities.fullTextSearchSetting, }) - // `executeSql` can raise WarehouseResponseLimitError, but only when a caller - // passed `responseLimits`. `compiledQueryBounded` is the one entry point that - // does; every other one strips the option (`withoutResponseLimits`) and then - // narrows the error away (`unbounded`). Types can't see that the strip makes - // the error unreachable, hence the explicit pair — and hence `Effect.die` - // rather than a mapping: if it ever fires, a caller reached the limit path - // without declaring it, which is a bug here and not a condition to handle. - - const withoutResponseLimits = (options?: SqlQueryOptions): SqlQueryOptions | undefined => { - if (options?.responseLimits === undefined) return options - const { responseLimits: _optedOut, ...rest } = options - return rest - } - - const unbounded = ( - effect: Effect.Effect, - ): Effect.Effect => - Effect.catchIf( - effect, - (error): error is WarehouseResponseLimitError => error instanceof WarehouseResponseLimitError, - (error) => Effect.die(error), - ) - const query = Effect.fn("WarehouseQueryService.query")(function* ( tenant: ExecutionTenant, payload: WarehouseQueryRequest, @@ -657,7 +745,7 @@ WHERE name = 'enable_full_text_index'`, yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) if (!tenant.orgId || tenant.orgId.trim() === "") { - return yield* new WarehouseValidationError({ + return yield* new WarehouseScopeError({ pipeName: payload.pipeName, message: "org_id must not be empty", }) @@ -718,30 +806,50 @@ WHERE name = 'enable_full_text_index'`, * so scoping is now decided by the builder when it sees an `OrgId` predicate * in a top-level WHERE, and this only enforces the decision. */ - const executeScopedSql = Effect.fn("WarehouseQueryService.executeScopedSql")(function* ( + const validateTenantScope = Effect.fn("WarehouseQueryService.validateTenantScope")(function* ( tenant: ExecutionTenant, - sql: string, tenantScope: TenantScope, context: string, - options?: SqlQueryOptions, ) { if (!tenant.orgId || tenant.orgId.trim() === "") { - return yield* new WarehouseValidationError({ + return yield* new WarehouseScopeError({ pipeName: context, message: `org_id must not be empty (${context})`, }) } if (tenantScope !== "org") { - return yield* new WarehouseValidationError({ + return yield* new WarehouseScopeError({ pipeName: context, message: `compiled query is not tenant-scoped: no top-level OrgId predicate (${context}). ` + `Deliberate cross-tenant reads must declare .crossOrg() and run through crossOrgQuery.`, }) } + }) + + const executeScopedSql = Effect.fn("WarehouseQueryService.executeScopedSql")(function* ( + tenant: ExecutionTenant, + sql: string, + tenantScope: TenantScope, + context: string, + options?: SqlQueryOptions, + ) { + yield* validateTenantScope(tenant, tenantScope, context) return yield* executeTrustedSql(tenant, sql, context, options) }) + const executeScopedSqlBounded = Effect.fn("WarehouseQueryService.executeScopedSqlBounded")(function* ( + tenant: ExecutionTenant, + sql: string, + tenantScope: TenantScope, + context: string, + options: SqlQueryOptions | undefined, + responseLimits: WarehouseResponseLimits, + ) { + yield* validateTenantScope(tenant, tenantScope, context) + return yield* executeTrustedSqlBounded(tenant, sql, context, options, responseLimits) + }) + const rawSqlQuery = Effect.fn("WarehouseQueryService.rawSqlQuery")(function* ( tenant: ExecutionTenant, sql: string, @@ -813,10 +921,7 @@ WHERE name = 'enable_full_text_index'`, tenant: ExecutionTenant, compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), options?: SqlQueryOptions, - ) => - unbounded( - executeCompiledQuery(tenant, compiled, withoutResponseLimits(options)), - )) as WarehouseQueryServiceShape["compiledQuery"] + ) => executeCompiledQuery(tenant, compiled, options)) as WarehouseQueryServiceShape["compiledQuery"] /** * Read with an explicit ceiling on the response we're willing to materialize. @@ -826,19 +931,41 @@ WHERE name = 'enable_full_text_index'`, * the same oversized response. Callers map it to a domain error that tells * the user to ask for less. */ - const compiledQueryBounded = ( + const compiledQueryBounded = Effect.fn("WarehouseQueryService.compiledQueryBounded")(function* ( tenant: ExecutionTenant, compiled: CompiledQuery, options: SqlQueryOptions & { - readonly responseLimits: { readonly maxRows: number; readonly maxBytes: number } + readonly responseLimits: WarehouseResponseLimits }, - ) => executeCompiledQuery(tenant, compiled, options) + ) { + const { responseLimits, ...queryOptions } = options + const normalizedOptions = withDefaultProfile(queryOptions) + const context = normalizedOptions.context ?? "compiledQueryBounded" + const rows = yield* executeScopedSqlBounded( + tenant, + compiled.sql, + compiled.tenantScope, + context, + withCompiledRouting(compiled, normalizedOptions), + responseLimits, + ) + return yield* compiled.decodeRows(rows).pipe( + Effect.mapError( + (error) => + new WarehouseResultDecodeError({ + pipeName: context, + message: error.message, + cause: error, + }), + ), + ) + }) const compiledQueryWithCapabilities = ( tenant: ExecutionTenant, compile: (capabilities: WarehouseCapabilities) => CompiledQuery, options?: SqlQueryOptions, - ) => unbounded(executeCompiledQuery(tenant, compile, withoutResponseLimits(options))) + ) => executeCompiledQuery(tenant, compile, options) /** * Deliberately read across every tenant. @@ -857,7 +984,7 @@ WHERE name = 'enable_full_text_index'`, const options = withDefaultProfile(rawOptions) const context = options.context ?? "crossOrgQuery" if (compiled.tenantScope !== "cross-org") { - return yield* new WarehouseValidationError({ + return yield* new WarehouseScopeError({ pipeName: context, message: `tenant-scoped query routed through crossOrgQuery (${context}). ` + @@ -1016,40 +1143,26 @@ WHERE name = 'enable_full_text_index'`, const asExecutor = (tenant: ExecutionTenant): WarehouseExecutorShape => ({ orgId: tenant.orgId, query: (pipe: WarehouseQueryName, params: Record, options?: SqlQueryOptions) => - unbounded( - query( - tenant, - { pipeName: pipe, params }, - { context: `pipe:${pipe}`, ...withoutResponseLimits(options) }, - ), - ).pipe(Effect.map((response) => ({ data: response.data as unknown as ReadonlyArray }))), + query(tenant, { pipeName: pipe, params }, { context: `pipe:${pipe}`, ...options }).pipe( + Effect.map((response) => ({ data: response.data as unknown as ReadonlyArray })), + ), compiledQuery: (compiled: CompiledQuery, options?: SqlQueryOptions) => compiledQuery(tenant, compiled, { context: "warehouseExecutor.compiledQuery", ...options }), compiledQueryFirst: (compiled: CompiledQuery, options?: SqlQueryOptions) => - unbounded( - compiledQueryFirst(tenant, compiled, { - context: "warehouseExecutor.compiledQueryFirst", - ...withoutResponseLimits(options), - }), - ), + compiledQueryFirst(tenant, compiled, { + context: "warehouseExecutor.compiledQueryFirst", + ...options, + }), }) return { - query: (tenant, payload, options) => - unbounded(query(tenant, payload, withoutResponseLimits(options))), - crossOrgQuery: (tenant, compiled, options) => - unbounded( - crossOrgQuery(tenant, compiled, { - ...withoutResponseLimits(options), - justification: options.justification, - }), - ), - rawSqlQuery: (tenant, sql, options) => unbounded(rawSqlQuery(tenant, sql, options)), + query, + crossOrgQuery: (tenant, compiled, options) => crossOrgQuery(tenant, compiled, options), + rawSqlQuery, compiledQuery, compiledQueryBounded, compiledQueryWithCapabilities, - compiledQueryFirst: (tenant, compiled, options) => - unbounded(compiledQueryFirst(tenant, compiled, withoutResponseLimits(options))), + compiledQueryFirst, // `resolveCapabilities` resolves the route on its way through, so warming // it warms both. `ignore` keeps a failed warm-up invisible — the real // query behind it fails with its own context a moment later. diff --git a/packages/query-engine/src/execution/ports.ts b/packages/query-engine/src/execution/ports.ts index e92a5c1a9..dd1e17891 100644 --- a/packages/query-engine/src/execution/ports.ts +++ b/packages/query-engine/src/execution/ports.ts @@ -3,8 +3,11 @@ import type { OrgId, UserId } from "@maple/domain" import type { RawSqlValidationError, ManagedWarehouseError, + WarehouseConfigError, WarehouseQueryRequest, WarehouseQueryResponse, + WarehouseSettingsRouteError, + WarehouseTokenRouteError, WarehouseValidationError, } from "@maple/domain/http" import type { ResolvedWarehouseConfig } from "./backend" @@ -12,8 +15,8 @@ import type { CompiledQuery } from "../ch" import type { WarehouseCapabilities } from "../capabilities" import type { WarehouseExecutorShape } from "../observability" import type { SqlQueryOptions } from "../profiles" -import type { WarehouseCompiledQueryError, WarehouseExecutionError } from "./errors" -import type { WarehouseResponseLimitError } from "./response-limits" +import type { WarehouseClassifiedError, WarehouseCompiledQueryError, WarehouseExecutionError } from "./errors" +import type { WarehouseResponseLimitError, WarehouseResponseLimits } from "./response-limits" /** The minimal tenant surface the executor reads (org scope + identity for spans). */ export interface ExecutionTenant { @@ -32,17 +35,14 @@ export type { ResolvedWarehouseConfig } from "./backend" */ export type CompiledQueryError = Routing extends "ingest" ? ManagedWarehouseError - : WarehouseCompiledQueryError | WarehouseValidationError + : WarehouseCompiledQueryError /** Minimal client interface — raw SQL execution plus row inserts. */ export interface WarehouseSqlClient { readonly sql: ( sql: string, options?: { - readonly responseLimits?: { - readonly maxRows: number - readonly maxBytes: number - } + readonly responseLimits?: WarehouseResponseLimits }, ) => Promise<{ data: ReadonlyArray> }> readonly insert: (datasource: string, rows: ReadonlyArray) => Promise @@ -59,6 +59,12 @@ export interface WarehouseSqlClient { */ export type RoutePurpose = "read" | "raw" | "ingest" +export type WarehouseTrustedRouteError = WarehouseSettingsRouteError +export type WarehouseRawRouteError = + | WarehouseSettingsRouteError + | WarehouseTokenRouteError + | WarehouseConfigError + /** The host's routing decision: which backend, with which credentials, and why. */ export interface WarehouseRoute { /** @@ -74,6 +80,30 @@ export interface WarehouseRoute { readonly clientCacheKey: string } +export interface WarehouseRouteResolver { + ( + tenant: ExecutionTenant, + purpose: "read", + label: string, + ): Effect.Effect + (tenant: ExecutionTenant, purpose: "ingest", label: string): Effect.Effect + ( + tenant: ExecutionTenant, + purpose: "read" | "ingest", + label: string, + ): Effect.Effect + ( + tenant: ExecutionTenant, + purpose: "raw", + label: string, + ): Effect.Effect + ( + tenant: ExecutionTenant, + purpose: RoutePurpose, + label: string, + ): Effect.Effect +} + /** * The injected dependencies of the warehouse executor. The host app provides * the driver construction (`createClient`) and the routing decision @@ -83,11 +113,7 @@ export interface WarehouseRoute { */ export interface WarehouseExecutorDeps { readonly createClient: (config: ResolvedWarehouseConfig) => WarehouseSqlClient - readonly resolveRoute: ( - tenant: ExecutionTenant, - purpose: RoutePurpose, - label: string, - ) => Effect.Effect + readonly resolveRoute: WarehouseRouteResolver /** * Drop whatever the host caches to answer `resolveRoute` for this tenant, and * report whether that actually invalidated a per-org routing override. @@ -129,7 +155,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, compiled: CompiledQuery, options: SqlQueryOptions & { readonly justification: string }, - ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> + ) => Effect.Effect, WarehouseCompiledQueryError> /** Execute validated user-authored SQL with tenant-scoped credentials and hard response limits. */ readonly rawSqlQuery: ( tenant: ExecutionTenant, @@ -149,7 +175,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, compiled: (capabilities: WarehouseCapabilities) => CompiledQuery, options?: SqlQueryOptions, - ): Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> + ): Effect.Effect, WarehouseCompiledQueryError> } /** * `compiledQuery` with an explicit ceiling on how much of the response we are @@ -163,22 +189,19 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, compiled: CompiledQuery, options: SqlQueryOptions & { - readonly responseLimits: { readonly maxRows: number; readonly maxBytes: number } + readonly responseLimits: WarehouseResponseLimits }, - ) => Effect.Effect< - ReadonlyArray, - WarehouseCompiledQueryError | WarehouseValidationError | WarehouseResponseLimitError - > + ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseResponseLimitError> readonly compiledQueryWithCapabilities: ( tenant: ExecutionTenant, compile: (capabilities: WarehouseCapabilities) => CompiledQuery, options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> + ) => Effect.Effect, WarehouseCompiledQueryError> readonly compiledQueryFirst: ( tenant: ExecutionTenant, compiled: CompiledQuery | ((capabilities: WarehouseCapabilities) => CompiledQuery), options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseCompiledQueryError | WarehouseValidationError> + ) => Effect.Effect, WarehouseCompiledQueryError> /** * Resolve this tenant's route and capabilities once, so a fan-out that * follows finds them memoized instead of each branch deriving them itself. @@ -201,7 +224,7 @@ export interface WarehouseQueryServiceShape { tenant: ExecutionTenant, datasource: string, rows: ReadonlyArray, - ) => Effect.Effect + ) => Effect.Effect /** * Present this service as the package-level `WarehouseExecutor` for a given * tenant — the single managed-warehouse implementation of that interface. diff --git a/packages/query-engine/src/execution/response-limits.ts b/packages/query-engine/src/execution/response-limits.ts index 930270d04..6bcf88462 100644 --- a/packages/query-engine/src/execution/response-limits.ts +++ b/packages/query-engine/src/execution/response-limits.ts @@ -3,6 +3,11 @@ import { Schema } from "effect" export const WarehouseResponseLimitKind = Schema.Literals(["rows", "bytes"]) export type WarehouseResponseLimitKind = Schema.Schema.Type +export interface WarehouseResponseLimits { + readonly maxRows: number + readonly maxBytes: number +} + /** Driver-level abort used before a raw response can be fully buffered. */ export class WarehouseResponseLimitError extends Schema.TaggedError()( "@maple/query-engine/execution/WarehouseResponseLimitError", diff --git a/packages/query-engine/src/profiles/query-profile.ts b/packages/query-engine/src/profiles/query-profile.ts index d48c0a481..09eda34c2 100644 --- a/packages/query-engine/src/profiles/query-profile.ts +++ b/packages/query-engine/src/profiles/query-profile.ts @@ -84,18 +84,6 @@ export type SqlQueryOptions = WarehouseQueryOptions & { * reads of gateway-written data gated on write-readiness). */ route?: "ingest" - /** - * Abort the read once the encoded response crosses these bounds, failing with - * `WarehouseResponseLimitError` instead of buffering the rest. - * - * ClickHouse settings cap what the *warehouse* spends; this caps what *we* - * are willing to materialize in a 128 MB Worker. Set it for queries whose - * result size is driven by user data rather than by the query shape — without - * it an oversized response dies as a platform abort, which the transient - * classifier reads as a flaky upstream and retries. Reach for it via - * `compiledQueryBounded`, which surfaces the error in its signature. - */ - responseLimits?: { readonly maxRows: number; readonly maxBytes: number } } /** diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts index 4893751b8..4e6e22e55 100644 --- a/packages/query-engine/src/runtime/query-engine.ts +++ b/packages/query-engine/src/runtime/query-engine.ts @@ -17,13 +17,13 @@ import { type TimeseriesPoint, } from "@maple/domain/query-engine" import { - QueryEngineExecutionError, QueryEngineTimeoutError, QueryEngineValidationError, MAX_RAW_SQL_ALERT_GROUPS, MAX_RAW_SQL_GROUP_KEY_LENGTH, type RawSqlValidationError, - type WarehouseError, + type WarehouseQueryPathError, + type WarehouseReadError, } from "@maple/domain/http" import type { OrgId } from "@maple/domain" import { Array as Arr, Duration, Effect, Match, Option, Result, Schema } from "effect" @@ -93,18 +93,21 @@ export interface QueryEngineWarehouse { tenant: T, sql: string, options: { readonly profile: QueryProfileName; readonly context: string }, - ) => Effect.Effect>, WarehouseError | RawSqlValidationError> + ) => Effect.Effect< + ReadonlyArray>, + WarehouseQueryPathError | RawSqlValidationError + > readonly compiledQuery: ( tenant: T, compiled: CH.CompiledQuery, options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseError> + ) => Effect.Effect, WarehouseReadError> /** Capability-aware execution; adapters may deliberately compile the baseline plan. */ readonly compiledQueryWithCapabilities: ( tenant: T, compile: (capabilities: WarehouseCapabilities) => CH.CompiledQuery, options?: SqlQueryOptions, - ) => Effect.Effect, WarehouseError> + ) => Effect.Effect, WarehouseReadError> } export interface TimeRangeBounds { @@ -159,10 +162,16 @@ export interface AlertEvaluateRequest { readonly sampleCountStrategy: QueryEngineEvaluateRequest["sampleCountStrategy"] | null } -export type QueryEngineDirectError = QueryEngineExecutionError | QueryEngineTimeoutError | WarehouseError +export type QueryEngineDirectError = QueryEngineTimeoutError | WarehouseReadError export type QueryEngineRouteError = QueryEngineValidationError | QueryEngineDirectError +/** Alert evaluation additionally accepts user-authored raw SQL. */ +export type QueryEngineEvaluationError = + | QueryEngineValidationError + | QueryEngineTimeoutError + | WarehouseQueryPathError + const QUERY_ENGINE_TIMEOUT = Duration.seconds(30) export const withTimeout = (effect: Effect.Effect) => @@ -813,10 +822,10 @@ export const validateEvaluate = Effect.fn("QueryEngineService.validateEvaluate") * is `Effect.tapError`, not a transformation. Named explicitly so call sites * don't read like they're remapping errors. */ -const annotateWarehouseError = ( - effect: Effect.Effect, +const annotateWarehouseError = ( + effect: Effect.Effect, context: string, -): Effect.Effect => +): Effect.Effect => effect.pipe( Effect.tapError((error) => Effect.annotateCurrentSpan({ @@ -1193,10 +1202,7 @@ export const makeQueryEngineExecute = (warehouse: QueryEn tenant: T, request: QueryEngineExecuteRequest, options?: QueryEngineExecuteOptions, - ): Effect.fn.Return< - QueryEngineExecuteResponse, - QueryEngineValidationError | QueryEngineExecutionError | WarehouseError - > { + ): Effect.fn.Return { yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) yield* Effect.annotateCurrentSpan("query.source", request.query.source) yield* Effect.annotateCurrentSpan("query.kind", request.query.kind) @@ -2173,7 +2179,7 @@ const computeRawSqlBuckets = Effect.fnUntraced(function* source: Extract, range: { readonly startTime: string; readonly endTime: string }, ) { - const executeRawSql = makeExecuteRawSql(warehouse) + const executeRawSql = makeExecuteRawSql(warehouse) const granularitySeconds = Math.max(source.windowMinutes * 60, 60) const { rows: rawRows } = yield* executeRawSql(tenant, { @@ -2336,7 +2342,7 @@ export const makeQueryEngineEvaluate = (warehouse: QueryE request: AlertEvaluateRequest, ): Effect.fn.Return< ReadonlyArray, - QueryEngineValidationError | QueryEngineExecutionError | WarehouseError + QueryEngineValidationError | WarehouseQueryPathError > { yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) const bucketSeconds = yield* prepareAlertEvaluation(request) @@ -2365,10 +2371,7 @@ export const makeQueryEngineEvaluateSeries = (warehouse: Effect.fn("QueryEngineService.evaluateSeries")(function* ( tenant: T, request: AlertEvaluateRequest, - ): Effect.fn.Return< - ReadonlyArray, - QueryEngineValidationError | QueryEngineExecutionError | WarehouseError - > { + ): Effect.fn.Return, QueryEngineValidationError | WarehouseQueryPathError> { yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) const bucketSeconds = yield* prepareAlertEvaluation(request) From 71c0066ca00d137958d590b5855fc736670a66e9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 13 Aug 2026 23:54:57 +0200 Subject: [PATCH 4/4] fix(ci): keep alchemy error types self-contained --- .../warehouse/WarehouseQueryService.ts | 134 +++++++++--------- packages/alchemy-maple/src/errors.ts | 16 +-- 2 files changed, 70 insertions(+), 80 deletions(-) diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.ts b/apps/api/src/services/warehouse/WarehouseQueryService.ts index de5dc2f07..64b8da475 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.ts @@ -327,80 +327,82 @@ export class WarehouseQueryService extends Context.Service< * gateway); a shared vanilla ClickHouse credential has no DB-enforced OrgId * scope, so raw SQL there is allowed only in single-org self-hosted mode. */ - const resolveRouteEffect = Effect.fn("WarehouseQueryService.resolveRoute")( - function* (tenant, purpose, label) { - yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) - yield* Effect.annotateCurrentSpan("warehouse.route", purpose) + const resolveRouteEffect = Effect.fn("WarehouseQueryService.resolveRoute")(function* ( + tenant: ExecutionTenant, + purpose: RoutePurpose, + label: string, + ) { + yield* Effect.annotateCurrentSpan("orgId", tenant.orgId) + yield* Effect.annotateCurrentSpan("warehouse.route", purpose) - if (purpose === "ingest") { - // Legacy attrs, dual-emitted until dashboards move to `warehouse.*`. - yield* Effect.annotateCurrentSpan("clientSource", "managed") - yield* Effect.annotateCurrentSpan("query.routing", "ingest") - yield* Effect.annotateCurrentSpan("db.client", "tinybird-sdk") - return { - source: "managed" as const, - config: { - kind: "tinybird" as const, - host: env.TINYBIRD_HOST, - token: Redacted.value(env.TINYBIRD_TOKEN), - }, - clientCacheKey: "write:managed", - } + if (purpose === "ingest") { + // Legacy attrs, dual-emitted until dashboards move to `warehouse.*`. + yield* Effect.annotateCurrentSpan("clientSource", "managed") + yield* Effect.annotateCurrentSpan("query.routing", "ingest") + yield* Effect.annotateCurrentSpan("db.client", "tinybird-sdk") + return { + source: "managed" as const, + config: { + kind: "tinybird" as const, + host: env.TINYBIRD_HOST, + token: Redacted.value(env.TINYBIRD_TOKEN), + }, + clientCacheKey: "write:managed", } + } - // A per-org BYO ClickHouse row (`org_clickhouse_settings`) overrides the - // managed upstream for that org's reads AND raw SQL (the credentials are - // already tenant-isolated). - const override = yield* orgClickHouseSettings.resolveRuntimeConfig(tenant.orgId) - if (Option.isSome(override)) { - yield* Effect.annotateCurrentSpan("clientSource", "org_override") - yield* Effect.annotateCurrentSpan("db.client", "clickhouse") - return { - source: "org-byo" as const, - config: { - kind: "clickhouse" as const, - url: override.value.url, - username: override.value.user, - password: override.value.password, - database: override.value.database, - }, - clientCacheKey: purpose === "raw" ? `raw:${tenant.orgId}` : `read:${tenant.orgId}`, - } + // A per-org BYO ClickHouse row (`org_clickhouse_settings`) overrides the + // managed upstream for that org's reads AND raw SQL (the credentials are + // already tenant-isolated). + const override = yield* orgClickHouseSettings.resolveRuntimeConfig(tenant.orgId) + if (Option.isSome(override)) { + yield* Effect.annotateCurrentSpan("clientSource", "org_override") + yield* Effect.annotateCurrentSpan("db.client", "clickhouse") + return { + source: "org-byo" as const, + config: { + kind: "clickhouse" as const, + url: override.value.url, + username: override.value.user, + password: override.value.password, + database: override.value.database, + }, + clientCacheKey: purpose === "raw" ? `raw:${tenant.orgId}` : `read:${tenant.orgId}`, } + } - yield* Effect.annotateCurrentSpan("clientSource", "managed") - const managed = yield* resolveManagedConfig() - if (purpose === "read") return { source: "managed" as const, ...managed } + yield* Effect.annotateCurrentSpan("clientSource", "managed") + const managed = yield* resolveManagedConfig() + if (purpose === "read") return { source: "managed" as const, ...managed } - // Raw SQL on the shared warehouse needs tenant isolation. Shared Tinybird - // is isolated with a datasource-scoped JWT; the same token works through - // both the SDK and Tinybird's ClickHouse-compatible gateway. - const clientCacheKey = `raw:${tenant.orgId}` - if (managed.config.kind === "tinybird" || managed.config.kind === "tinybird-gateway") { - const jwt = yield* orgTokens.getOrgReadToken(tenant.orgId) - yield* Effect.annotateCurrentSpan("maple.tinybird.token.scope", "org_jwt") - return { - source: "org-jwt" as const, - config: - managed.config.kind === "tinybird" - ? { ...managed.config, token: jwt } - : { ...managed.config, password: jwt }, - clientCacheKey, - } + // Raw SQL on the shared warehouse needs tenant isolation. Shared Tinybird + // is isolated with a datasource-scoped JWT; the same token works through + // both the SDK and Tinybird's ClickHouse-compatible gateway. + const clientCacheKey = `raw:${tenant.orgId}` + if (managed.config.kind === "tinybird" || managed.config.kind === "tinybird-gateway") { + const jwt = yield* orgTokens.getOrgReadToken(tenant.orgId) + yield* Effect.annotateCurrentSpan("maple.tinybird.token.scope", "org_jwt") + return { + source: "org-jwt" as const, + config: + managed.config.kind === "tinybird" + ? { ...managed.config, token: jwt } + : { ...managed.config, password: jwt }, + clientCacheKey, } + } - // A shared vanilla ClickHouse credential has no database-enforced OrgId - // scope. It is safe only in Maple's single-org self-hosted deployment mode. - if (env.MAPLE_AUTH_MODE.toLowerCase() !== "self_hosted") { - return yield* new WarehouseConfigError({ - pipeName: label, - message: - "Raw SQL on managed vanilla ClickHouse is available only in single-org self-hosted mode", - }) - } - return { source: "managed" as const, config: managed.config, clientCacheKey } - }, - ) + // A shared vanilla ClickHouse credential has no database-enforced OrgId + // scope. It is safe only in Maple's single-org self-hosted deployment mode. + if (env.MAPLE_AUTH_MODE.toLowerCase() !== "self_hosted") { + return yield* new WarehouseConfigError({ + pipeName: label, + message: + "Raw SQL on managed vanilla ClickHouse is available only in single-org self-hosted mode", + }) + } + return { source: "managed" as const, config: managed.config, clientCacheKey } + }) function resolveRoute( tenant: ExecutionTenant, diff --git a/packages/alchemy-maple/src/errors.ts b/packages/alchemy-maple/src/errors.ts index aeafb8c3c..f721c32d6 100644 --- a/packages/alchemy-maple/src/errors.ts +++ b/packages/alchemy-maple/src/errors.ts @@ -1,11 +1,4 @@ import { Schema } from "effect" -import type { - AlertDestinationNotFoundError, - AlertRuleNotFoundError, - ApiKeyNotFoundError, - DashboardNotFoundError, - PublicHttpErrorTag, -} from "@maple/domain/http" export const MaplePublicErrorType = Schema.Literals([ "invalid_request_error", @@ -31,7 +24,7 @@ export const MapleErrorRecovery = Schema.Literals([ /** Public HTTP tags are disjoint from this package's client-side error tags. */ export const MapleHttpErrorTagSchema = Schema.TemplateLiteral(["@maple/http/", Schema.String]) -export type MapleHttpErrorTag = PublicHttpErrorTag +export type MapleHttpErrorTag = Schema.Schema.Type /** Stable tags used for provider lifecycle decisions. */ export const MapleErrorTags = { @@ -39,12 +32,7 @@ export const MapleErrorTags = { dashboardNotFound: "@maple/http/errors/DashboardNotFoundError", alertRuleNotFound: "@maple/http/errors/AlertRuleNotFoundError", alertDestinationNotFound: "@maple/http/errors/AlertDestinationNotFoundError", -} as const satisfies { - readonly apiKeyNotFound: ApiKeyNotFoundError["_tag"] - readonly dashboardNotFound: DashboardNotFoundError["_tag"] - readonly alertRuleNotFound: AlertRuleNotFoundError["_tag"] - readonly alertDestinationNotFound: AlertDestinationNotFoundError["_tag"] -} +} as const satisfies Record /** Published mirror of Maple's canonical v2 error body. Kept honest by contract tests. */ export const MaplePublicErrorBodySchema = Schema.Struct({