Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/landing/src/content/docs/sdks/effect-cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ In addition to the [common options](/docs/sdks/effect#configuration-reference),

`anticipatedErrorIdentifiers` keeps expected rejections (a 404, a 401) visible as traces without counting them as errors — matching how Maple's ingest gateway treats 4xx. A span still exports as `Error` if its cause contains any defect.

An error that crossed an HTTP boundary is a decoded body rather than the class that raised it, so a failure shaped `{ error: { _tag } }` — the envelope convention many APIs use — is matched on the body's `_tag`. Client-side spans classify the same as the server-side ones they mirror, with no separate identifiers to configure.

`dropSpanNames` is useful for suppressing protocol-level chatter — e.g. `["McpServer/Notifications."]` to drop MCP notification spam without dropping legitimate handler spans.

## Endpoint Resolution
Expand Down
72 changes: 70 additions & 2 deletions apps/web/src/api/warehouse/effect-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { describe, expect, it } from "vitest"
import { beforeEach, describe, expect, it } from "vitest"
import { WarehouseQuotaExceededError } from "@maple/domain/http"
import { WarehouseDecodeError, WarehouseQueryError, normalizeWarehouseError } from "./effect-utils"
import { HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { apiBaseUrl } from "@/lib/services/common/api-base-url"
import {
noteReachable,
noteUnreachable,
originOf,
PEER_OUTAGE_GRACE_MS,
} from "@/lib/services/common/peer-reachability"
import {
WarehouseDecodeError,
WarehouseQueryError,
WarehouseUnreachableError,
normalizeWarehouseError,
} from "./effect-utils"

describe("normalizeWarehouseError", () => {
it("preserves a v2 error envelope", () => {
Expand Down Expand Up @@ -37,3 +50,58 @@ describe("normalizeWarehouseError", () => {
expect(normalizeWarehouseError("query", normalized)).toBe(normalized)
})
})

const transportFailure = () =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: HttpClientRequest.post(`${apiBaseUrl}/internal/query-engine/execute-batch`),
cause: new TypeError("Failed to fetch"),
}),
})

/**
* A dropped connection is the network, not the warehouse — but only while it is
* still short enough to be a blip. Past the grace window the API is genuinely
* unreachable and the failure reports exactly as it did before.
*/
describe("transport failures during a connectivity blip", () => {
const origin = originOf(apiBaseUrl)

beforeEach(() => {
noteReachable(origin)
})

it("classifies a transport failure as unreachable while the origin is blipping", () => {
noteUnreachable(origin, Date.now())

const normalized = normalizeWarehouseError("query", transportFailure())

expect(normalized).toBeInstanceOf(WarehouseUnreachableError)
if (!(normalized instanceof WarehouseUnreachableError)) throw new Error("expected unreachable")
// The user is told the API is unreachable and that it retries — never that
// their query needs fixing.
expect(normalized.error.title).toBe("Cannot reach Maple API")
expect(normalized.error.retryable).toBe(true)
})

it("unwraps a transport failure nested behind another error", () => {
noteUnreachable(origin, Date.now())
const wrapped = new Error("Warehouse batch request failed", { cause: transportFailure() })

expect(normalizeWarehouseError("query", wrapped)).toBeInstanceOf(WarehouseUnreachableError)
})

it("reports a transport failure once the outage outlasts the grace window", () => {
noteUnreachable(origin, Date.now() - PEER_OUTAGE_GRACE_MS - 1)

expect(normalizeWarehouseError("query", transportFailure())).toBeInstanceOf(WarehouseQueryError)
})

it("leaves a failure the API actually answered alone, blip or not", () => {
noteUnreachable(origin, Date.now())

expect(normalizeWarehouseError("query", new Error("decode failed"))).toBeInstanceOf(
WarehouseQueryError,
)
})
})
95 changes: 83 additions & 12 deletions apps/web/src/api/warehouse/effect-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type AttributeValueItem,
} from "@maple/query-engine"
import { Effect, Layer, Schema } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { PublicHttpErrorBodySchema, type AnyPublicHttpErrorBody } from "@maple/domain/http"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
Expand All @@ -18,7 +19,9 @@ import {
mapleInternalClientLayer,
mapleRuntime,
} from "@/lib/registry"
import { makeClientErrorBody } from "@/lib/error-messages"
import { makeClientErrorBody, NetworkErrorBody } from "@/lib/error-messages"
import { apiBaseUrl } from "@/lib/services/common/api-base-url"
import { isBlipping, originOf } from "@/lib/services/common/peer-reachability"
import { makeExecuteBatcher } from "./execute-batcher"

export const WarehouseDateTimeString = TinybirdDateTime
Expand Down Expand Up @@ -94,11 +97,39 @@ export class WarehouseInvalidInputError extends Schema.TaggedError<WarehouseInva
})
}

/**
* The browser could not reach the API at all, and has not been able to for less
* than `PEER_OUTAGE_GRACE_MS`.
*
* Its own tag rather than a flavour of `WarehouseQueryError` because it is not a
* fault of Maple's: `otel-layer.ts` anticipates this tag, so the spans it fails
* record `Ok` and no exception event is fingerprinted for a wifi blip. A failure
* still arriving after the grace window is a real outage and stays a
* `WarehouseQueryError`, which reports as before.
*
* It carries the same public body a bare transport failure already resolved to
* through `displayError`, so the UI copy is unchanged — "Cannot reach Maple
* API", retryable. That copy is the point: the path this replaces re-raised a
* dropped connection as `WarehouseInvalidInputError`, telling the user their
* query was invalid and to fix the request.
*/
export class WarehouseUnreachableError extends Schema.TaggedError<WarehouseUnreachableError>()(
"@maple/web/errors/WarehouseUnreachableError",
{
operation: Schema.String,
message: Schema.String,
cause: Schema.optional(Schema.Unknown),
},
) {
readonly error = NetworkErrorBody
}

export type WarehouseApiError =
| WarehouseDecodeError
| WarehouseQueryError
| WarehouseTransformError
| WarehouseInvalidInputError
| WarehouseUnreachableError

/** Backend failures are either a public body or an error carrying that same body. */
export type BackendError = AnyPublicHttpErrorBody | { readonly error: AnyPublicHttpErrorBody }
Expand All @@ -124,17 +155,43 @@ export const isWarehouseApiError = (cause: unknown): cause is WarehouseApiError
typeof cause._tag === "string" &&
cause._tag.startsWith("@maple/web/errors/Warehouse")

/**
* True when `cause` is a request that never got a response — the browser could
* not reach the API — as opposed to one the API answered with a failure.
*
* Walks the cause chain because the transport failure is usually nested: the
* batcher rejects its promise with an `HttpClientError`, which `Effect.tryPromise`
* then wraps. Bounded at the same depth `displayError` uses.
*/
export const isTransportFailure = (cause: unknown, depth = 0): boolean => {
if (HttpClientError.isHttpClientError(cause)) return cause.reason._tag === "TransportError"
if (depth >= 4) return false
const nested =
typeof cause === "object" && cause !== null && "cause" in cause
? (cause as { readonly cause: unknown }).cause
: undefined
return nested === undefined || nested === cause ? false : isTransportFailure(nested, depth + 1)
}

/**
* A transport failure while the API is inside its grace window is the network
* dropping, not the warehouse failing — see `peer-reachability.ts`. Once the run
* outlasts the window it is a real outage and stays a `WarehouseQueryError`, so
* an API that is genuinely down still reports.
*/
export const isNetworkBlip = (cause: unknown): boolean =>
isTransportFailure(cause) && isBlipping(originOf(apiBaseUrl), Date.now())

/** Preserve known errors; introduce a local query error only for an unstructured failure. */
export const normalizeWarehouseError = (
operation: string,
cause: unknown,
): WarehouseApiError | BackendError => {
if (isBackendError(cause) || isWarehouseApiError(cause)) return cause
return new WarehouseQueryError({
operation,
message: toMessage(cause, `Warehouse query failed for ${operation}`),
cause,
})
const message = toMessage(cause, `Warehouse query failed for ${operation}`)
return isNetworkBlip(cause)
? new WarehouseUnreachableError({ operation, message, cause })
: new WarehouseQueryError({ operation, message, cause })
}

export function decodeInput<S extends Schema.Top & { readonly DecodingServices: never }>(
Expand Down Expand Up @@ -195,6 +252,25 @@ export function runWarehouseQueryV2<A, E>(
)
}

/**
* Raise a query-set failure whose per-query causes the runner has already
* flattened to strings.
*
* `runQuerySetWindow` catches each executor failure into `result.error` text and
* re-raises the batch as `QuerySetNoDataError`, so the type is gone by the time
* an adapter sees it and only the live reachability clock still knows whether
* the API answered. While it says the API is unreachable this is that, not a bad
* query — which is what the user was previously told to fix.
*/
export function querySetFailure(
operation: string,
message: string,
): Effect.Effect<never, WarehouseInvalidInputError | WarehouseUnreachableError> {
return isBlipping(originOf(apiBaseUrl), Date.now())
? Effect.fail(new WarehouseUnreachableError({ operation, message }))
: invalidWarehouseInput(operation, message)
}

export function invalidWarehouseInput(
operation: string,
message: string,
Expand Down Expand Up @@ -229,12 +305,7 @@ const executeQueryEngineEffect = Effect.fn("QueryEngine.execute")(function* (
) {
return yield* Effect.tryPromise({
try: () => executeBatcher.enqueue(payload),
catch: (cause) =>
new WarehouseQueryError({
operation: "QueryEngine.executeBatch",
message: toMessage(cause, "Warehouse batch request failed"),
cause,
}),
catch: (cause) => normalizeWarehouseError("QueryEngine.executeBatch", cause),
})
})

Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/api/warehouse/query-builder-breakdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Effect, Exit } from "effect"
import { describe, expect, it } from "vitest"

import { QuerySetNoDataError } from "@maple/query-engine/query-set"
import * as breakdownModule from "@/api/warehouse/query-builder-breakdown"

// `mergeBreakdownResults` moved to `@maple/query-engine/query-set`, where the
Expand All @@ -13,3 +15,37 @@ describe("query-builder breakdown units", () => {
expect(breakdownModule.__testables).not.toHaveProperty("normalizeErrorRatePoints")
})
})

/**
* The timeseries adapter stopped failing on an empty window — an empty window is
* a normal answer — but the breakdown adapter beside it was left raising
* `WarehouseInvalidInputError`, which marked the span `Error` and billed an
* exception event for a panel the user simply has no data for.
*/
describe("empty window", () => {
it("answers with zero rows when every query ran and none matched", () => {
const exit = Effect.runSyncExit(
breakdownModule.__testables.onNoData(
new QuerySetNoDataError({
message: "No breakdown data found in selected time range",
details: [],
}),
),
)

expect(exit).toStrictEqual(Exit.succeed({ rows: [], diagnostics: [] }))
})

it("still fails when a query itself failed", () => {
const exit = Effect.runSyncExit(
breakdownModule.__testables.onNoData(
new QuerySetNoDataError({
message: "Unknown column 'nope'",
details: ["Unknown column 'nope'"],
}),
),
)

expect(Exit.isFailure(exit)).toBe(true)
})
})
35 changes: 30 additions & 5 deletions apps/web/src/api/warehouse/query-builder-breakdown.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { Effect, Schema } from "effect"
import { QueryBuilderQueryDraftSchema } from "@maple/domain/http"
import { runBreakdownQuerySet } from "@maple/query-engine/query-set"
import { decodeInput, invalidWarehouseInput } from "@/api/warehouse/effect-utils"
import {
runBreakdownQuerySet,
type BreakdownQuerySetResult,
type QuerySetNoDataError,
} from "@maple/query-engine/query-set"
import {
decodeInput,
invalidWarehouseInput,
querySetFailure,
type WarehouseInvalidInputError,
type WarehouseUnreachableError,
} from "@/api/warehouse/effect-utils"
import { makeWarehouseExecutor } from "@/api/warehouse/query-set-executor"

const executor = makeWarehouseExecutor("queryEngine.breakdownQuery")
Expand All @@ -23,6 +33,22 @@ const QueryBuilderBreakdownInputSchema = Schema.Struct({

export type QueryBuilderBreakdownInput = Schema.Schema.Type<typeof QueryBuilderBreakdownInputSchema>

/**
* An empty window is a normal answer, not a failure.
*
* The same reasoning as `getQueryBuilderTimeseries`, which this path was left
* out of: failing here marked the span `Error` and billed an exception event for
* a panel the user simply has no data for, and `use-widget-data` already renders
* an empty envelope as the muted "No data" frame. A populated `details` carries
* a real per-query failure and stays an error.
*/
const onNoData = (
error: QuerySetNoDataError,
): Effect.Effect<BreakdownQuerySetResult, WarehouseInvalidInputError | WarehouseUnreachableError> =>
error.details.length === 0
? Effect.succeed({ rows: [], diagnostics: [] })
: querySetFailure("getQueryBuilderBreakdown", error.message)

export function getQueryBuilderBreakdown({ data }: { data: QueryBuilderBreakdownInput }) {
return getQueryBuilderBreakdownEffect({ data })
}
Expand All @@ -43,8 +69,7 @@ const getQueryBuilderBreakdownEffect = Effect.fn("QueryEngine.getQueryBuilderBre
Effect.catchTags({
"@maple/query-engine/query-set/QuerySetInputError": (error) =>
invalidWarehouseInput("getQueryBuilderBreakdown", error.message),
"@maple/query-engine/query-set/QuerySetNoDataError": (error) =>
invalidWarehouseInput("getQueryBuilderBreakdown", error.message),
"@maple/query-engine/query-set/QuerySetNoDataError": onNoData,
}),
)

Expand All @@ -54,4 +79,4 @@ const getQueryBuilderBreakdownEffect = Effect.fn("QueryEngine.getQueryBuilderBre
// The merge and the per-query execution moved to `@maple/query-engine/query-set`
// and are tested there; what stays worth asserting here is that this module adds
// no rescaling of its own on the way out.
export const __testables = {}
export const __testables = { onNoData }
12 changes: 8 additions & 4 deletions apps/web/src/api/warehouse/query-builder-timeseries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
fallbackStrategyFromWire,
runTimeseriesQuerySet,
} from "@maple/query-engine/query-set"
import { decodeInput, invalidWarehouseInput } from "@/api/warehouse/effect-utils"
import { decodeInput, invalidWarehouseInput, querySetFailure } from "@/api/warehouse/effect-utils"
import { makeWarehouseExecutor } from "@/api/warehouse/query-set-executor"

/**
Expand Down Expand Up @@ -130,8 +130,12 @@ const getQueryBuilderTimeseriesEffect = Effect.fn("QueryEngine.getQueryBuilderTi
...(!(input.maxDataPoints === undefined) ? { maxDataPoints: input.maxDataPoints } : undefined),
}).pipe(
// The runner's tagged failures carry the message this app already showed;
// re-raising them as `WarehouseInvalidInputError` keeps `displayError` and
// `mapBuilderChartFailure` working unchanged.
// re-raising them keeps `displayError` and `mapBuilderChartFailure` working
// unchanged. `querySetFailure` rather than `invalidWarehouseInput` for the
// no-data case: the runner stringifies each per-query failure into
// `details`, so a dropped connection arrived here as text and was re-raised
// as "Invalid query" — telling the user to fix a request that never left
// the browser.
Effect.catchTags({
"@maple/query-engine/query-set/QuerySetInputError": (error) =>
invalidWarehouseInput("getQueryBuilderTimeseries", error.message),
Expand All @@ -144,7 +148,7 @@ const getQueryBuilderTimeseriesEffect = Effect.fn("QueryEngine.getQueryBuilderTi
rows: [],
diagnostics: emptyTimeseriesResponse(input).diagnostics,
} satisfies TimeseriesQuerySetResult)
: invalidWarehouseInput("getQueryBuilderTimeseries", error.message),
: querySetFailure("getQueryBuilderTimeseries", error.message),
}),
)

Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/lib/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ export const makeClientErrorBody = (definition: ClientErrorDefinition): AnyPubli
...definition,
})

const NetworkError = makeClientErrorBody({
/**
* Shared so the warehouse layer can raise this same body as a typed failure —
* `WarehouseUnreachableError` — rather than only reaching it by unwrapping a
* cause chain. One copy, one tag, whichever way a dropped connection arrives.
*/
export const NetworkErrorBody = makeClientErrorBody({
_tag: NetworkErrorTag,
code: "network_unreachable",
title: "Cannot reach Maple API",
Expand Down Expand Up @@ -111,7 +116,7 @@ const displayErrorInternal = (input: unknown, depth: number): AnyPublicHttpError

if (HttpClientError.isHttpClientError(value)) {
if (value.reason._tag === "TransportError") {
return isTimeoutException(value.reason.cause) ? TimeoutError : NetworkError
return isTimeoutException(value.reason.cause) ? TimeoutError : NetworkErrorBody
}
return value.reason._tag === "InvalidUrlError" ? InvalidUrlError : HttpRequestError
}
Expand Down
Loading
Loading