Skip to content

Commit a60b9ce

Browse files
feat(error): consolidate error handling into unified service
- Merge errorReporting.service.ts functionality into errorHandler.service.ts - Add comprehensive error reporting with circuit breaker pattern - Implement global JS and native error handlers - Add safe JSON serialization to prevent circular references - Include error simulation functions for testing - Remove dependency on react-native-exception-handler
1 parent 84ec2d9 commit a60b9ce

1 file changed

Lines changed: 230 additions & 0 deletions

File tree

services/errorHandler.service.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Removed react-native-exception-handler; implementing lightweight JS/global handlers manually.
2+
// Native test exception helper
3+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
4+
// @ts-ignore
5+
// Removed rn-test-exception-handler (deprecated / incompatible). We'll simulate native error instead.
6+
// import RnTestExceptionHandler from 'rn-test-exception-handler';
7+
import { notifyError } from '@/services/notifier.service';
8+
import { showFatalErrorNotification } from '@/utils/notifications.utils';
9+
import { Alert, Platform, NativeModules } from 'react-native';
10+
11+
// ============================================================================
12+
// Error Reporting (merged from former errorReporting.service.ts)
13+
// ----------------------------------------------------------------------------
14+
export interface ReportOptions {
15+
/** Will be set by the global handlers for native vs JS */
16+
isFatal?: boolean;
17+
/** Logical source (hook / component / boundary / task) */
18+
componentStack?: string;
19+
/** Arbitrary labels for filtering (small strings only) */
20+
tags?: Record<string, string>;
21+
/** Extra structured diagnostic data */
22+
extra?: Record<string, unknown>;
23+
}
24+
25+
export interface ErrorReport {
26+
id: string; // client generated id for tracing
27+
name: string;
28+
message: string;
29+
stack?: string;
30+
cause?: string;
31+
timestamp: string;
32+
platform: string;
33+
isFatal?: boolean;
34+
componentStack?: string;
35+
tags?: Record<string, string>;
36+
extra?: Record<string, unknown>;
37+
}
38+
39+
const ERROR_REPORTING_ENDPOINT = 'https://api.codebuilder.org/errors';
40+
41+
// Lightweight circuit breaker to avoid spamming backend on outages.
42+
const circuitBreaker = {
43+
failureCount: 0,
44+
lastFailureTime: 0,
45+
isOpen: false,
46+
failureThreshold: 3,
47+
resetTimeout: 60_000, // 1 min
48+
baseDelay: 5_000, // 5s
49+
maxDelay: 3_600_000, // 1h
50+
shouldPreventApiCall(): boolean {
51+
if (!this.isOpen) return false;
52+
const now = Date.now();
53+
const elapsed = now - this.lastFailureTime;
54+
const backoff = Math.min(this.baseDelay * Math.pow(2, this.failureCount - 1), this.maxDelay);
55+
return elapsed <= backoff;
56+
},
57+
recordSuccess(): void {
58+
if (this.failureCount > 0) console.log('Error reporting API recovered');
59+
this.failureCount = 0;
60+
this.isOpen = false;
61+
},
62+
recordFailure(): void {
63+
this.failureCount++;
64+
this.lastFailureTime = Date.now();
65+
if (this.failureCount >= this.failureThreshold) {
66+
this.isOpen = true;
67+
const backoff = Math.min(this.baseDelay * Math.pow(2, this.failureCount - 1), this.maxDelay);
68+
console.log(`Circuit opened after ${this.failureCount} failures. Retrying in ~${Math.round(backoff / 1000)}s`);
69+
}
70+
},
71+
};
72+
73+
let isReportingError = false;
74+
let nestedReportCount = 0;
75+
const MAX_NESTED_REPORTS = 3;
76+
77+
function buildReport(error: Error, opts?: ReportOptions): ErrorReport {
78+
return {
79+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
80+
name: error.name,
81+
message: error.message || String(error),
82+
stack: error.stack || undefined,
83+
cause: (error as any).cause ? String((error as any).cause) : undefined,
84+
timestamp: new Date().toISOString(),
85+
platform: Platform.OS,
86+
isFatal: opts?.isFatal,
87+
componentStack: opts?.componentStack,
88+
tags: opts?.tags,
89+
extra: opts?.extra,
90+
};
91+
}
92+
93+
// Safe JSON stringify preventing circular reference explosions
94+
function safeStringify(value: any, depth = 0, seen = new WeakSet()): string {
95+
try {
96+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
97+
if (seen.has(value)) return '"[Circular]"';
98+
if (depth > 5) return '"[Truncated]"';
99+
seen.add(value);
100+
if (Array.isArray(value)) {
101+
return '[' + value.slice(0, 50).map((v: any) => safeStringify(v, depth + 1, seen)).join(',') + (value.length > 50 ? ',"[+more]"' : '') + ']';
102+
}
103+
const keys = Object.keys(value).slice(0, 50);
104+
const body = keys.map((k: string) => JSON.stringify(k) + ':' + safeStringify((value as any)[k], depth + 1, seen)).join(',');
105+
return '{' + body + (Object.keys(value).length > 50 ? ',"[+more]":"truncated"' : '') + '}';
106+
} catch {
107+
try { return JSON.stringify(String(value)); } catch { return '"[Unserializable]"'; }
108+
}
109+
}
110+
111+
async function sendReportInternal(report: ErrorReport) {
112+
if (isReportingError) {
113+
nestedReportCount++;
114+
if (nestedReportCount >= MAX_NESTED_REPORTS) {
115+
console.log('Nested error reporting limit reached – aborting');
116+
return;
117+
}
118+
return; // ignore additional nested errors
119+
}
120+
121+
if (circuitBreaker.shouldPreventApiCall()) {
122+
console.log('Error reporting circuit open – skipping');
123+
return;
124+
}
125+
126+
console.log('[error-report] sending', {
127+
id: report.id,
128+
name: report.name,
129+
isFatal: report.isFatal,
130+
componentStack: report.componentStack,
131+
tags: report.tags,
132+
});
133+
134+
try {
135+
isReportingError = true;
136+
const resp = await fetch(ERROR_REPORTING_ENDPOINT, {
137+
method: 'POST',
138+
headers: { 'Content-Type': 'application/json' },
139+
body: JSON.stringify(report),
140+
});
141+
if (!resp.ok) {
142+
console.error('[error-report] failed', report.id, resp.status, resp.statusText);
143+
circuitBreaker.recordFailure();
144+
} else {
145+
console.log('[error-report] success', report.id);
146+
circuitBreaker.recordSuccess();
147+
}
148+
} catch (e) {
149+
console.error('[error-report] exception while sending', report.id, e);
150+
circuitBreaker.recordFailure();
151+
} finally {
152+
isReportingError = false;
153+
nestedReportCount = 0;
154+
}
155+
}
156+
157+
export function reportError(maybeError: unknown, options?: ReportOptions): void {
158+
let error: Error;
159+
if (maybeError instanceof Error) {
160+
error = maybeError;
161+
} else if (typeof maybeError === 'string') {
162+
error = new Error(maybeError);
163+
} else {
164+
let serialized = 'Unknown non-Error thrown';
165+
try { serialized = safeStringify(maybeError); } catch {}
166+
error = new Error(serialized);
167+
}
168+
169+
try {
170+
const report = buildReport(error, options);
171+
void sendReportInternal(report);
172+
} catch (internalErr) {
173+
// Final fallback to avoid cascading crashes
174+
try { console.error('[error-report] internal failure', internalErr); } catch {}
175+
}
176+
}
177+
178+
export interface GlobalErrorHandlerOptions {
179+
useSystemAlert?: boolean;
180+
verbose?: boolean;
181+
baseContext?: Partial<ReportOptions>; // merged into every report
182+
}
183+
184+
let configured = false;
185+
186+
export function setupGlobalErrorHandlers(opts: GlobalErrorHandlerOptions = {}) {
187+
if (configured) return;
188+
configured = true;
189+
const { useSystemAlert = false, verbose = true, baseContext = {} } = opts;
190+
191+
// JS global handler (ErrorUtils is provided by RN runtime)
192+
const originalHandler: any = (global as any).ErrorUtils?.getGlobalHandler?.();
193+
(global as any).ErrorUtils?.setGlobalHandler?.((maybeError: any, isFatal?: boolean) => {
194+
const error = maybeError instanceof Error ? maybeError : new Error(typeof maybeError === 'string' ? maybeError : JSON.stringify(maybeError) || 'Unknown error');
195+
if (verbose) console.error('[GlobalError]', error, 'isFatal:', isFatal);
196+
reportError(error, { isFatal, componentStack: 'global.js', ...baseContext });
197+
if (isFatal) showFatalErrorNotification(error);
198+
if (useSystemAlert) {
199+
try { Alert.alert('Unexpected Error', 'The app hit a problem but will try to continue.'); } catch {}
200+
} else if (isFatal) {
201+
notifyError(error, 'A fatal error occurred. The app may be unstable.');
202+
}
203+
// Call original so RN red screen still appears in dev
204+
try { originalHandler?.(maybeError, isFatal); } catch {}
205+
});
206+
207+
// We can't directly hook native crashes without a module; rely on JS side reporting only.
208+
if (verbose) console.log('[ErrorHandler] Global handlers initialized (JS only)');
209+
}
210+
211+
export function simulateGlobalError(message = 'Simulated error') {
212+
throw new Error(message);
213+
}
214+
215+
// Safer async variant that lets the global handler catch it without an immediate red screen
216+
export function simulateAsyncGlobalError(message = 'Simulated async error') {
217+
setTimeout(() => {
218+
throw new Error(message);
219+
}, 0);
220+
}
221+
222+
// Single helper to raise a native exception via rn-test-exception-handler
223+
export function triggerNativeTestException() {
224+
// Simulate a fatal-style error path; we can't raise a true native crash without a helper lib.
225+
console.warn('[NativeTest] Simulating native exception (library removed).');
226+
// Use setTimeout so global handler catches it like an async native callback.
227+
setTimeout(() => {
228+
throw new Error('Simulated Native Exception (stub)');
229+
}, 0);
230+
}

0 commit comments

Comments
 (0)