Skip to content
Merged
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
16 changes: 16 additions & 0 deletions backend/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,19 @@ export class RateLimitError extends AppError {
super('Too Many Requests', 429, detail, 'https://syncro.app/errors/too-many-requests', { retryAfter });
}
}

/**
* Thrown when an external dependency (Redis, email provider, payment gateway, etc.)
* is unavailable or returns an unexpected error (HTTP 502).
*/
export class ExternalDependencyError extends AppError {
constructor(detail: string, public dependency: string) {
super(
'External Dependency Error',
502,
detail,
'https://syncro.app/errors/external-dependency',
{ dependency }
);
}
}
11 changes: 10 additions & 1 deletion backend/src/lib/redis-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,21 @@ export class RateLimitRedisStore {
return this.isConnected && this.store !== null;
}

/**
* Returns true when Redis was configured but is currently unavailable,
* meaning the system is operating in degraded mode with a memory fallback.
*/
isDegraded(): boolean {
return !!(rateLimitConfig.redis.enabled && rateLimitConfig.redis.url && !this.isConnected);
}

/**
* Get Redis connection health status
*/
getHealthStatus(): { connected: boolean; reconnectAttempts: number; error?: string } {
getHealthStatus(): { connected: boolean; degraded: boolean; reconnectAttempts: number; error?: string } {
return {
connected: this.isConnected,
degraded: this.isDegraded(),
reconnectAttempts: this.reconnectAttempts,
error: this.isConnected ? undefined : 'Redis connection unavailable',
};
Expand Down
8 changes: 6 additions & 2 deletions backend/src/middleware/rate-limit-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,16 @@ export class RateLimiterFactory {
}

/**
* Get Redis store status for health monitoring
* Get Redis store status for health monitoring.
* When `degraded` is true the app is running with the in-memory fallback
* because Redis was configured but is currently unreachable.
*/
static getStoreStatus(): { type: 'redis' | 'memory'; available: boolean } {
static getStoreStatus(): { type: 'redis' | 'memory'; available: boolean; degraded: boolean } {
const degraded = this.redisStoreInitialized && !this.redisStore;
return {
type: this.redisStore ? 'redis' : 'memory',
available: this.redisStoreInitialized,
degraded,
};
}
}
Expand Down
156 changes: 156 additions & 0 deletions backend/tests/error-taxonomy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { createServer } from 'http';
import express, { Request, Response } from 'express';
import request from 'supertest';
import {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
ForbiddenError,
ConflictError,
BadRequestError,
RateLimitError,
ExternalDependencyError,
} from '../src/errors';
import { errorHandler } from '../src/middleware/errorHandler';

function buildApp(thrower: (req: Request, res: Response, next: any) => void) {
const app = express();
app.use(express.json());
app.get('/test', thrower);
app.use(errorHandler);
return app;
}

describe('error taxonomy – class shapes', () => {
it('NotFoundError has correct status and type', () => {
const err = new NotFoundError('Widget not found');
expect(err.status).toBe(404);
expect(err.title).toBe('Not Found');
expect(err.type).toBe('https://syncro.app/errors/not-found');
expect(err.detail).toBe('Widget not found');
expect(err).toBeInstanceOf(AppError);
});

it('ValidationError carries field errors', () => {
const err = new ValidationError('Invalid input', { email: ['must be valid'] });
expect(err.status).toBe(400);
expect(err.errors).toEqual({ email: ['must be valid'] });
});

it('UnauthorizedError defaults message', () => {
const err = new UnauthorizedError();
expect(err.status).toBe(401);
expect(err.detail).toBe('Authentication required.');
});

it('ForbiddenError defaults message', () => {
const err = new ForbiddenError();
expect(err.status).toBe(403);
expect(err.detail).toBe('Access denied.');
});

it('ConflictError has 409 status', () => {
const err = new ConflictError('Resource already exists');
expect(err.status).toBe(409);
expect(err.type).toBe('https://syncro.app/errors/conflict');
});

it('RateLimitError carries retryAfter', () => {
const err = new RateLimitError('Slow down', 60);
expect(err.status).toBe(429);
expect(err.retryAfter).toBe(60);
expect(err.extensions).toMatchObject({ retryAfter: 60 });
});

it('ExternalDependencyError has 502 status and dependency field', () => {
const err = new ExternalDependencyError('Stripe is unreachable', 'stripe');
expect(err.status).toBe(502);
expect(err.dependency).toBe('stripe');
expect(err.type).toBe('https://syncro.app/errors/external-dependency');
expect(err).toBeInstanceOf(AppError);
});
});

describe('error taxonomy – HTTP response shapes via errorHandler', () => {
it('NotFoundError → 404 Problem Details', async () => {
const app = buildApp((_req, _res, next) => next(new NotFoundError('Widget not found')));
const res = await request(app).get('/test');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
expect(res.body).toMatchObject({
type: 'https://syncro.app/errors/not-found',
title: 'Not Found',
status: 404,
detail: 'Widget not found',
});
});

it('ValidationError → 400 with errors array', async () => {
const app = buildApp((_req, _res, next) =>
next(new ValidationError('Bad input', { name: ['required'] }))
);
const res = await request(app).get('/test');
expect(res.status).toBe(400);
expect(res.body).toMatchObject({
type: 'https://syncro.app/errors/validation',
status: 400,
errors: { name: ['required'] },
});
});

it('UnauthorizedError → 401', async () => {
const app = buildApp((_req, _res, next) => next(new UnauthorizedError()));
const res = await request(app).get('/test');
expect(res.status).toBe(401);
expect(res.body.status).toBe(401);
});

it('ForbiddenError → 403', async () => {
const app = buildApp((_req, _res, next) => next(new ForbiddenError()));
const res = await request(app).get('/test');
expect(res.status).toBe(403);
expect(res.body.status).toBe(403);
});

it('ConflictError → 409', async () => {
const app = buildApp((_req, _res, next) => next(new ConflictError('Duplicate key')));
const res = await request(app).get('/test');
expect(res.status).toBe(409);
expect(res.body.status).toBe(409);
});

it('RateLimitError → 429 with retryAfter in body', async () => {
const app = buildApp((_req, _res, next) => next(new RateLimitError('Too fast', 30)));
const res = await request(app).get('/test');
expect(res.status).toBe(429);
expect(res.body).toMatchObject({ status: 429, retryAfter: 30 });
});

it('ExternalDependencyError → 502 with dependency in body', async () => {
const app = buildApp((_req, _res, next) =>
next(new ExternalDependencyError('Redis unavailable', 'redis'))
);
const res = await request(app).get('/test');
expect(res.status).toBe(502);
expect(res.body).toMatchObject({
type: 'https://syncro.app/errors/external-dependency',
status: 502,
dependency: 'redis',
});
});

it('unknown error → 500 without leaking internals in production', async () => {
const original = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';

const app = buildApp((_req, _res, next) => next(new Error('secret internal error')));
const res = await request(app).get('/test');

expect(res.status).toBe(500);
expect(res.body.detail).toBe('An unexpected error occurred.');
expect(res.body.detail).not.toContain('secret');

process.env.NODE_ENV = original;
});
});
89 changes: 89 additions & 0 deletions backend/tests/redis-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe('RateLimitRedisStore', () => {

expect(status).toEqual({
connected: false,
degraded: true,
reconnectAttempts: 0,
error: 'Redis connection unavailable',
});
Expand All @@ -155,6 +156,7 @@ describe('RateLimitRedisStore', () => {

expect(status).toEqual({
connected: true,
degraded: false,
reconnectAttempts: 0,
error: undefined,
});
Expand Down Expand Up @@ -187,6 +189,93 @@ describe('RateLimitRedisStore', () => {
expect(reconnectStrategy(4)).toBe(30000); // Capped at 30 seconds
});
});

describe('outage scenarios', () => {
it('isDegraded returns false when redis is not configured', () => {
const { rateLimitConfig } = require('../src/config/rate-limit');
const savedEnabled = rateLimitConfig.redis.enabled;
rateLimitConfig.redis.enabled = false;

const store = RateLimitRedisStore.getInstance();
expect(store.isDegraded()).toBe(false);

rateLimitConfig.redis.enabled = savedEnabled;
});

it('isDegraded returns true when redis is configured but disconnected', () => {
const store = RateLimitRedisStore.getInstance();
// Store is configured (mocked config has enabled: true) but not yet connected
expect(store.isDegraded()).toBe(true);
});

it('isDegraded returns false after successful connection', async () => {
const store = RateLimitRedisStore.getInstance();
await store.initialize();

// Simulate connect event
const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1];
connectHandler();

expect(store.isDegraded()).toBe(false);
});

it('falls back to memory store and is marked degraded on connection failure', async () => {
mockRedisClient.connect.mockRejectedValue(new Error('ECONNREFUSED'));
const store = RateLimitRedisStore.getInstance();

await expect(store.initialize()).rejects.toThrow('ECONNREFUSED');

expect(store.isAvailable()).toBe(false);
expect(store.getStore()).toBeNull();
});

it('falls back silently via createRedisStore when redis is unreachable', async () => {
const mockStoreInstance = {
initialize: jest.fn().mockRejectedValue(new Error('timeout')),
getStore: jest.fn().mockReturnValue(null),
};
jest.spyOn(RateLimitRedisStore, 'getInstance').mockReturnValue(mockStoreInstance as any);

const result = await createRedisStore();

expect(result).toBeUndefined();
});

it('getHealthStatus includes degraded flag', async () => {
const store = RateLimitRedisStore.getInstance();
await store.initialize();

const status = store.getHealthStatus();
expect(status).toHaveProperty('degraded');
expect(typeof status.degraded).toBe('boolean');
});

it('transitions from degraded to healthy on reconnect event', async () => {
const store = RateLimitRedisStore.getInstance();
await store.initialize();

expect(store.isDegraded()).toBe(true);

const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1];
connectHandler();

expect(store.isDegraded()).toBe(false);
expect(store.getHealthStatus().degraded).toBe(false);
});

it('transitions back to degraded after disconnect event', async () => {
const store = RateLimitRedisStore.getInstance();
await store.initialize();

const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1];
connectHandler();
expect(store.isDegraded()).toBe(false);

const disconnectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'disconnect')[1];
disconnectHandler();
expect(store.isDegraded()).toBe(true);
});
});
});

describe('createRedisStore', () => {
Expand Down
33 changes: 33 additions & 0 deletions client/app/dashboard/analytics/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client"

import { useEffect } from "react"
import { AlertTriangle } from "lucide-react"

interface AnalyticsErrorProps {
error: Error & { digest?: string }
reset: () => void
}

export default function AnalyticsError({ error, reset }: AnalyticsErrorProps) {
useEffect(() => {
console.error("Analytics route error:", error)
}, [error])

return (
<div className="flex flex-col items-center justify-center min-h-[40vh] p-8 text-center gap-4">
<AlertTriangle className="h-10 w-10 text-red-500" aria-hidden="true" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Failed to load analytics
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm">
Something went wrong while fetching your spending data. Please try again.
</p>
<button
onClick={reset}
className="mt-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors"
>
Try again
</button>
</div>
)
}
21 changes: 21 additions & 0 deletions client/app/dashboard/analytics/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton"

export default function AnalyticsLoading() {
return (
<div className="p-4 sm:p-8 space-y-8">
{/* Stats row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-6">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 sm:gap-8">
<Skeleton className="h-64 sm:h-80 w-full rounded-xl" />
<Skeleton className="h-64 sm:h-80 w-full rounded-xl" />
</div>
{/* Top subscriptions */}
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
Loading
Loading