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 && (
- + handleSubmit(new Event('submit')) : undefined} />
)} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 92d6b87..684fc1b 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -41,7 +41,7 @@ function StreamSection({ title, direction }) { )} - {error && } + {error && } {!loading && !error && streams.length === 0 && ( { - 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} */ 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); + } } /** @@ -135,11 +139,15 @@ export async function createStream(input) { * @returns {Promise} */ 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); + } } /** @@ -148,9 +156,13 @@ export async function withdrawStream(id) { * @returns {Promise} */ 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); + } } diff --git a/test/error-normalizer.test.js b/test/error-normalizer.test.js new file mode 100644 index 0000000..6ca8f99 --- /dev/null +++ b/test/error-normalizer.test.js @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { normalizeError, isRetryableError } from '../src/services/api.js'; + +test('normalizes provider payload fixture and redacts secrets', () => { + const raw = { + code: 'ERR_BAD_RESPONSE', + message: 'Provider rejected request', + response: { + status: 429, + data: { + message: 'Too many requests', + token: 'sk_live_1234567890', + details: 'provider payload contains raw secret material', + }, + }, + requestId: 'trace-xyz-42', + config: { + headers: { + Authorization: 'Bearer secret-token', + }, + }, + }; + + const err = normalizeError(raw); + + assert.equal(err.code, 'rate_limited'); + assert.equal(err.status, 429); + assert.equal(err.retryable, true); + assert.equal(err.correlationId, 'trace-xyz-42'); + assert.match(err.message, /too many requests|try again/i); + assert.ok(!err.message.includes('sk_live_1234567890')); + assert.ok(!err.message.includes('provider payload contains raw secret material')); + assert.ok(!JSON.stringify(err.cause || {}).includes('sk_live_1234567890')); + assert.ok(!JSON.stringify(err.cause || {}).includes('secret-token')); +}); + +test('classifies retryable and non-retryable errors using normalized metadata', () => { + assert.equal(isRetryableError({ message: 'network timeout' }), true); + assert.equal(isRetryableError({ status: 503 }), true); + assert.equal(isRetryableError({ status: 422 }), false); + assert.equal(isRetryableError({ status: 401 }), false); + assert.equal(isRetryableError({ message: 'Stream not found' }), false); +}); + +test('extracts correlation ids from provider headers and error metadata', () => { + const fromHeaders = normalizeError({ + response: { + status: 503, + headers: { 'x-correlation-id': 'corr-headers-1' }, + }, + }); + + const fromMetadata = normalizeError({ + correlationId: 'corr-meta-2', + status: 500, + }); + + assert.equal(fromHeaders.correlationId, 'corr-headers-1'); + assert.equal(fromMetadata.correlationId, 'corr-meta-2'); +}); + +test('handles malformed error inputs safely', () => { + const values = [null, undefined, 'boom', 42, { foo: 'bar' }, { message: '' }]; + + for (const value of values) { + const err = normalizeError(value); + assert.equal(err.code, 'internal_error'); + assert.ok(typeof err.message === 'string'); + assert.ok(err.message.length > 0); + assert.equal(err.retryable, false); + } +}); + +test('unknown errors fail closed without exposing raw payloads', () => { + const err = normalizeError({ + data: { + secrets: ['sk_live_very_secret'], + details: 'provider raw payload should never render', + }, + }); + + assert.equal(err.code, 'internal_error'); + assert.match(err.message, /something went wrong|try again/i); + assert.ok(!err.message.includes('provider raw payload should never render')); + assert.ok(!JSON.stringify(err.cause || {}).includes('sk_live_very_secret')); +});