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
55 changes: 55 additions & 0 deletions src/controllers/dlqController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import { dlqService } from '../services/dlqService';
import { sendSuccess } from '../utils/responseWrapper';

export class DlqController {
/**
* GET /api/v1/dlq
* List DLQ entries
*/
public async getDlqEntries(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 10;

const { data, total } = await dlqService.listEntries(page, limit);

const totalPages = Math.ceil(total / limit);

// Construct a response matching the existing patterns
sendSuccess(
res,
{ entries: data, pagination: { total, page, limit, totalPages } },
'DLQ entries retrieved successfully',
);
} catch (error) {
next(error);
}
}

/**
* POST /api/v1/dlq/:id/retry
* Retry a specific DLQ entry
*/
public async retryDlqEntry(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const { id } = req.params;
const result = await dlqService.retryEntry(id);

sendSuccess(res, result, 'DLQ entry retried successfully', StatusCodes.OK);
} catch (error) {
next(error);
}
}
}

export const dlqController = new DlqController();
43 changes: 43 additions & 0 deletions src/models/DlqEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import mongoose, { Document, Schema } from 'mongoose';

export enum DlqStatus {
PENDING = 'pending',
RETRIED = 'retried',
RESOLVED = 'resolved',
}

export interface IDlqEntry extends Document {
payload: any;
errorReason: string;
retryCount: number;
status: DlqStatus;
createdAt: Date;
updatedAt: Date;
}

const DlqEntrySchema: Schema = new Schema(
{
payload: {
type: Schema.Types.Mixed,
required: true,
},
errorReason: {
type: String,
required: true,
},
retryCount: {
type: Number,
default: 0,
},
status: {
type: String,
enum: Object.values(DlqStatus),
default: DlqStatus.PENDING,
},
},
{
timestamps: true,
}
);

export const DlqEntry = mongoose.model<IDlqEntry>('DlqEntry', DlqEntrySchema);
37 changes: 37 additions & 0 deletions src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router } from 'express';
import authenticate from '../middleware/authenticate';
import requireRole from '../middleware/requireRole';
import { suspendUser, getDisputes } from '../controllers/adminController';
import { dlqController } from '../controllers/dlqController';
import { UserRole } from '../interfaces/IUser';

const router = Router();
Expand Down Expand Up @@ -148,4 +149,40 @@ router.get('/disputes', getDisputes);
*/
router.put('/users/:id/suspend', suspendUser);

/**
* @openapi
* /v1/admin/dlq:
* get:
* tags: [Admin]
* summary: Fetch Dead Letter Queue (DLQ) entries
* description: Admin-only. Returns a paginated list of failed transaction DLQ entries.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Successfully retrieved DLQ entries
*/
router.get('/dlq', dlqController.getDlqEntries);

/**
* @openapi
* /v1/admin/dlq/{id}/retry:
* post:
* tags: [Admin]
* summary: Retry a specific DLQ entry
* description: Admin-only. Retries a previously failed transaction.
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Successfully retried the DLQ entry
*/
router.post('/dlq/:id/retry', dlqController.retryDlqEntry);

export default router;
75 changes: 75 additions & 0 deletions src/services/dlqService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DlqEntry, IDlqEntry, DlqStatus } from '../models/DlqEntry';
import AppError from '../utils/AppError';
import { StatusCodes } from 'http-status-codes';

// We import stellarService inside the function to avoid circular dependencies
// since stellarService will import dlqService.
export class DlqService {
/**
* Add a failed transaction to the Dead Letter Queue.
*/
public async addEntry(payload: any, errorReason: string): Promise<IDlqEntry> {
const entry = new DlqEntry({
payload,
errorReason,
status: DlqStatus.PENDING,
retryCount: 0,
});
return await entry.save();
}

/**
* List DLQ entries with pagination.
*/
public async listEntries(page: number = 1, limit: number = 10): Promise<{ data: IDlqEntry[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
DlqEntry.find().sort({ createdAt: -1 }).skip(skip).limit(limit).exec(),
DlqEntry.countDocuments().exec(),
]);

return { data, total };
}

/**
* Retry a specific DLQ entry.
*/
public async retryEntry(id: string): Promise<any> {
const entry = await DlqEntry.findById(id);
if (!entry) {
throw new AppError('DLQ entry not found', StatusCodes.NOT_FOUND);
}

if (entry.status === DlqStatus.RESOLVED) {
throw new AppError('DLQ entry is already resolved', StatusCodes.BAD_REQUEST);
}

// Increment retry count
entry.retryCount += 1;
entry.status = DlqStatus.RETRIED;
await entry.save();

try {
// Dynamic import to break circular dependency with stellarService
const { stellarService } = await import('./stellarService');

// Currently, we assume the payload is a SubmitEscrowLockInput
// since that's the main transaction failure we are catching.
// If there are other types, we might need a type field in the DLQ entry.
// For now, we attempt to retry it via stellarService.submitEscrowLock
const result = await stellarService.submitEscrowLock(entry.payload);

entry.status = DlqStatus.RESOLVED;
await entry.save();
return result;
} catch (error: any) {
// If it fails again, we log the new error but keep the status as RETRIED (or revert to PENDING)
entry.errorReason = error.message || String(error);
entry.status = DlqStatus.PENDING; // mark it pending again so it can be retried later
await entry.save();
throw new AppError(`Retry failed: ${entry.errorReason}`, StatusCodes.INTERNAL_SERVER_ERROR);
}
}
}

export const dlqService = new DlqService();
15 changes: 10 additions & 5 deletions src/services/stellarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,16 @@ export class StellarService {
`[StellarService] Submission failed — status=${response.status} ` +
`errorResultXdr=${errXdr} payer=${payerAddress}`,
);
throw new AppError(
`Transaction submission failed with status '${response.status}'. ` +
`Error result XDR: ${errXdr}`,
StatusCodes.BAD_GATEWAY,
);

const errorMessage = `Transaction submission failed with status '${response.status}'. Error result XDR: ${errXdr}`;

// Store in Dead Letter Queue (DLQ)
const { dlqService } = await import('./dlqService');
await dlqService.addEntry(input, errorMessage).catch((dlqErr) => {
logger.error(`[StellarService] Failed to save to DLQ: ${extractMessage(dlqErr)}`);
});

throw new AppError(errorMessage, StatusCodes.BAD_GATEWAY);
}

// Unreachable — loop always returns or throws.
Expand Down