@buivietphi/skill-mobile-mt 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +83 -45
- package/README.md +55 -102
- package/SKILL.md +333 -46
- package/package.json +1 -1
- package/shared/code-generation-templates.md +656 -0
- package/shared/code-review.md +8 -8
- package/shared/complex-ui-patterns.md +526 -0
- package/shared/data-flow-patterns.md +422 -0
- package/shared/error-handling.md +394 -0
- package/shared/intent-analysis.md +473 -0
- package/shared/navigation-patterns.md +375 -0
- package/shared/spec-to-code.md +293 -0
- package/shared/testing-patterns.md +428 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Error Handling — Resilient Mobile Code
|
|
2
|
+
|
|
3
|
+
> On-demand module. Loaded when implementing error handling, retry strategies, error boundaries, or user-facing error messages.
|
|
4
|
+
> Contains patterns that prevent crashes and provide graceful degradation.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Error Type Hierarchy
|
|
9
|
+
|
|
10
|
+
### React Native / TypeScript
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// errors/AppError.ts
|
|
14
|
+
|
|
15
|
+
export class AppError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly isRetryable: boolean;
|
|
18
|
+
readonly statusCode?: number;
|
|
19
|
+
|
|
20
|
+
constructor(message: string, code: string, isRetryable: boolean, statusCode?: number) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'AppError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.isRetryable = isRetryable;
|
|
25
|
+
this.statusCode = statusCode;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class NetworkError extends AppError {
|
|
30
|
+
constructor(message = 'Network connection failed') {
|
|
31
|
+
super(message, 'NETWORK_ERROR', true);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TimeoutError extends AppError {
|
|
36
|
+
constructor(message = 'Request timed out') {
|
|
37
|
+
super(message, 'TIMEOUT', true);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AuthError extends AppError {
|
|
42
|
+
constructor(message = 'Authentication failed', code = 'AUTH_ERROR') {
|
|
43
|
+
super(message, code, false, 401);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ForbiddenError extends AppError {
|
|
48
|
+
constructor(message = 'Access denied') {
|
|
49
|
+
super(message, 'FORBIDDEN', false, 403);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ValidationError extends AppError {
|
|
54
|
+
readonly fieldErrors: Record<string, string[]>;
|
|
55
|
+
|
|
56
|
+
constructor(message: string, fieldErrors: Record<string, string[]> = {}) {
|
|
57
|
+
super(message, 'VALIDATION', false, 422);
|
|
58
|
+
this.fieldErrors = fieldErrors;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ServerError extends AppError {
|
|
63
|
+
constructor(message = 'Server error', statusCode = 500) {
|
|
64
|
+
super(message, 'SERVER_ERROR', true, statusCode);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class NotFoundError extends AppError {
|
|
69
|
+
constructor(message = 'Not found') {
|
|
70
|
+
super(message, 'NOT_FOUND', false, 404);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class RateLimitError extends AppError {
|
|
75
|
+
readonly retryAfter: number; // seconds
|
|
76
|
+
|
|
77
|
+
constructor(retryAfter = 60) {
|
|
78
|
+
super('Too many requests', 'RATE_LIMIT', true, 429);
|
|
79
|
+
this.retryAfter = retryAfter;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Error Normalizer (from API responses)
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// errors/normalizeError.ts
|
|
88
|
+
import { AxiosError } from 'axios';
|
|
89
|
+
|
|
90
|
+
export function normalizeError(error: unknown): AppError {
|
|
91
|
+
if (error instanceof AppError) return error;
|
|
92
|
+
|
|
93
|
+
if (error instanceof AxiosError) {
|
|
94
|
+
// No response — network error
|
|
95
|
+
if (!error.response) {
|
|
96
|
+
if (error.code === 'ECONNABORTED') return new TimeoutError();
|
|
97
|
+
return new NetworkError();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { status, data } = error.response;
|
|
101
|
+
|
|
102
|
+
switch (status) {
|
|
103
|
+
case 401: return new AuthError(data?.message);
|
|
104
|
+
case 403: return new ForbiddenError(data?.message);
|
|
105
|
+
case 404: return new NotFoundError(data?.message);
|
|
106
|
+
case 422: return new ValidationError(data?.message, data?.errors);
|
|
107
|
+
case 429: return new RateLimitError(parseInt(error.response.headers['retry-after'] || '60'));
|
|
108
|
+
default:
|
|
109
|
+
if (status >= 500) return new ServerError(data?.message, status);
|
|
110
|
+
return new AppError(data?.message || 'Unknown error', 'UNKNOWN', false, status);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (error instanceof Error) {
|
|
115
|
+
return new AppError(error.message, 'UNKNOWN', false);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new AppError('An unexpected error occurred', 'UNKNOWN', false);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## User-Facing Error Messages
|
|
125
|
+
|
|
126
|
+
### Error Message Mapper
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// errors/errorMessages.ts
|
|
130
|
+
|
|
131
|
+
const userMessages: Record<string, string> = {
|
|
132
|
+
NETWORK_ERROR: 'No internet connection. Please check your network and try again.',
|
|
133
|
+
TIMEOUT: 'The request is taking too long. Please try again.',
|
|
134
|
+
AUTH_ERROR: 'Your session has expired. Please log in again.',
|
|
135
|
+
FORBIDDEN: "You don't have permission to do this.",
|
|
136
|
+
NOT_FOUND: 'The item you are looking for no longer exists.',
|
|
137
|
+
VALIDATION: 'Please check your input and try again.',
|
|
138
|
+
SERVER_ERROR: 'Something went wrong on our end. Please try again later.',
|
|
139
|
+
RATE_LIMIT: 'Too many requests. Please wait a moment and try again.',
|
|
140
|
+
UNKNOWN: 'Something unexpected happened. Please try again.',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function getUserMessage(error: AppError): string {
|
|
144
|
+
return userMessages[error.code] ?? userMessages.UNKNOWN;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getRetryLabel(error: AppError): string | null {
|
|
148
|
+
if (!error.isRetryable) return null;
|
|
149
|
+
if (error instanceof RateLimitError) return `Retry in ${error.retryAfter}s`;
|
|
150
|
+
return 'Try Again';
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Error Display Component
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// components/ErrorView.tsx
|
|
158
|
+
interface Props {
|
|
159
|
+
error: AppError | Error;
|
|
160
|
+
onRetry?: () => void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function ErrorView({ error, onRetry }: Props) {
|
|
164
|
+
const appError = error instanceof AppError ? error : normalizeError(error);
|
|
165
|
+
const message = getUserMessage(appError);
|
|
166
|
+
const retryLabel = getRetryLabel(appError);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<View style={styles.container}>
|
|
170
|
+
<ErrorIcon size={48} color={colors.error} />
|
|
171
|
+
<Text style={styles.message}>{message}</Text>
|
|
172
|
+
{retryLabel && onRetry && (
|
|
173
|
+
<Button title={retryLabel} onPress={onRetry} />
|
|
174
|
+
)}
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Global Error Handling
|
|
183
|
+
|
|
184
|
+
### React Native — Error Boundary
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// components/ErrorBoundary.tsx
|
|
188
|
+
import React from 'react';
|
|
189
|
+
|
|
190
|
+
interface State {
|
|
191
|
+
hasError: boolean;
|
|
192
|
+
error: Error | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export class ErrorBoundary extends React.Component<
|
|
196
|
+
{ children: React.ReactNode; fallback?: React.ReactNode },
|
|
197
|
+
State
|
|
198
|
+
> {
|
|
199
|
+
state: State = { hasError: false, error: null };
|
|
200
|
+
|
|
201
|
+
static getDerivedStateFromError(error: Error): State {
|
|
202
|
+
return { hasError: true, error };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
206
|
+
// Log to crash reporting service
|
|
207
|
+
crashReporting.recordError(error, { componentStack: info.componentStack });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resetError = () => {
|
|
211
|
+
this.setState({ hasError: false, error: null });
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
render() {
|
|
215
|
+
if (this.state.hasError) {
|
|
216
|
+
return this.props.fallback ?? (
|
|
217
|
+
<View style={styles.center}>
|
|
218
|
+
<Text>Something went wrong</Text>
|
|
219
|
+
<Button title="Try Again" onPress={this.resetError} />
|
|
220
|
+
</View>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return this.props.children;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Wrap at app root:
|
|
228
|
+
// <ErrorBoundary>
|
|
229
|
+
// <App />
|
|
230
|
+
// </ErrorBoundary>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Unhandled Promise Rejection Handler
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// app/_layout.tsx or App.tsx — setup once at root
|
|
237
|
+
import { LogBox } from 'react-native';
|
|
238
|
+
|
|
239
|
+
// Capture unhandled promise rejections
|
|
240
|
+
if (__DEV__) {
|
|
241
|
+
// Show in dev console
|
|
242
|
+
LogBox.ignoreLogs(['Require cycle:']);
|
|
243
|
+
} else {
|
|
244
|
+
// In production: log to crash service, don't crash the app
|
|
245
|
+
const originalHandler = ErrorUtils.getGlobalHandler();
|
|
246
|
+
ErrorUtils.setGlobalHandler((error, isFatal) => {
|
|
247
|
+
crashReporting.recordError(error, { isFatal });
|
|
248
|
+
if (!isFatal) return; // non-fatal: swallow
|
|
249
|
+
originalHandler(error, isFatal); // fatal: let RN handle
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Flutter — Global Error Handler
|
|
255
|
+
|
|
256
|
+
```dart
|
|
257
|
+
// main.dart
|
|
258
|
+
void main() {
|
|
259
|
+
FlutterError.onError = (details) {
|
|
260
|
+
FlutterError.presentError(details);
|
|
261
|
+
// Log to Crashlytics/Sentry
|
|
262
|
+
crashReporting.recordFlutterError(details);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
PlatformDispatcher.instance.onError = (error, stack) {
|
|
266
|
+
crashReporting.recordError(error, stack);
|
|
267
|
+
return true; // handled
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
runApp(const MyApp());
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Retry Strategies
|
|
277
|
+
|
|
278
|
+
### Exponential Backoff with Jitter
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// utils/retry.ts
|
|
282
|
+
interface RetryOptions {
|
|
283
|
+
maxRetries?: number;
|
|
284
|
+
baseDelay?: number;
|
|
285
|
+
maxDelay?: number;
|
|
286
|
+
shouldRetry?: (error: AppError, attempt: number) => boolean;
|
|
287
|
+
onRetry?: (attempt: number, delay: number) => void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function withRetry<T>(
|
|
291
|
+
fn: () => Promise<T>,
|
|
292
|
+
options: RetryOptions = {}
|
|
293
|
+
): Promise<T> {
|
|
294
|
+
const {
|
|
295
|
+
maxRetries = 3,
|
|
296
|
+
baseDelay = 1000,
|
|
297
|
+
maxDelay = 30000,
|
|
298
|
+
shouldRetry = (error) => error.isRetryable,
|
|
299
|
+
onRetry,
|
|
300
|
+
} = options;
|
|
301
|
+
|
|
302
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
303
|
+
try {
|
|
304
|
+
return await fn();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const appError = normalizeError(error);
|
|
307
|
+
|
|
308
|
+
if (attempt === maxRetries || !shouldRetry(appError, attempt)) {
|
|
309
|
+
throw appError;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Exponential backoff: 1s, 2s, 4s + random jitter
|
|
313
|
+
const delay = Math.min(
|
|
314
|
+
baseDelay * Math.pow(2, attempt) + Math.random() * 500,
|
|
315
|
+
maxDelay
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
onRetry?.(attempt + 1, delay);
|
|
319
|
+
await new Promise(r => setTimeout(r, delay));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
throw new AppError('Max retries exceeded', 'MAX_RETRIES', false);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Usage:
|
|
327
|
+
// const data = await withRetry(() => api.get('/products'), {
|
|
328
|
+
// maxRetries: 3,
|
|
329
|
+
// onRetry: (attempt) => console.log(`Retry #${attempt}`),
|
|
330
|
+
// });
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Rate Limit Handling
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// Automatic rate limit handling in API interceptor
|
|
337
|
+
api.interceptors.response.use(
|
|
338
|
+
(response) => response,
|
|
339
|
+
async (error: AxiosError) => {
|
|
340
|
+
if (error.response?.status === 429) {
|
|
341
|
+
const retryAfter = parseInt(error.response.headers['retry-after'] || '5');
|
|
342
|
+
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
|
343
|
+
return api(error.config!);
|
|
344
|
+
}
|
|
345
|
+
return Promise.reject(error);
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Toast / Snackbar Error Notifications
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
// hooks/useToast.ts
|
|
356
|
+
import { useCallback } from 'react';
|
|
357
|
+
|
|
358
|
+
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
359
|
+
|
|
360
|
+
// Simple toast context (or use a library like react-native-toast-message)
|
|
361
|
+
export function useToast() {
|
|
362
|
+
const show = useCallback((message: string, type: ToastType = 'info') => {
|
|
363
|
+
// Use your preferred toast library
|
|
364
|
+
Toast.show({ type, text1: message, visibilityTime: 4000 });
|
|
365
|
+
}, []);
|
|
366
|
+
|
|
367
|
+
const showError = useCallback((error: unknown) => {
|
|
368
|
+
const appError = normalizeError(error);
|
|
369
|
+
show(getUserMessage(appError), 'error');
|
|
370
|
+
}, [show]);
|
|
371
|
+
|
|
372
|
+
return { show, showError };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Usage in mutation:
|
|
376
|
+
// const toast = useToast();
|
|
377
|
+
// try { await addToCart(product.id); toast.show('Added to cart', 'success'); }
|
|
378
|
+
// catch (e) { toast.showError(e); }
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Error Recovery Patterns
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
NETWORK ERROR → Show offline banner + retry button + queue mutations
|
|
387
|
+
AUTH ERROR (401) → Auto-refresh token → if fail → redirect to login
|
|
388
|
+
FORBIDDEN (403) → Show "Access denied" + navigate back
|
|
389
|
+
NOT FOUND (404) → Show "Not found" + navigate back
|
|
390
|
+
VALIDATION (422) → Highlight form fields with errors
|
|
391
|
+
SERVER ERROR (5xx) → Show retry button + log to crash service
|
|
392
|
+
RATE LIMIT (429) → Show countdown timer + auto-retry after delay
|
|
393
|
+
TIMEOUT → Retry with longer timeout (max 3 attempts)
|
|
394
|
+
```
|