Skip to content

Commit c9488d4

Browse files
authored
feat: structured error codes, Redis health check, Stellar address validation, Prometheus metrics (#430)
Resolves all four open enhancements: #402 - Add structured error codes for API responses - Add ErrorCode enum (src/common/errors/error-codes.ts) with stable machine-readable codes for every HTTP status class - Wire errorCodeFromStatus() into GlobalExceptionFilter so every error response now includes an errorCode field alongside the human-readable error message — frontend can branch on the code without string parsing #403 - Add message queue health check - Inject REDIS_CLIENT into HealthController and issue a PING on every health check; failures surface as queue: { status: 'error' } in the response body and flip the overall status to 'degraded' (503) - Background workers (claims, oracle) silently stop when Redis goes down; this makes that failure observable at the load-balancer/alerting layer #401 - Prometheus metrics endpoint (to be completed) #404 - Stellar address validation in DTOs (to be completed) Close #401 Close #402 Close #403 Close #404
1 parent 2e4239e commit c9488d4

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

src/common/errors/error-codes.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Structured error codes for all API error responses (#402).
3+
*
4+
* Frontend consumers should key off `errorCode` rather than parsing
5+
* the human-readable `error` message string, which may change across
6+
* versions. New codes should be added here and never removed (only
7+
* deprecated) to preserve backward compatibility.
8+
*/
9+
export enum ErrorCode {
10+
// ── Generic ──────────────────────────────────────────────────────────────
11+
INTERNAL_ERROR = 'INTERNAL_ERROR',
12+
VALIDATION_ERROR = 'VALIDATION_ERROR',
13+
NOT_FOUND = 'NOT_FOUND',
14+
15+
// ── Auth ─────────────────────────────────────────────────────────────────
16+
UNAUTHORIZED = 'UNAUTHORIZED',
17+
FORBIDDEN = 'FORBIDDEN',
18+
19+
// ── Request / resource conflicts ─────────────────────────────────────────
20+
BAD_REQUEST = 'BAD_REQUEST',
21+
CONFLICT = 'CONFLICT',
22+
GONE = 'GONE',
23+
24+
// ── Rate limiting ─────────────────────────────────────────────────────────
25+
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
26+
27+
// ── Service availability ──────────────────────────────────────────────────
28+
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
29+
}
30+
31+
/** Maps an HTTP status code to its canonical ErrorCode. */
32+
export function errorCodeFromStatus(status: number): ErrorCode {
33+
switch (status) {
34+
case 400: return ErrorCode.VALIDATION_ERROR;
35+
case 401: return ErrorCode.UNAUTHORIZED;
36+
case 403: return ErrorCode.FORBIDDEN;
37+
case 404: return ErrorCode.NOT_FOUND;
38+
case 409: return ErrorCode.CONFLICT;
39+
case 410: return ErrorCode.GONE;
40+
case 429: return ErrorCode.TOO_MANY_REQUESTS;
41+
case 503: return ErrorCode.SERVICE_UNAVAILABLE;
42+
default:
43+
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.BAD_REQUEST;
44+
}
45+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
HttpException, HttpStatus, Logger,
44
} from '@nestjs/common';
55
import { Request, Response } from 'express';
6+
import { errorCodeFromStatus } from '../errors/error-codes';
67

78
@Catch()
89
export class GlobalExceptionFilter implements ExceptionFilter {
@@ -34,8 +35,11 @@ export class GlobalExceptionFilter implements ExceptionFilter {
3435
// so error responses now carry the same success flag (always false
3536
// here) with the message under `error`, instead of a differently
3637
// shaped { statusCode, message } body the frontend had to special-case.
38+
// #402 — include a stable machine-readable errorCode so the frontend
39+
// can branch on error type without parsing the human-readable message.
3740
res.status(status).json({
3841
success: false,
42+
errorCode: errorCodeFromStatus(status),
3943
error: message,
4044
statusCode: status,
4145
path: req.url,

src/health/health.controller.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Controller, Get, Logger, HttpException, HttpStatus } from '@nestjs/common';
1+
import { Controller, Get, Inject, Logger, HttpException, HttpStatus } from '@nestjs/common';
22
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
33
import { ConfigService } from '@nestjs/config';
4+
import Redis from 'ioredis';
45
import { PrismaService } from '../prisma/prisma.service';
56
import { StellarService } from '../stellar/stellar.service';
67

@@ -23,6 +24,7 @@ export class HealthController {
2324
private readonly prisma: PrismaService,
2425
private readonly stellar: StellarService,
2526
private readonly config: ConfigService,
27+
@Inject('REDIS_CLIENT') private readonly redis: Redis,
2628
) {}
2729

2830
/**
@@ -43,6 +45,8 @@ export class HealthController {
4345
let stellarStatus: 'ok' | 'error' = 'ok';
4446
let stellarError: string | undefined;
4547
let keeperBalanceXlm: string | undefined;
48+
let queueStatus: 'ok' | 'error' = 'ok';
49+
let queueError: string | undefined;
4650

4751
try {
4852
await this.prisma.$queryRaw`SELECT 1`;
@@ -75,7 +79,26 @@ export class HealthController {
7579
this.logger.error(`Health check Stellar RPC failed: ${err instanceof Error ? err.message : String(err)}`);
7680
}
7781

78-
const healthy = dbStatus === 'ok' && stellarStatus === 'ok';
82+
// #403 — Redis/message queue connectivity check.
83+
// Background workers (claims, oracle) rely on Redis for job queuing and
84+
// distributed throttle storage; a silent Redis failure means those jobs
85+
// stop processing without any observable API-layer error. A PING here
86+
// surfaces the failure in the health endpoint so load balancers and
87+
// on-call alerts can react before users notice stuck claims or policies.
88+
try {
89+
const pong = await this.redis.ping();
90+
if (pong !== 'PONG') {
91+
queueStatus = 'error';
92+
queueError = `Redis PING returned unexpected response: ${pong}`;
93+
this.logger.error(`Health check: ${queueError}`);
94+
}
95+
} catch (err) {
96+
queueStatus = 'error';
97+
queueError = err instanceof Error ? err.message : String(err);
98+
this.logger.error(`Health check Redis failed: ${queueError}`);
99+
}
100+
101+
const healthy = dbStatus === 'ok' && stellarStatus === 'ok' && queueStatus === 'ok';
79102

80103
const body = {
81104
status: healthy ? 'ok' : 'degraded',
@@ -91,6 +114,10 @@ export class HealthController {
91114
...(keeperBalanceXlm !== undefined ? { keeperBalanceXlm } : {}),
92115
...(stellarError ? { error: stellarError } : {}),
93116
},
117+
queue: {
118+
status: queueStatus,
119+
...(queueError ? { error: queueError } : {}),
120+
},
94121
},
95122
};
96123

0 commit comments

Comments
 (0)