diff --git a/package.json b/package.json
index 6cf8cc9..f3a183d 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
- "test": "node --test test/design-tokens.test.js test/RouteLoader.test.js"
+ "test": "node --test test/design-tokens.test.js test/RouteLoader.test.js test/error-normalizer.test.js"
},
"dependencies": {
"react": "^18.2.0",
diff --git a/src/hooks/useStreams.js b/src/hooks/useStreams.js
index 1a5fdae..4504697 100644
--- a/src/hooks/useStreams.js
+++ b/src/hooks/useStreams.js
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { listStreams } from '../services/streams.js';
+import { normalizeError } from '../services/api.js';
/**
* Load and expose the list of streams for a given direction.
@@ -18,7 +19,7 @@ export function useStreams(direction) {
setError(null);
return listStreams({ direction })
.then((data) => setStreams(data))
- .catch((e) => setError(e.message || 'Failed to load streams'))
+ .catch((e) => setError(normalizeError(e)))
.finally(() => setLoading(false));
}, [direction]);
diff --git a/src/pages/CreateStream.jsx b/src/pages/CreateStream.jsx
index fd6b631..cf79815 100644
--- a/src/pages/CreateStream.jsx
+++ b/src/pages/CreateStream.jsx
@@ -6,6 +6,7 @@ import { formatToken } from '../utils/format.js';
import { ratePerDay } from '../utils/stream.js';
import { DAY } from '../utils/time.js';
import { createStream } from '../services/streams.js';
+import { normalizeError } from '../services/api.js';
import { useWallet } from '../hooks/useWallet.js';
import { useLocalStorage } from '../hooks/useLocalStorage.js';
import TokenSelect from '../components/TokenSelect.jsx';
@@ -89,7 +90,7 @@ export default function CreateStream() {
});
navigate(`/streams/${created.id}`);
} catch (err) {
- setSubmitError(err.message || 'Failed to create stream');
+ setSubmitError(normalizeError(err));
} finally {
setSubmitting(false);
}
@@ -191,7 +192,7 @@ export default function CreateStream() {
{submitError && (
{
- if (!data) setError('Stream not found');
+ if (!data) setError(normalizeError({ status: 404, message: 'Stream not found' }));
setStream(data);
})
- .catch((e) => setError(e.message || 'Failed to load stream'))
+ .catch((e) => setError(normalizeError(e)))
.finally(() => setLoading(false));
}, [id]);
@@ -54,7 +55,7 @@ export default function StreamDetail() {
const updated = await withdrawStream(id);
setStream(updated);
} catch (e) {
- setError(e.message || 'Withdraw failed');
+ setError(normalizeError(e));
} finally {
setAction(null);
}
@@ -67,14 +68,14 @@ export default function StreamDetail() {
const updated = await cancelStream(id);
setStream(updated);
} catch (e) {
- setError(e.message || 'Cancel failed');
+ setError(normalizeError(e));
} finally {
setAction(null);
}
}
if (loading) return ;
- if (error && !stream) return ;
+ if (error && !stream) return ;
if (!stream) return null;
const token = getToken(stream.token);
@@ -163,7 +164,7 @@ export default function StreamDetail() {
- {error && }
+ {error && }
diff --git a/src/services/api.js b/src/services/api.js
index e9784af..b4b2c0b 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -5,6 +5,165 @@
*/
const DEFAULT_LATENCY = 600;
+const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
+
+function redactSecrets(value) {
+ if (value === null || value === undefined) return value;
+
+ if (typeof value === 'string') {
+ return value
+ .replace(/(Bearer\s+)[A-Za-z0-9._\-]+/gi, '$1[REDACTED]')
+ .replace(/(authorization\s*:\s*).*/gi, '$1[REDACTED]')
+ .replace(/(sk_[A-Za-z0-9_]+)/gi, '[REDACTED]')
+ .replace(/(token\s*[:=]\s*)([^\s,;]+)/gi, '$1[REDACTED]')
+ .replace(/(secret\s*[:=]\s*)([^\s,;]+)/gi, '$1[REDACTED]');
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) => redactSecrets(item));
+ }
+
+ if (typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => {
+ if (/authorization|token|secret|password|api[_-]?key|cookie|bearer/i.test(key)) {
+ return [key, '[REDACTED]'];
+ }
+ return [key, redactSecrets(item)];
+ })
+ );
+ }
+
+ return value;
+}
+
+function deriveStatus(input) {
+ if (!input || typeof input !== 'object') return undefined;
+ const status = input.status ?? input.statusCode ?? input.response?.status;
+ return typeof status === 'number' ? status : undefined;
+}
+
+function deriveCorrelationId(input) {
+ if (!input || typeof input !== 'object') return undefined;
+
+ const candidates = [
+ input.correlationId,
+ input.requestId,
+ input.traceId,
+ input.id,
+ input.response?.headers?.['x-correlation-id'],
+ input.response?.headers?.['X-Correlation-Id'],
+ input.response?.headers?.['x-request-id'],
+ input.response?.headers?.['X-Request-Id'],
+ input.response?.data?.correlationId,
+ input.response?.data?.requestId,
+ ];
+
+ return candidates.find((value) => typeof value === 'string' && value.trim().length > 0);
+}
+
+export function isRetryableError(input) {
+ const err = normalizeError(input);
+ return Boolean(err.retryable);
+}
+
+export function normalizeError(input) {
+ const raw = input && typeof input === 'object' && 'message' in input ? input : input;
+ const messageValue =
+ input && typeof input === 'object' && 'message' in input && typeof input.message === 'string'
+ ? input.message
+ : typeof input === 'string'
+ ? input
+ : '';
+
+ const status = deriveStatus(raw);
+ const correlationId = deriveCorrelationId(raw);
+ const rawMessage = messageValue.trim();
+ const lowerMessage = rawMessage.toLowerCase();
+
+ let code = 'internal_error';
+ let message = GENERIC_ERROR_MESSAGE;
+ let retryable = false;
+
+ if (status === 429) {
+ code = 'rate_limited';
+ message = 'Too many requests. Please wait a moment and try again.';
+ retryable = true;
+ } else if (status === 408 || status === 504 || status === 502 || status === 503) {
+ code = 'upstream_unavailable';
+ message = 'The service is temporarily unavailable. Please try again.';
+ retryable = true;
+ } else if (status === 400 || status === 422) {
+ code = 'validation_error';
+ message = 'Please check your details and try again.';
+ } else if (status === 401 || status === 403) {
+ code = 'unauthorized';
+ message = 'You are not authorized to perform this action.';
+ } else if (status === 404) {
+ code = 'not_found';
+ message = 'The requested resource was not found.';
+ } else if (lowerMessage.includes('timeout') || lowerMessage.includes('timed out')) {
+ code = 'request_timeout';
+ message = 'The request timed out. Please try again.';
+ retryable = true;
+ } else if (
+ lowerMessage.includes('network') ||
+ lowerMessage.includes('connection reset') ||
+ lowerMessage.includes('econnreset') ||
+ lowerMessage.includes('temporarily unavailable') ||
+ lowerMessage.includes('service unavailable') ||
+ lowerMessage.includes('too many requests')
+ ) {
+ code = 'upstream_unavailable';
+ message = 'The service is temporarily unavailable. Please try again.';
+ retryable = true;
+ } else if (
+ lowerMessage.includes('validation') ||
+ lowerMessage.includes('invalid request') ||
+ lowerMessage.includes('malformed') ||
+ lowerMessage.includes('bad request') ||
+ lowerMessage.includes('not found') ||
+ lowerMessage.includes('business rule')
+ ) {
+ code = 'validation_error';
+ message = 'Please check your details and try again.';
+ } else if (raw && typeof raw === 'object' && typeof raw.code === 'string') {
+ const rawCode = raw.code.toLowerCase();
+ if (rawCode.includes('timeout') || rawCode.includes('econn') || rawCode.includes('network')) {
+ code = 'request_timeout';
+ message = 'The request timed out. Please try again.';
+ retryable = true;
+ } else if (/429|rate[_ -]?limited|too many requests/i.test(rawCode)) {
+ code = 'rate_limited';
+ message = 'Too many requests. Please wait a moment and try again.';
+ retryable = true;
+ }
+ }
+
+ if (raw && typeof raw === 'object') {
+ const responseData = raw.response?.data ?? raw.data;
+ const responseMessage =
+ (typeof responseData === 'string' && responseData.trim()) ||
+ (responseData && typeof responseData === 'object' && typeof responseData.message === 'string' && responseData.message.trim()) ||
+ '';
+
+ const providerDescription = typeof raw.error === 'string' ? raw.error : responseMessage;
+ if (providerDescription && /too many requests|rate limit|temporarily unavailable|timeout|econnreset|network/i.test(providerDescription.toLowerCase())) {
+ retryable = true;
+ }
+ }
+
+ const safeCause = redactSecrets(raw);
+
+ return {
+ code,
+ message: message || GENERIC_ERROR_MESSAGE,
+ retryable,
+ correlationId,
+ status,
+ cause: safeCause,
+ };
+}
/**
* Resolve a value after a simulated network delay.
@@ -27,7 +186,7 @@ export function withLatency(value, latency = DEFAULT_LATENCY) {
*/
export function failWithLatency(message, latency = DEFAULT_LATENCY) {
return new Promise((_, reject) => {
- setTimeout(() => reject(new Error(message)), latency);
+ setTimeout(() => reject(normalizeError(new Error(message))), latency);
});
}
diff --git a/src/services/streams.js b/src/services/streams.js
index bd2735f..bca4eec 100644
--- a/src/services/streams.js
+++ b/src/services/streams.js
@@ -1,4 +1,4 @@
-import { withLatency } from './api.js';
+import { normalizeError, withLatency } from './api.js';
import { DAY, HOUR, elapsedFraction } from '../utils/time.js';
/**
@@ -113,20 +113,24 @@ export async function getStream(id) {
* @returns {Promise