Skip to content

Commit f6b9353

Browse files
authored
Merge pull request #444 from Saboleee/staging
This PR implements comprehensive observability and reliability enhancements for the payments and limits services, adding domain event emission
2 parents 34bb12e + 04d174e commit f6b9353

21 files changed

Lines changed: 907 additions & 35 deletions

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,27 +29,30 @@
2929
"@nestjs/common": "^11.0.1",
3030
"@nestjs/config": "^4.0.2",
3131
"@nestjs/core": "^11.0.1",
32+
"@nestjs/event-emitter": "^3.1.0",
3233
"@nestjs/mapped-types": "*",
3334
"@nestjs/platform-express": "^11.0.1",
3435
"@nestjs/terminus": "^11.1.1",
3536
"@nestjs/throttler": "^6.5.0",
3637
"@prisma/adapter-pg": "^7.3.0",
3738
"@prisma/client": "^7.3.0",
39+
"@willsoto/nestjs-prometheus": "^6.1.0",
3840
"axios": "^1.6.0",
3941
"class-transformer": "^0.5.1",
4042
"class-validator": "^0.15.1",
4143
"dotenv": "^17.2.3",
4244
"pg": "^8.17.2",
45+
"prom-client": "^15.1.3",
4346
"reflect-metadata": "^0.2.2",
4447
"rxjs": "^7.8.1",
4548
"stellar-sdk": "^10.2.0"
4649
},
4750
"devDependencies": {
4851
"@eslint/eslintrc": "^3.2.0",
4952
"@eslint/js": "^9.18.0",
50-
"@nestjs/swagger": "^8.0.0",
5153
"@nestjs/cli": "^11.0.0",
5254
"@nestjs/schematics": "^11.0.0",
55+
"@nestjs/swagger": "^8.0.0",
5356
"@nestjs/testing": "^11.0.1",
5457
"@types/express": "^5.0.0",
5558
"@types/jest": "^30.0.0",

src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { Module } from '@nestjs/common';
22
import { APP_GUARD } from '@nestjs/core';
33
import { AppController } from './app.controller';
44
import { ConfigModule } from '@nestjs/config';
5+
import { EventEmitterModule } from '@nestjs/event-emitter';
56
import { PrismaModule } from './prisma/prisma.module';
7+
import { MetricsModule } from './metrics/metrics.module';
68
import { AppService } from './app.service';
79
import { UsersModule } from './users/users.module';
810
import { IdempotentUserModule } from './users/idempotent-user.module';
@@ -30,6 +32,8 @@ import { HealthModule } from './health/health.module';
3032
isGlobal: true,
3133
envFilePath: '.env',
3234
}),
35+
EventEmitterModule.forRoot(),
36+
MetricsModule,
3337
PrismaModule,
3438
AuthModule,
3539
RateLimitModule,

src/common/utils/retry.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Logger } from '@nestjs/common';
2+
import { retryWithBackoff, RetryError } from './retry';
3+
4+
describe('retryWithBackoff', () => {
5+
let logger: any;
6+
7+
beforeEach(() => {
8+
logger = { debug: jest.fn() };
9+
});
10+
11+
it('should return value on first attempt success', async () => {
12+
const fn = jest.fn().mockResolvedValue('success');
13+
14+
const result = await retryWithBackoff(fn, 3, 100, logger);
15+
16+
expect(result).toBe('success');
17+
expect(fn).toHaveBeenCalledTimes(1);
18+
});
19+
20+
it('should retry and succeed on second attempt', async () => {
21+
const fn = jest
22+
.fn()
23+
.mockRejectedValueOnce(new Error('Network error'))
24+
.mockResolvedValueOnce('success');
25+
26+
const result = await retryWithBackoff(fn, 3, 100, logger);
27+
28+
expect(result).toBe('success');
29+
expect(fn).toHaveBeenCalledTimes(2);
30+
expect(logger.debug).toHaveBeenCalledTimes(1);
31+
});
32+
33+
it('should exhausts all retries and throw last error', async () => {
34+
const error = new Error('Persistent error');
35+
const fn = jest.fn().mockRejectedValue(error);
36+
37+
await expect(retryWithBackoff(fn, 3, 100, logger)).rejects.toThrow(
38+
'Persistent error',
39+
);
40+
41+
expect(fn).toHaveBeenCalledTimes(3);
42+
expect(logger.debug).toHaveBeenCalledTimes(2);
43+
});
44+
45+
it('should not retry on 400 client error', async () => {
46+
const error = new Error('Bad request');
47+
(error as any).status = 400;
48+
const fn = jest.fn().mockRejectedValue(error);
49+
50+
await expect(retryWithBackoff(fn, 3, 100, logger)).rejects.toThrow(
51+
'Bad request',
52+
);
53+
54+
expect(fn).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('should not retry on 403 forbidden error', async () => {
58+
const error = new Error('Forbidden');
59+
(error as any).status = 403;
60+
const fn = jest.fn().mockRejectedValue(error);
61+
62+
await expect(retryWithBackoff(fn, 3, 100, logger)).rejects.toThrow(
63+
'Forbidden',
64+
);
65+
66+
expect(fn).toHaveBeenCalledTimes(1);
67+
});
68+
69+
it('should retry on 500 server error', async () => {
70+
const error = new Error('Server error');
71+
(error as any).status = 500;
72+
const fn = jest
73+
.fn()
74+
.mockRejectedValueOnce(error)
75+
.mockResolvedValueOnce('success');
76+
77+
const result = await retryWithBackoff(fn, 3, 100, logger);
78+
79+
expect(result).toBe('success');
80+
expect(fn).toHaveBeenCalledTimes(2);
81+
});
82+
83+
it('should log retry attempts', async () => {
84+
const fn = jest
85+
.fn()
86+
.mockRejectedValueOnce(new Error('Error 1'))
87+
.mockResolvedValueOnce('success');
88+
89+
await retryWithBackoff(fn, 3, 100, logger);
90+
91+
expect(logger.debug).toHaveBeenCalled();
92+
});
93+
});

src/common/utils/retry.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Logger } from '@nestjs/common';
2+
3+
export class RetryError extends Error {
4+
constructor(
5+
public readonly lastError: Error,
6+
public readonly attemptsMade: number,
7+
) {
8+
super(
9+
`Retryable operation failed after ${attemptsMade} attempts: ${lastError.message}`,
10+
);
11+
this.name = 'RetryError';
12+
}
13+
}
14+
15+
function isRetryable(error: any): boolean {
16+
if (!error) return false;
17+
18+
// Don't retry on client errors (4xx)
19+
if (error.status && error.status >= 400 && error.status < 500) {
20+
return false;
21+
}
22+
23+
// Retry on network errors, timeouts, server errors (5xx), or other thrown exceptions
24+
return true;
25+
}
26+
27+
export async function retryWithBackoff<T>(
28+
fn: () => Promise<T>,
29+
maxAttempts: number = 3,
30+
baseDelayMs: number = 100,
31+
logger?: Logger,
32+
): Promise<T> {
33+
let lastError: Error | null = null;
34+
35+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
36+
try {
37+
return await fn();
38+
} catch (error) {
39+
lastError = error instanceof Error ? error : new Error(String(error));
40+
41+
if (attempt === maxAttempts || !isRetryable(error)) {
42+
throw error;
43+
}
44+
45+
const delay = baseDelayMs * Math.pow(2, attempt - 1);
46+
logger?.debug(
47+
`Retry attempt ${attempt} failed: ${lastError.message}. Waiting ${delay}ms before retry.`,
48+
);
49+
50+
await new Promise((resolve) => setTimeout(resolve, delay));
51+
}
52+
}
53+
54+
// This should never be reached, but for type safety
55+
throw lastError || new Error('Unknown retry error');
56+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class LimitExceededEvent {
2+
constructor(
3+
public readonly userId: string,
4+
public readonly limitType: string,
5+
public readonly limit: number,
6+
public readonly attempted: number,
7+
public readonly timestamp: Date,
8+
) {}
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class LimitUpdatedEvent {
2+
constructor(
3+
public readonly walletId: string,
4+
public readonly limitType: string,
5+
public readonly oldValue: number | null,
6+
public readonly newValue: number,
7+
public readonly timestamp: Date,
8+
) {}
9+
}

src/limits/limits.controller.spec.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ describe('LimitsController', () => {
3636
}).compile();
3737

3838
controller = module.get<LimitsController>(LimitsController);
39-
service = module.get(LimitsService);
4039
});
4140

4241
it('should be defined', () => {
@@ -74,5 +73,20 @@ describe('LimitsController', () => {
7473
expect(metadata).toBeDefined();
7574
});
7675
});
76+
77+
it('should have @ApiOperation on all routes', () => {
78+
const routes = ['setLimits', 'getLimits', 'removeLimits'];
79+
80+
routes.forEach((route) => {
81+
const descriptor = Object.getOwnPropertyDescriptor(
82+
LimitsController.prototype,
83+
route,
84+
);
85+
expect(descriptor).toBeDefined();
86+
87+
const metadata = Reflect.getMetadata('swagger/apiOperation', descriptor.value);
88+
expect(metadata).toBeDefined();
89+
});
90+
});
7791
});
7892
});

src/limits/limits.controller.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ import { SetLimitsDto } from './dto/set-limits.dto';
2323
export class LimitsController {
2424
constructor(private readonly limitsService: LimitsService) {}
2525

26-
@ApiOperation({ summary: 'Set wallet transaction and daily limits' })
27-
@ApiParam({ name: 'walletId', description: 'Wallet ID' })
26+
@ApiOperation({
27+
summary: 'Set wallet transaction and daily limits',
28+
description: 'Set or update daily and per-transaction limits for a wallet. Requires API key authentication. Emits limit.updated events for each limit changed.',
29+
})
30+
@ApiParam({ name: 'walletId', description: 'Wallet ID (UUID)' })
2831
@ApiBody({
2932
type: SetLimitsDto,
3033
examples: {
@@ -38,7 +41,7 @@ export class LimitsController {
3841
})
3942
@ApiResponse({
4043
status: 201,
41-
description: 'Limits set successfully',
44+
description: 'Limits set successfully. Emits limit.updated events.',
4245
example: {
4346
walletId: '123e4567-e89b-12d3-a456-426614174000',
4447
dailyLimit: 5000,
@@ -78,8 +81,11 @@ export class LimitsController {
7881
);
7982
}
8083

81-
@ApiOperation({ summary: 'Get wallet limits' })
82-
@ApiParam({ name: 'walletId', description: 'Wallet ID' })
84+
@ApiOperation({
85+
summary: 'Get wallet limits',
86+
description: 'Retrieve current daily and per-transaction limits for a wallet. Requires API key authentication.',
87+
})
88+
@ApiParam({ name: 'walletId', description: 'Wallet ID (UUID)' })
8389
@ApiResponse({
8490
status: 200,
8591
description: 'Wallet limits retrieved successfully',
@@ -101,13 +107,28 @@ export class LimitsController {
101107
error: 'Not Found',
102108
},
103109
})
110+
@ApiResponse({
111+
status: 422,
112+
description: 'Limit exceeded - transaction blocked',
113+
example: {
114+
statusCode: 422,
115+
timestamp: '2024-06-24T12:34:56.789Z',
116+
path: '/payments',
117+
message: 'Per-transaction limit exceeded. Limit: 1000',
118+
error: 'Unprocessable Entity',
119+
errorCode: 'LIMIT_PER_TX_EXCEEDED',
120+
},
121+
})
104122
@Get()
105123
getLimits(@Param('walletId') walletId: string) {
106124
return this.limitsService.getLimits(walletId);
107125
}
108126

109-
@ApiOperation({ summary: 'Remove wallet limits' })
110-
@ApiParam({ name: 'walletId', description: 'Wallet ID' })
127+
@ApiOperation({
128+
summary: 'Remove wallet limits',
129+
description: 'Delete all limits for a wallet. Requires API key authentication.',
130+
})
131+
@ApiParam({ name: 'walletId', description: 'Wallet ID (UUID)' })
111132
@ApiResponse({
112133
status: 204,
113134
description: 'Limits removed successfully',

0 commit comments

Comments
 (0)