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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useStreams.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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]);

Expand Down
5 changes: 3 additions & 2 deletions src/pages/CreateStream.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -191,7 +192,7 @@ export default function CreateStream() {

{submitError && (
<div aria-live="assertive" aria-atomic="true">
<ErrorMessage message={submitError} />
<ErrorMessage message={submitError.message} onRetry={submitError.retryable ? () => handleSubmit(new Event('submit')) : undefined} />
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function StreamSection({ title, direction }) {
<StreamCardSkeleton />
</div>
)}
{error && <ErrorMessage message={error} onRetry={refetch} />}
{error && <ErrorMessage message={error.message} onRetry={error.retryable ? refetch : undefined} />}
{!loading && !error && streams.length === 0 && (
<EmptyState
icon="🌊"
Expand Down
13 changes: 7 additions & 6 deletions src/pages/StreamDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
cancelStream,
currentAddress,
} from '../services/streams.js';
import { normalizeError } from '../services/api.js';
import { deriveStream } from '../utils/stream.js';
import { getToken } from '../constants/tokens.js';
import {
Expand Down Expand Up @@ -36,10 +37,10 @@ export default function StreamDetail() {
setError(null);
return getStream(id)
.then((data) => {
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]);

Expand All @@ -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);
}
Expand All @@ -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 <Loader label="Loading stream…" />;
if (error && !stream) return <ErrorMessage message={error} onRetry={load} />;
if (error && !stream) return <ErrorMessage message={error.message} onRetry={error.retryable ? load : undefined} />;
if (!stream) return null;

const token = getToken(stream.token);
Expand Down Expand Up @@ -163,7 +164,7 @@ export default function StreamDetail() {
</dl>

<div aria-live="assertive" aria-atomic="true">
{error && <ErrorMessage message={error} />}
{error && <ErrorMessage message={error.message} onRetry={error.retryable ? load : undefined} />}
</div>

<div className="stream-detail__actions">
Expand Down
161 changes: 160 additions & 1 deletion src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
});
}

Expand Down
62 changes: 37 additions & 25 deletions src/services/streams.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withLatency } from './api.js';
import { normalizeError, withLatency } from './api.js';
import { DAY, HOUR, elapsedFraction } from '../utils/time.js';

/**
Expand Down Expand Up @@ -113,20 +113,24 @@ export async function getStream(id) {
* @returns {Promise<object>}
*/
export async function createStream(input) {
const stream = {
id: `str-${nextId++}`,
sender: ME,
recipient: input.recipient.trim(),
token: input.token,
total: Number(input.total),
withdrawn: 0,
start: input.start,
end: input.end,
status: 'active',
label: input.label || 'New stream',
};
streams = [stream, ...streams];
return withLatency(stream, 700);
try {
const stream = {
id: `str-${nextId++}`,
sender: ME,
recipient: input.recipient.trim(),
token: input.token,
total: Number(input.total),
withdrawn: 0,
start: input.start,
end: input.end,
status: 'active',
label: input.label || 'New stream',
};
streams = [stream, ...streams];
return withLatency(stream, 700);
} catch (error) {
throw normalizeError(error);
}
}

/**
Expand All @@ -135,11 +139,15 @@ export async function createStream(input) {
* @returns {Promise<object>}
*/
export async function withdrawStream(id) {
const stream = streams.find((s) => s.id === id);
if (!stream) throw new Error('Stream not found');
const available = streamedSoFar(stream) - stream.withdrawn;
stream.withdrawn += Math.max(0, available);
return withLatency({ ...stream, claimed: available }, 700);
try {
const stream = streams.find((s) => s.id === id);
if (!stream) throw normalizeError({ status: 404, message: 'Stream not found' });
const available = streamedSoFar(stream) - stream.withdrawn;
stream.withdrawn += Math.max(0, available);
return withLatency({ ...stream, claimed: available }, 700);
} catch (error) {
throw normalizeError(error);
}
}

/**
Expand All @@ -148,9 +156,13 @@ export async function withdrawStream(id) {
* @returns {Promise<object>}
*/
export async function cancelStream(id) {
const stream = streams.find((s) => s.id === id);
if (!stream) throw new Error('Stream not found');
stream.status = 'cancelled';
stream.end = Date.now();
return withLatency({ ...stream }, 700);
try {
const stream = streams.find((s) => s.id === id);
if (!stream) throw normalizeError({ status: 404, message: 'Stream not found' });
stream.status = 'cancelled';
stream.end = Date.now();
return withLatency({ ...stream }, 700);
} catch (error) {
throw normalizeError(error);
}
}
Loading