Skip to content
Open
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
9 changes: 9 additions & 0 deletions frontend/app/lib/stellar/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { Networks } from "@stellar/stellar-sdk";
export const SOROBAN_RPC_URL =
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org";

/**
* Additional Soroban RPC endpoints to fall back to, comma-separated.
*
* Empty by default: which providers this app is willing to send traffic to is
* an operator's decision, so no third-party node is hardcoded here. When set,
* the error boundary can offer a one-click switch to the next one.
*/
export const SOROBAN_RPC_URLS = process.env.NEXT_PUBLIC_SOROBAN_RPC_URLS;

/** Network passphrase the built transaction is signed against. */
export const STELLAR_NETWORK_PASSPHRASE =
process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET;
Expand Down
132 changes: 132 additions & 0 deletions frontend/app/lib/stellar/rpcHealth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Classification and backoff for Soroban RPC failures.
*
* Pure and dependency-free so the retry policy can be tested without a network
* or a rendered boundary.
*/

/** Statuses worth retrying: the node is unhealthy or busy, not the request wrong. */
const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);

const TRANSIENT_MESSAGE_PATTERNS = [
"504",
"502",
"503",
"gateway timeout",
"bad gateway",
"service unavailable",
"too many requests",
"rate limit",
"failed to fetch",
"network request failed",
"networkerror",
"econnreset",
"econnrefused",
"etimedout",
"socket hang up",
"timeout",
];

/**
* A contract-level failure. Retrying cannot change the outcome, because the
* node answered correctly and the answer was "no".
*/
const TERMINAL_MESSAGE_PATTERNS = [
"unreachable",
"invalid action",
"contract error",
"hostfunction",
"trapped",
"insufficient balance",
"unauthorized",
"not found",
"txbadauth",
"txinsufficientbalance",
"simulation failed",
];

function statusOf(error: unknown): number | undefined {
if (typeof error !== "object" || error === null) return undefined;
const e = error as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } };
for (const candidate of [e.status, e.statusCode, e.response?.status]) {
if (typeof candidate === "number") return candidate;
}
return undefined;
}

/**
* Whether `error` is worth retrying against the RPC.
*
* Terminal contract failures are checked first: a revert message can mention a
* timeout in its own text, and retrying a rejected transaction just burns time
* and fees on an answer that will not change.
*/
export function isTransientRpcError(error: unknown): boolean {
const status = statusOf(error);
if (status !== undefined) return TRANSIENT_STATUS.has(status);

const text = (
error instanceof Error ? error.message : String(error ?? "")
).toLowerCase();

if (TERMINAL_MESSAGE_PATTERNS.some((p) => text.includes(p))) return false;
return TRANSIENT_MESSAGE_PATTERNS.some((p) => text.includes(p));
}

export const MAX_AUTO_RETRIES = 3;
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 8_000;
const JITTER_RATIO = 0.25;

/**
* Delay before retry `attempt` (0-based): 500ms, 1s, 2s… capped at 8s, with
* ±25% jitter so a node recovering from an outage is not hit by every open tab
* at the same instant.
*/
export function computeRetryDelay(
attempt: number,
random: () => number = Math.random,
): number {
const exponential = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
const jitter = exponential * JITTER_RATIO * (random() * 2 - 1);
return Math.max(0, Math.round(exponential + jitter));
}

/** Whether another automatic retry is allowed for this error. */
export function shouldAutoRetry(error: unknown, attemptsSoFar: number): boolean {
return attemptsSoFar < MAX_AUTO_RETRIES && isTransientRpcError(error);
}

// ── Endpoint rotation ──────────────────────────────────────────────────────

/**
* Configured RPC endpoints, in preference order.
*
* Read from `NEXT_PUBLIC_SOROBAN_RPC_URLS` (comma-separated) and falling back
* to the single `SOROBAN_RPC_URL`. Deliberately not seeded with third-party
* public nodes: which providers this app is willing to send traffic to is an
* operator's decision, not a default to inherit from a component.
*/
export function parseRpcEndpoints(
list: string | undefined,
fallback: string,
): string[] {
const parsed = (list ?? "")
.split(",")
.map((url) => url.trim())
.filter((url) => url.length > 0);

const endpoints = parsed.length > 0 ? parsed : [fallback];

// Preserve order while dropping duplicates: a repeated endpoint would make
// "switch node" appear to do nothing.
return Array.from(new Set(endpoints));
}

/** The endpoint after `current`, wrapping around. Returns `current` if it is alone. */
export function nextEndpoint(endpoints: readonly string[], current: string): string {
if (endpoints.length === 0) return current;
const index = endpoints.indexOf(current);
if (index === -1) return endpoints[0];
return endpoints[(index + 1) % endpoints.length];
}
187 changes: 174 additions & 13 deletions frontend/components/ui/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,218 @@
"use client";

import React, { Component, ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { AlertTriangle, ChevronDown, RefreshCw, Server } from "lucide-react";
import {
SOROBAN_RPC_URL,
SOROBAN_RPC_URLS,
} from "@/app/lib/stellar/network";
import {
MAX_AUTO_RETRIES,
computeRetryDelay,
isTransientRpcError,
nextEndpoint,
parseRpcEndpoints,
shouldAutoRetry,
} from "@/app/lib/stellar/rpcHealth";

interface Props {
children: ReactNode;
fallback?: ReactNode;
/**
* Called when the reader picks a different RPC endpoint. Without it the
* switcher is hidden, since changing an endpoint nothing listens to would
* be a button that appears to work and does not.
*/
onEndpointChange?: (endpoint: string) => void;
/** Disables the automatic backoff retry. Mainly a seam for tests. */
autoRetry?: boolean;
}

interface State {
hasError: boolean;
error?: Error;
componentStack?: string;
/** Automatic retries spent on the current error. */
retryCount: number;
isRetrying: boolean;
showDetails: boolean;
endpoint: string;
}

export class ErrorBoundary extends Component<Props, State> {
private retryTimer: ReturnType<typeof setTimeout> | null = null;

constructor(props: Props) {
super(props);
this.state = { hasError: false };
this.state = {
hasError: false,
retryCount: 0,
isRetrying: false,
showDetails: false,
endpoint: SOROBAN_RPC_URL,
};
}

static getDerivedStateFromError(error: Error): State {
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error caught by boundary:", error, errorInfo);
this.setState({ componentStack: errorInfo.componentStack ?? undefined });

// A testnet node returning 504 for a moment should not leave a dead
// subtree that only a full page reload can revive.
if (this.props.autoRetry !== false && shouldAutoRetry(error, this.state.retryCount)) {
this.scheduleRetry();
}
}

componentWillUnmount() {
if (this.retryTimer) clearTimeout(this.retryTimer);
}

private scheduleRetry = () => {
const delay = computeRetryDelay(this.state.retryCount);
this.setState({ isRetrying: true });
this.retryTimer = setTimeout(() => {
// Clearing hasError remounts the subtree, which re-runs whatever
// request failed. If it fails again, componentDidCatch fires with an
// incremented count until the budget runs out.
this.setState((prev) => ({
hasError: false,
error: undefined,
componentStack: undefined,
isRetrying: false,
retryCount: prev.retryCount + 1,
}));
}, delay);
};

/** Manual retry. Resets the budget: the reader has chosen to try again. */
private handleRetry = () => {
if (this.retryTimer) clearTimeout(this.retryTimer);
this.setState({
hasError: false,
error: undefined,
componentStack: undefined,
isRetrying: false,
retryCount: 0,
});
};

private handleSwitchEndpoint = () => {
const endpoints = parseRpcEndpoints(SOROBAN_RPC_URLS, SOROBAN_RPC_URL);
const next = nextEndpoint(endpoints, this.state.endpoint);
this.setState({ endpoint: next });
this.props.onEndpointChange?.(next);
this.handleRetry();
};

render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}

const { error, retryCount, isRetrying, showDetails, componentStack } = this.state;
const transient = isTransientRpcError(error);
const endpoints = parseRpcEndpoints(SOROBAN_RPC_URLS, SOROBAN_RPC_URL);
const canSwitch = endpoints.length > 1 && !!this.props.onEndpointChange;

if (isRetrying) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div
className="bg-[#0A0F11] border border-[#33C5E0]/30 rounded-2xl p-8 max-w-md w-full text-center"
role="status"
>
<RefreshCw className="text-[#33C5E0] mx-auto mb-4 animate-spin" size={32} />
<p className="text-white">
Connection problem — retrying ({retryCount + 1} of {MAX_AUTO_RETRIES})
</p>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="bg-[#0A0F11] border border-red-500/30 rounded-2xl p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="text-red-400" size={32} />
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Something went wrong
{transient ? "Connection problem" : "Something went wrong"}
</h2>
<p className="text-[#8899A6] mb-6">
{this.state.error?.message || "An unexpected error occurred"}
<p className="text-[#8899A6] mb-2">
{transient
? "The Soroban RPC node did not respond. This is usually temporary on testnet."
: error?.message || "An unexpected error occurred"}
</p>
<button
onClick={() => window.location.reload()}
className="bg-[#33C5E0] text-[#161E22] px-6 py-3 rounded-full font-medium flex items-center gap-2 mx-auto hover:bg-[#2AB5D0] transition-colors"
>
<RefreshCw size={20} />
Reload Page
</button>

{transient && retryCount >= MAX_AUTO_RETRIES && (
<p className="text-[#8899A6]/70 text-sm mb-4">
Retried {MAX_AUTO_RETRIES} times without success.
</p>
)}

<div className="flex flex-col sm:flex-row gap-3 justify-center mt-6">
<button
onClick={this.handleRetry}
className="bg-[#33C5E0] text-[#161E22] px-6 py-3 rounded-full font-medium flex items-center gap-2 justify-center hover:bg-[#2AB5D0] transition-colors"
>
<RefreshCw size={20} />
Try again
</button>

{canSwitch && (
<button
onClick={this.handleSwitchEndpoint}
className="border border-[#33C5E0]/40 text-[#33C5E0] px-6 py-3 rounded-full font-medium flex items-center gap-2 justify-center hover:bg-[#33C5E0]/10 transition-colors"
>
<Server size={20} />
Switch RPC node
</button>
)}

{/* Kept as a last resort rather than the only option: a reload
throws away all client state for what is often a blip. */}
<button
onClick={() => window.location.reload()}
className="text-[#8899A6] px-6 py-3 rounded-full font-medium hover:text-white transition-colors"
>
Reload page
</button>
</div>

{(error?.stack || componentStack) && (
<div className="mt-6 text-left">
<button
onClick={() => this.setState((prev) => ({ showDetails: !prev.showDetails }))}
aria-expanded={showDetails}
className="text-[#8899A6] text-sm flex items-center gap-1 hover:text-white transition-colors"
>
<ChevronDown
size={16}
className={showDetails ? "rotate-180 transition-transform" : "transition-transform"}
/>
Technical details
</button>

{showDetails && (
<div className="mt-3 bg-black/40 border border-[#161E22] rounded-lg p-3 max-h-64 overflow-auto">
<p className="text-[#8899A6] text-xs mb-2 break-all">
RPC endpoint: {this.state.endpoint}
</p>
<pre className="text-[#8899A6] text-xs whitespace-pre-wrap break-all">
{error?.stack ?? error?.message}
{componentStack}
</pre>
</div>
)}
</div>
)}
</div>
</div>
);
Expand Down
Loading