Skip to content

Commit e0eb46f

Browse files
committed
feat: centralized error handling, boundaries, retry, and support playbook
- Add lib/errors.ts: unified error code → user-safe message dictionary (i18n-ready), AppError with correlation ID, resolveErrorMessage/getCorrelationId helpers - Add lib/api/fetch.ts: shared apiFetch wrapper extracting x-request-id from headers - Add lib/retry.ts: withRetry() exponential backoff + jitter for idempotent reads - Add components/error-boundary.tsx: class-based ErrorBoundary, dev collapsible stack, prod hides traces, forwards anonymized events to observability - Add components/route-error.tsx: shared render for Next.js error.tsx files - Add app/{claims,policy,quote,support}/error.tsx: per-segment error boundaries - Add components/ui/inline-error.tsx: standardized inline error for forms with retry - Add lib/hooks/use-error-toast.ts: useErrorToast() standardizes destructive toasts - Update http-exception.filter.ts: Stellar/Soroban error code normalization map - Add docs/ops/error-support-playbook.md: correlation ID lookup, error code table, Stellar tx debugging, escalation path, PII policy for observability Never surfaces private keys, seeds, or signed XDR in user-facing messages.
1 parent db900f4 commit e0eb46f

14 files changed

Lines changed: 588 additions & 3 deletions

File tree

backend/src/common/filters/http-exception.filter.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@ import {
88
} from '@nestjs/common';
99
import { Request, Response } from 'express';
1010

11+
/**
12+
* Maps Stellar / Soroban error strings to stable API error codes.
13+
* Keeps raw blockchain internals out of client-facing responses.
14+
*/
15+
const STELLAR_ERROR_MAP: Record<string, string> = {
16+
tx_failed: 'TRANSACTION_FAILED',
17+
tx_bad_auth: 'SIGNATURE_INVALID',
18+
tx_insufficient_fee: 'INSUFFICIENT_FEE',
19+
tx_no_account: 'INVALID_WALLET_ADDRESS',
20+
op_no_trust: 'TRANSACTION_FAILED',
21+
op_underfunded: 'INSUFFICIENT_BALANCE',
22+
ledgerClosed: 'LEDGER_CLOSED',
23+
timeout: 'TIMEOUT_ERROR',
24+
};
25+
26+
function normalizeStellarError(raw: string): string | undefined {
27+
const lower = raw.toLowerCase();
28+
for (const [key, code] of Object.entries(STELLAR_ERROR_MAP)) {
29+
if (lower.includes(key.toLowerCase())) return code;
30+
}
31+
return undefined;
32+
}
33+
1134
@Catch()
1235
export class HttpExceptionFilter implements ExceptionFilter {
1336
private readonly logger = new Logger(HttpExceptionFilter.name);
@@ -22,11 +45,17 @@ export class HttpExceptionFilter implements ExceptionFilter {
2245
? exception.getStatus()
2346
: HttpStatus.INTERNAL_SERVER_ERROR;
2447

25-
const message =
48+
const rawResponse =
2649
exception instanceof HttpException
2750
? exception.getResponse()
2851
: 'Internal server error';
2952

53+
// Normalize Stellar error codes when present
54+
let errorCode: string | undefined;
55+
if (exception instanceof Error) {
56+
errorCode = normalizeStellarError(exception.message);
57+
}
58+
3059
// Log 5xx errors with stack trace; 4xx are client errors — debug level
3160
if (status >= 500) {
3261
this.logger.error(
@@ -41,8 +70,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
4170
statusCode: status,
4271
timestamp: new Date().toISOString(),
4372
path: request.url,
44-
requestId: request.requestId, // propagate correlation ID into error body
45-
message,
73+
requestId: request.requestId, // correlation ID for support escalation
74+
...(errorCode ? { error: errorCode } : {}),
75+
message:
76+
typeof rawResponse === 'string'
77+
? rawResponse
78+
: (rawResponse as Record<string, unknown>).message ?? rawResponse,
4679
});
4780
}
4881
}

docs/ops/error-support-playbook.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Error & Support Playbook
2+
3+
## Overview
4+
5+
Every API error response includes a `requestId` (correlation ID) that links the
6+
user-facing error to a specific backend log entry. Support agents should always
7+
ask users for this reference before escalating.
8+
9+
---
10+
11+
## 1. Finding a Correlation ID
12+
13+
### From the UI
14+
Users see the reference in error toasts and inline error messages:
15+
> "Something went wrong. (Ref: `req_abc123`)"
16+
17+
### From the API response body
18+
```json
19+
{
20+
"statusCode": 500,
21+
"requestId": "req_abc123",
22+
"error": "SERVER_ERROR",
23+
"message": "Internal server error"
24+
}
25+
```
26+
27+
### From response headers
28+
```
29+
x-request-id: req_abc123
30+
```
31+
32+
---
33+
34+
## 2. Log Lookup
35+
36+
### NestJS backend (structured JSON logs)
37+
```bash
38+
# Grep by requestId in production logs
39+
grep '"requestId":"req_abc123"' /var/log/app/app.log
40+
41+
# Or with jq
42+
cat /var/log/app/app.log | jq 'select(.requestId == "req_abc123")'
43+
```
44+
45+
### Grafana / Loki
46+
```logql
47+
{app="niffyinsur-backend"} |= "req_abc123"
48+
```
49+
50+
---
51+
52+
## 3. Error Code Reference
53+
54+
| Code | HTTP | Meaning | Action |
55+
|------|------|---------|--------|
56+
| `UNAUTHORIZED` | 401 | Session expired | Ask user to reconnect wallet |
57+
| `FORBIDDEN` | 403 | Insufficient role | Verify user permissions |
58+
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests | Wait and retry; check for abuse |
59+
| `TRANSACTION_FAILED` | 400 | Stellar tx rejected | Check Stellar explorer with tx hash |
60+
| `SIGNATURE_INVALID` | 400 | Bad wallet signature | Ask user to retry signing |
61+
| `INSUFFICIENT_BALANCE` | 400 | Not enough XLM/token | User needs to fund wallet |
62+
| `LEDGER_CLOSED` | 400 | Tx missed ledger window | Resubmit transaction |
63+
| `SOROBAN_RPC_ERROR` | 502 | RPC node issue | Check RPC node health; retry |
64+
| `OPEN_CLAIM_EXISTS` | 409 | Duplicate claim attempt | Explain existing open claim |
65+
| `SERVER_ERROR` | 500 | Unhandled exception | Escalate with requestId |
66+
67+
---
68+
69+
## 4. Stellar Transaction Debugging
70+
71+
1. Extract `transactionHash` from the error details or user report.
72+
2. Look up on Stellar Expert:
73+
- Testnet: `https://stellar.expert/explorer/testnet/tx/<hash>`
74+
- Mainnet: `https://stellar.expert/explorer/public/tx/<hash>`
75+
3. Check the result code (e.g. `tx_failed`, `op_underfunded`).
76+
4. Map to the error code table above.
77+
78+
> **Security**: Never ask users to share private keys, seed phrases, or signed
79+
> XDR outside of a verified secure developer tool. These are never required for
80+
> support escalation.
81+
82+
---
83+
84+
## 5. Escalation Path
85+
86+
1. **Tier 1** — User self-service: retry button in UI, reconnect wallet.
87+
2. **Tier 2** — Support agent: collect `requestId`, look up logs, check Stellar explorer.
88+
3. **Tier 3** — Engineering: provide `requestId` + full log context + Stellar tx hash.
89+
90+
---
91+
92+
## 6. PII Policy for Error Events
93+
94+
Anonymized error events forwarded to observability tools **must not** include:
95+
- Wallet addresses (truncate to first 6 + last 4 chars if needed for grouping)
96+
- Email addresses
97+
- IP addresses (hash or omit)
98+
- Private keys, seeds, or signed XDR (never, under any circumstances)
99+
100+
See `backend/src/maintenance/privacy.service.ts` for the data-scrubbing implementation.

frontend/src/app/claims/error.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use client';
2+
import { RouteError } from '@/components/route-error';
3+
export default function ClaimsError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
4+
return <RouteError error={error} reset={reset} area="Claims Board" />;
5+
}

frontend/src/app/policy/error.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use client';
2+
import { RouteError } from '@/components/route-error';
3+
export default function PolicyError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
4+
return <RouteError error={error} reset={reset} area="Policy" />;
5+
}

frontend/src/app/quote/error.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use client';
2+
import { RouteError } from '@/components/route-error';
3+
export default function QuoteError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
4+
return <RouteError error={error} reset={reset} area="Quote" />;
5+
}

frontend/src/app/support/error.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use client';
2+
import { RouteError } from '@/components/route-error';
3+
export default function SupportError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
4+
return <RouteError error={error} reset={reset} area="Support" />;
5+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { AlertTriangle, RefreshCw } from 'lucide-react';
5+
import { Button } from '@/components/ui/button';
6+
import { resolveErrorMessage, getCorrelationId } from '@/lib/errors';
7+
8+
interface Props {
9+
children: React.ReactNode;
10+
/** Shown in the fallback heading, e.g. "Claims Board" */
11+
area?: string;
12+
}
13+
14+
interface State {
15+
error: unknown;
16+
hasError: boolean;
17+
}
18+
19+
/**
20+
* Error boundary for a major feature area.
21+
*
22+
* - In development: shows a collapsible technical details panel.
23+
* - In production: shows only a user-safe message + correlation ID.
24+
* - Never renders private keys, seeds, or raw XDR.
25+
*/
26+
export class ErrorBoundary extends React.Component<Props, State> {
27+
state: State = { hasError: false, error: null };
28+
29+
static getDerivedStateFromError(error: unknown): State {
30+
return { hasError: true, error };
31+
}
32+
33+
componentDidCatch(error: unknown, info: React.ErrorInfo) {
34+
// Forward anonymized event to observability (no PII, no stack in prod)
35+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
36+
const correlationId = getCorrelationId(error);
37+
// Replace with your observability SDK call, e.g. Sentry.captureException
38+
console.error('[ErrorBoundary]', {
39+
area: this.props.area,
40+
correlationId,
41+
componentStack: info.componentStack?.slice(0, 200), // truncate
42+
});
43+
} else {
44+
console.error('[ErrorBoundary dev]', error, info);
45+
}
46+
}
47+
48+
reset = () => this.setState({ hasError: false, error: null });
49+
50+
render() {
51+
if (!this.state.hasError) return this.props.children;
52+
53+
const { error } = this.state;
54+
const userMessage = resolveErrorMessage(error);
55+
const correlationId = getCorrelationId(error);
56+
const isDev = process.env.NODE_ENV !== 'production';
57+
58+
return (
59+
<div
60+
role="alert"
61+
className="flex flex-col items-center justify-center min-h-[200px] p-8 text-center gap-4"
62+
>
63+
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden />
64+
<div>
65+
<p className="font-semibold text-lg">
66+
{this.props.area ? `${this.props.area} unavailable` : 'Something went wrong'}
67+
</p>
68+
<p className="text-muted-foreground text-sm mt-1">{userMessage}</p>
69+
{correlationId && (
70+
<p className="text-xs text-muted-foreground mt-2">
71+
Support reference:{' '}
72+
<code className="font-mono">{correlationId}</code>
73+
</p>
74+
)}
75+
</div>
76+
77+
{isDev && error instanceof Error && (
78+
<details className="w-full max-w-xl text-left text-xs border rounded p-3 bg-muted">
79+
<summary className="cursor-pointer font-medium">Technical details (dev only)</summary>
80+
<pre className="mt-2 whitespace-pre-wrap break-all opacity-80">
81+
{error.stack ?? error.message}
82+
</pre>
83+
</details>
84+
)}
85+
86+
<Button variant="outline" size="sm" onClick={this.reset}>
87+
<RefreshCw className="h-4 w-4 mr-2" aria-hidden />
88+
Try again
89+
</Button>
90+
</div>
91+
);
92+
}
93+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { AlertTriangle, RefreshCw } from 'lucide-react';
5+
import { Button } from '@/components/ui/button';
6+
import { resolveErrorMessage, getCorrelationId } from '@/lib/errors';
7+
8+
interface Props {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
area: string;
12+
}
13+
14+
/**
15+
* Shared render for Next.js route-level error.tsx files.
16+
* Keeps all feature-area error UIs consistent.
17+
*/
18+
export function RouteError({ error, reset, area }: Props) {
19+
const isDev = process.env.NODE_ENV !== 'production';
20+
const correlationId = getCorrelationId(error) ?? error.digest;
21+
22+
useEffect(() => {
23+
if (process.env.NODE_ENV === 'production') {
24+
// Forward anonymized event — replace with your observability SDK
25+
console.error('[RouteError]', { area, correlationId });
26+
} else {
27+
console.error('[RouteError dev]', error);
28+
}
29+
}, [error, area, correlationId]);
30+
31+
return (
32+
<div
33+
role="alert"
34+
className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center gap-4"
35+
>
36+
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden />
37+
<div>
38+
<p className="font-semibold text-lg">{area} unavailable</p>
39+
<p className="text-muted-foreground text-sm mt-1">{resolveErrorMessage(error)}</p>
40+
{correlationId && (
41+
<p className="text-xs text-muted-foreground mt-2">
42+
Support reference: <code className="font-mono">{correlationId}</code>
43+
</p>
44+
)}
45+
</div>
46+
47+
{isDev && (
48+
<details className="w-full max-w-xl text-left text-xs border rounded p-3 bg-muted">
49+
<summary className="cursor-pointer font-medium">Technical details (dev only)</summary>
50+
<pre className="mt-2 whitespace-pre-wrap break-all opacity-80">
51+
{error.stack ?? error.message}
52+
</pre>
53+
</details>
54+
)}
55+
56+
<Button variant="outline" size="sm" onClick={reset}>
57+
<RefreshCw className="h-4 w-4 mr-2" aria-hidden />
58+
Try again
59+
</Button>
60+
</div>
61+
);
62+
}

frontend/src/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export {
4848
} from './card'
4949
export { Badge, badgeVariants } from './badge'
5050
export { Stepper, StepContent, type Step } from './stepper'
51+
export { InlineError } from './inline-error'
5152
export { VoteEducationPanel } from '../claims/vote-education-panel'
5253
export { VoteTally } from '../claims/vote-tally'
5354
export { VoteConfirmModal } from '../claims/vote-confirm-modal'
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client';
2+
3+
import { AlertCircle, RefreshCw } from 'lucide-react';
4+
import { Button } from '@/components/ui/button';
5+
import { resolveErrorMessage, getCorrelationId } from '@/lib/errors';
6+
7+
interface InlineErrorProps {
8+
error: unknown;
9+
/** If provided, renders a retry button */
10+
onRetry?: () => void;
11+
className?: string;
12+
}
13+
14+
/**
15+
* Standardized inline error for forms and data-fetch sections.
16+
* Shows user-safe message + optional correlation ID + optional retry button.
17+
*/
18+
export function InlineError({ error, onRetry, className }: InlineErrorProps) {
19+
const message = resolveErrorMessage(error);
20+
const correlationId = getCorrelationId(error);
21+
22+
return (
23+
<div
24+
role="alert"
25+
className={`flex items-start gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm ${className ?? ''}`}
26+
>
27+
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-destructive" aria-hidden />
28+
<div className="flex-1 min-w-0">
29+
<p className="text-destructive font-medium">{message}</p>
30+
{correlationId && (
31+
<p className="text-xs text-muted-foreground mt-0.5">
32+
Ref: <code className="font-mono">{correlationId}</code>
33+
</p>
34+
)}
35+
</div>
36+
{onRetry && (
37+
<Button variant="ghost" size="sm" onClick={onRetry} className="shrink-0 h-7 px-2">
38+
<RefreshCw className="h-3 w-3 mr-1" aria-hidden />
39+
Retry
40+
</Button>
41+
)}
42+
</div>
43+
);
44+
}

0 commit comments

Comments
 (0)