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
14 changes: 11 additions & 3 deletions apps/web/src/components/common/error-state.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Button } from "@maple/ui/components/ui/button"
import { useNetworkAutoRetry } from "@/hooks/use-network-auto-retry"
import { formatBackendError } from "@/lib/error-messages"
import { cn } from "@maple/ui/lib/utils"

Expand Down Expand Up @@ -33,6 +34,13 @@ interface ErrorStateProps {
export function ErrorState({ error, title, onRetry, variant = "panel", className }: ErrorStateProps) {
const formatted = formatBackendError(error)
const heading = title ?? formatted.title
// Connectivity blips self-heal: probe on a backoff poll + the `online`
// event, so recovery doesn't require the user to click or reload.
const autoRetrying = formatted.kind === "network" && onRetry !== undefined
useNetworkAutoRetry(autoRetrying, onRetry)
const description = autoRetrying
? `${formatted.description} Retrying automatically…`
: formatted.description

if (variant === "inline") {
return (
Expand All @@ -46,7 +54,7 @@ export function ErrorState({ error, title, onRetry, variant = "panel", className
</span>
<p className="text-xs font-medium text-foreground">{heading}</p>
</div>
<p className="text-xs whitespace-pre-wrap text-muted-foreground">{formatted.description}</p>
<p className="text-xs whitespace-pre-wrap text-muted-foreground">{description}</p>
{onRetry && (
<Button
size="sm"
Expand All @@ -72,7 +80,7 @@ export function ErrorState({ error, title, onRetry, variant = "panel", className
<DroppedSignalGlyph compact />
<div className="min-w-0 flex-1 basis-56 space-y-0.5">
<p className="text-sm font-medium text-foreground">{heading}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">{formatted.description}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">{description}</p>
</div>
{onRetry && (
<Button size="sm" variant="outline" className="shrink-0" onClick={onRetry}>
Expand All @@ -95,7 +103,7 @@ export function ErrorState({ error, title, onRetry, variant = "panel", className
</div>
<div className="max-w-xs space-y-1">
<p className="text-sm font-medium text-foreground">{heading}</p>
<p className="text-xs whitespace-pre-wrap text-muted-foreground">{formatted.description}</p>
<p className="text-xs whitespace-pre-wrap text-muted-foreground">{description}</p>
</div>
{onRetry && (
<Button size="sm" variant="outline" onClick={onRetry}>
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/components/route-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect } from "react"
import { AlertWarningIcon, CircleQuestionIcon, HouseIcon } from "@/components/icons"
import { Button, buttonVariants } from "@maple/ui/components/ui/button"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty"
import { useNetworkAutoRetry } from "@/hooks/use-network-auto-retry"
import { formatBackendError } from "@/lib/error-messages"
import { isChunkLoadError, shouldAttemptChunkReload } from "@/lib/chunk-reload"

Expand All @@ -17,9 +18,17 @@ function RouteError({ error, reset }: ErrorComponentProps) {
}
}, [isStaleChunk])

const { title, description } = formatBackendError(error)
const formatted = formatBackendError(error)
const { title, description } = formatted
const stack = error instanceof Error ? error.stack : undefined

const retry = () => {
reset()
router.invalidate()
}
// Route-loader transport failures self-heal without a manual reload.
useNetworkAutoRetry(formatted.kind === "network" && !isStaleChunk, retry)

return (
<Empty className="min-h-[60vh]">
<EmptyHeader>
Expand All @@ -38,8 +47,7 @@ function RouteError({ error, reset }: ErrorComponentProps) {
window.location.reload()
return
}
reset()
router.invalidate()
retry()
}}
>
{isStaleChunk ? "Reload" : "Try again"}
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/hooks/use-network-auto-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from "react"

import { useMountEffect } from "@/hooks/use-mount-effect"

/** Poll backoff: 5s → 10s → 20s, then every 30s (±10% jitter). */
const POLL_DELAYS_MS = [5_000, 10_000, 20_000, 30_000]

/**
* While `enabled`, invokes `onRetry` automatically:
*
* - immediately when the browser fires the window `online` event (or the tab
* becomes visible again),
* - on a backoff poll per {@link POLL_DELAYS_MS}, with jitter so several error
* panels on one screen don't probe in lockstep.
*
* Poll ticks are skipped while the tab is hidden or the browser reports
* offline (the `online`/`visibilitychange` listeners fire the probe instead).
* The timer chain is mount-scoped; `enabled` and `onRetry` are read fresh at
* fire time. If a probe unmounts the host (error → loading → error remount),
* the backoff resets to 5s — effectively a steady poll during a long outage,
* which the 5s floor keeps cheap.
*/
export function useNetworkAutoRetry(enabled: boolean, onRetry: (() => void) | undefined): void {
const fire = React.useEffectEvent(() => {
if (enabled) onRetry?.()
})

useMountEffect(() => {
// React Doctor cannot infer that useMountEffect is an Effect; this is the
// canonical Effect Event pattern for a mount-scoped external subscription.
// oxlint-disable-next-line react-doctor/rules-of-hooks
const probe = () => fire()

let attempt = 0
let timeout: ReturnType<typeof setTimeout> | undefined

const schedule = () => {
const base = POLL_DELAYS_MS[Math.min(attempt, POLL_DELAYS_MS.length - 1)]
const delay = base * (0.9 + Math.random() * 0.2)
timeout = setTimeout(() => {
attempt += 1
if (!document.hidden && navigator.onLine !== false) probe()
schedule()
}, delay)
}

const onOnline = () => probe()
const onVisibilityChange = () => {
if (!document.hidden && navigator.onLine !== false) probe()
}

window.addEventListener("online", onOnline)
document.addEventListener("visibilitychange", onVisibilityChange)
schedule()

return () => {
window.removeEventListener("online", onOnline)
document.removeEventListener("visibilitychange", onVisibilityChange)
if (timeout !== undefined) clearTimeout(timeout)
}
})
}
22 changes: 22 additions & 0 deletions apps/web/src/lib/error-messages.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { describe, expect, it } from "vitest"
import { formatBackendError } from "./error-messages"

Expand Down Expand Up @@ -168,6 +169,27 @@ describe("formatBackendError", () => {
expect(result.title).toBe("Not authorized")
})

it("tags transport HttpClientError as a network error", () => {
const error = new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: HttpClientRequest.get("https://api.maple.dev/v1/services"),
}),
})
const result = formatBackendError(error)
expect(result.title).toBe("Cannot reach Maple API")
expect(result.kind).toBe("network")
})

it("tags fetch-failure Error messages as network errors", () => {
const result = formatBackendError(new Error("Failed to fetch"))
expect(result.title).toBe("Cannot reach Maple API")
expect(result.kind).toBe("network")
})

it("does not tag non-network errors", () => {
expect(formatBackendError(new Error("boom")).kind).toBeUndefined()
})

it("falls back for plain Error", () => {
const result = formatBackendError(new Error("boom"))
expect(result.title).toBe("Something went wrong")
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { isChunkLoadError } from "./chunk-reload"
export interface FormattedError {
readonly title: string
readonly description: string
/** `"network"` = transient connectivity failure; safe to auto-retry. */
readonly kind?: "network"
}

const QUOTA_DESCRIPTIONS: Record<string, string> = {
Expand Down Expand Up @@ -250,6 +252,8 @@ export const formatBackendError = (input: unknown): FormattedError => {
return {
title: "Cannot reach Maple API",
description: "Check your connection — data will resume once the API is reachable.",
// A bad URL never self-heals — only transport failures auto-retry.
...(reasonTag === "TransportError" ? { kind: "network" as const } : {}),
}
}
if (status !== undefined && status >= 500) {
Expand All @@ -274,6 +278,7 @@ export const formatBackendError = (input: unknown): FormattedError => {
return {
title: "Cannot reach Maple API",
description: "Check your connection — data will resume once the API is reachable.",
kind: "network",
}
}
return {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/services/common/atom-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Effect } from "effect"
import { HttpClient, HttpClientError } from "effect/unstable/http"
import { apiBaseUrl } from "./api-base-url"
import { MapleFetchHttpClientLive } from "./http-client"
import { isRetryableTransportError, mapleRetrySchedule } from "./retry-policy"

export class MapleApiAtomClient extends AtomHttpApi.Service<MapleApiAtomClient>()(
"@maple/web/services/common/MapleApiAtomClient",
Expand All @@ -27,11 +28,15 @@ export class MapleApiAtomClient extends AtomHttpApi.Service<MapleApiAtomClient>(
),
HttpClient.retry({
times: 3,
schedule: mapleRetrySchedule,
while: (error) => {
// Transient network failures (idempotent requests only) self-heal
// with backoff instead of failing fast to the error UI.
if (isRetryableTransportError(error)) return true
if (!HttpClientError.isHttpClientError(error)) return false
const status = error.response?.status
if (status === undefined) return false
// Only retry on 500/502/503 — not 504 (timeout) or undefined (network failure)
// Retry on 500/502/503 — not 504 (query timeout, won't get faster)
if (status >= 500 && status < 600 && status !== 504) return true
// Billing reads (customer/usage/plans) can fire during the Clerk
// token-settle window where getToken() is transiently null → the
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/lib/services/common/retry-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { describe, expect, it } from "vitest"
import { isRetryableTransportError } from "./retry-policy"

const transportError = (request: HttpClientRequest.HttpClientRequest, cause?: unknown) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request, cause }),
})

describe("isRetryableTransportError", () => {
it("retries transport failures on idempotent requests", () => {
const error = transportError(HttpClientRequest.get("https://api.maple.dev/v1/services"))
expect(isRetryableTransportError(error)).toBe(true)
})

it("never replays mutations", () => {
const error = transportError(HttpClientRequest.post("https://api.maple.dev/v1/dashboards"))
expect(isRetryableTransportError(error)).toBe(false)
})

it("does not multiply the client timeout", () => {
const error = transportError(
HttpClientRequest.get("https://api.maple.dev/v1/services"),
new DOMException("timed out", "TimeoutError"),
)
expect(isRetryableTransportError(error)).toBe(false)
})

it("does not replay aborted requests", () => {
const error = transportError(
HttpClientRequest.get("https://api.maple.dev/v1/services"),
new DOMException("aborted", "AbortError"),
)
expect(isRetryableTransportError(error)).toBe(false)
})

it("ignores non-HttpClientError values", () => {
expect(isRetryableTransportError(new Error("Failed to fetch"))).toBe(false)
})
})
24 changes: 24 additions & 0 deletions apps/web/src/lib/services/common/retry-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Schedule } from "effect"
import { HttpClientError } from "effect/unstable/http"

const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS"])

/**
* Transient network failure worth replaying: a `TransportError` (fetch failed,
* DNS, connection reset) on an idempotent request. Transport failures can occur
* after the request reached the server, so mutations are never replayed. The
* 45s client timeout (`AbortSignal.timeout` in http-client.ts) also surfaces as
* a TransportError — excluded so one hang doesn't multiply into several.
*/
export const isRetryableTransportError = (error: unknown): boolean => {
if (!HttpClientError.isHttpClientError(error)) return false
if (error.reason._tag !== "TransportError") return false
const cause = error.reason.cause
if (cause instanceof DOMException && (cause.name === "TimeoutError" || cause.name === "AbortError")) {
return false
}
return IDEMPOTENT_METHODS.has(error.request.method.toUpperCase())
}

/** Backoff between HTTP-layer retry attempts: 300ms → 600ms → 1.2s. */
export const mapleRetrySchedule = Schedule.exponential("300 millis")
5 changes: 5 additions & 0 deletions apps/web/src/lib/services/common/v2-atom-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Effect } from "effect"
import { HttpClient, HttpClientError } from "effect/unstable/http"
import { apiBaseUrl } from "./api-base-url"
import { MapleFetchHttpClientLive } from "./http-client"
import { isRetryableTransportError, mapleRetrySchedule } from "./retry-policy"

/** Typed dashboard client for the public, stability-committed v2 API. */
export class MapleApiV2AtomClient extends AtomHttpApi.Service<MapleApiV2AtomClient>()(
Expand All @@ -22,7 +23,11 @@ export class MapleApiV2AtomClient extends AtomHttpApi.Service<MapleApiV2AtomClie
),
HttpClient.retry({
times: 3,
schedule: mapleRetrySchedule,
while: (error) => {
// Transient network failures (idempotent requests only) self-heal
// with backoff instead of failing fast to the error UI.
if (isRetryableTransportError(error)) return true
if (!HttpClientError.isHttpClientError(error)) return false
const status = error.response?.status
return status !== undefined && status >= 500 && status < 600 && status !== 504
Expand Down
Loading