Skip to content

Commit 27689f5

Browse files
chidinma000claude
andcommitted
feat(wallets): add retry with backoff to wallet orchestrator (#418)
- Fix runtime ReferenceError: define `requestIdLabel` in `createWallet` before use - Register WalletRetryService and WalletApiMetricsService as explicit providers in WalletCreationOrchestratorModule so they are always injected in production - Expand WalletRetryService unit tests from 2 → 24: cover all transient HTTP status codes (408/425/429/5xx), all 7 network error codes via it.each, AbortError non-retry, maxAttempts override, delay cap, config env reads, and retry exhaustion behaviour - Add orchestrator retry integration tests (40 → 46): key-generation retries, exhaustion mapping to WalletOrchestrationError, non-transient skip, Friendbot retry, Friendbot exhaustion non-blocking, and no-retry-service fallback path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2c8a4c6 commit 27689f5

4 files changed

Lines changed: 494 additions & 6 deletions

src/wallets/wallet-creation-orchestrator.module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { UsersModule } from '../users/users.module';
88
import { WebhookModule } from '../webhooks/webhook.module';
99
import { KeyManagementModule } from '../key-management/key-management.module';
1010
import { IdempotencyService } from '../common/idempotency/idempotency.service';
11+
import { WalletRetryService } from './wallet-retry.service';
12+
import { WalletApiMetricsService } from './wallet-api-metrics.service';
1113

1214
@Module({
1315
imports: [
@@ -19,7 +21,12 @@ import { IdempotencyService } from '../common/idempotency/idempotency.service';
1921
WebhookModule,
2022
],
2123
controllers: [WalletCreationOrchestratorController],
22-
providers: [WalletCreationOrchestrator, IdempotencyService],
24+
providers: [
25+
WalletCreationOrchestrator,
26+
IdempotencyService,
27+
WalletRetryService,
28+
WalletApiMetricsService,
29+
],
2330
exports: [WalletCreationOrchestrator],
2431
})
2532
export class WalletCreationOrchestratorModule {}

src/wallets/wallet-creation-orchestrator.service.spec.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,4 +1066,196 @@ describe('WalletCreationOrchestrator', () => {
10661066
expect(ageMs).toBeLessThan(6 * 60 * 1000);
10671067
});
10681068
});
1069+
1070+
// -------------------------------------------------------------------------
1071+
// Retry with backoff integration (#418)
1072+
// -------------------------------------------------------------------------
1073+
1074+
describe('retry with backoff', () => {
1075+
const transientError = Object.assign(new Error('connection reset'), {
1076+
code: 'ECONNRESET',
1077+
});
1078+
1079+
const provisioningWallet = {
1080+
id: 'wallet-123',
1081+
userId: 'user-123',
1082+
publicKey: 'GABC123DEF456',
1083+
encryptedSecret: 'encrypted-private-key',
1084+
encryptionVersion: 1,
1085+
secretVersion: 1,
1086+
network: WalletNetwork.TESTNET,
1087+
status: 'PROVISIONING',
1088+
statusReason: null,
1089+
statusChangedAt: new Date(),
1090+
rotatedFromId: null,
1091+
createdAt: new Date(),
1092+
updatedAt: new Date(),
1093+
};
1094+
const activeWallet = {
1095+
...provisioningWallet,
1096+
status: 'ACTIVE',
1097+
statusReason: 'Wallet provisioned and activated',
1098+
};
1099+
1100+
/** Creates an orchestrator wired with a real WalletRetryService (wait stubbed). */
1101+
function makeOrchestratorWithRetry() {
1102+
const { WalletRetryService } = jest.requireActual('./wallet-retry.service') as typeof import('./wallet-retry.service');
1103+
const retryConfig = { get: (_k: string, fallback: number) => fallback };
1104+
const retryService = new WalletRetryService(retryConfig as any);
1105+
// Stub internal wait so tests run instantly
1106+
jest.spyOn(retryService as any, 'wait').mockResolvedValue(undefined);
1107+
1108+
return {
1109+
retryService,
1110+
orchestrator: new WalletCreationOrchestrator(
1111+
mockEncryptionService as any,
1112+
mockConfigService as any,
1113+
mockIdempotentUserService as any,
1114+
mockKeyManagementService as any,
1115+
mockPrisma as any,
1116+
undefined,
1117+
retryService,
1118+
),
1119+
};
1120+
}
1121+
1122+
it('retries key generation on transient failures and eventually succeeds', async () => {
1123+
const { orchestrator: retryOrchestrator } = makeOrchestratorWithRetry();
1124+
1125+
mockPrisma.$transaction.mockImplementation(async (cb: any) =>
1126+
cb(mockPrisma),
1127+
);
1128+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
1129+
mockPrisma.wallet.create.mockResolvedValue(provisioningWallet);
1130+
mockPrisma.wallet.update.mockResolvedValue(activeWallet);
1131+
1132+
// Key generation fails twice (transient), succeeds on the third attempt
1133+
mockKeyManagementService.generateKey
1134+
.mockRejectedValueOnce(transientError)
1135+
.mockRejectedValueOnce(transientError)
1136+
.mockResolvedValueOnce({
1137+
publicKey: 'GABC123DEF456',
1138+
encryptedData: 'encrypted-private-key',
1139+
encryptionVersion: 1,
1140+
});
1141+
1142+
const result = await retryOrchestrator.createWallet({
1143+
userId: 'user-123',
1144+
network: WalletNetwork.TESTNET,
1145+
});
1146+
1147+
expect(result.isNewWallet).toBe(true);
1148+
expect(mockKeyManagementService.generateKey).toHaveBeenCalledTimes(3);
1149+
});
1150+
1151+
it('throws WalletOrchestrationError(key-generation) when key generation exhausts all retries', async () => {
1152+
const { orchestrator: retryOrchestrator } = makeOrchestratorWithRetry();
1153+
1154+
mockPrisma.$transaction.mockImplementation(async (cb: any) =>
1155+
cb(mockPrisma),
1156+
);
1157+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
1158+
1159+
mockKeyManagementService.generateKey.mockRejectedValue(transientError);
1160+
1161+
const err = await retryOrchestrator
1162+
.createWallet({ userId: 'user-123', network: WalletNetwork.TESTNET })
1163+
.catch((e) => e);
1164+
1165+
expect(err).toBeInstanceOf(WalletOrchestrationError);
1166+
expect(err.phase).toBe('key-generation');
1167+
// generateKey called maxAttempts (3) times
1168+
expect(mockKeyManagementService.generateKey).toHaveBeenCalledTimes(3);
1169+
});
1170+
1171+
it('does not retry non-transient key generation failures (400)', async () => {
1172+
const { orchestrator: retryOrchestrator } = makeOrchestratorWithRetry();
1173+
1174+
mockPrisma.$transaction.mockImplementation(async (cb: any) =>
1175+
cb(mockPrisma),
1176+
);
1177+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
1178+
1179+
const nonTransient = Object.assign(new Error('bad request'), {
1180+
status: 400,
1181+
});
1182+
mockKeyManagementService.generateKey.mockRejectedValue(nonTransient);
1183+
1184+
const err = await retryOrchestrator
1185+
.createWallet({ userId: 'user-123', network: WalletNetwork.TESTNET })
1186+
.catch((e) => e);
1187+
1188+
expect(err).toBeInstanceOf(WalletOrchestrationError);
1189+
expect(err.phase).toBe('key-generation');
1190+
// No retries — called exactly once
1191+
expect(mockKeyManagementService.generateKey).toHaveBeenCalledTimes(1);
1192+
});
1193+
1194+
it('retries Friendbot on transient HTTP failures and wallet creation still succeeds', async () => {
1195+
const { orchestrator: retryOrchestrator } = makeOrchestratorWithRetry();
1196+
1197+
mockPrisma.$transaction.mockImplementation(async (cb: any) =>
1198+
cb(mockPrisma),
1199+
);
1200+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
1201+
mockPrisma.wallet.create.mockResolvedValue(provisioningWallet);
1202+
mockPrisma.wallet.update.mockResolvedValue(activeWallet);
1203+
1204+
// Friendbot responds with 503 twice, then succeeds
1205+
mockFetch
1206+
.mockResolvedValueOnce({ ok: false, status: 503, text: async () => 'unavailable' })
1207+
.mockResolvedValueOnce({ ok: false, status: 503, text: async () => 'unavailable' })
1208+
.mockResolvedValueOnce({ ok: true });
1209+
1210+
const result = await retryOrchestrator.createWallet({
1211+
userId: 'user-123',
1212+
network: WalletNetwork.TESTNET,
1213+
});
1214+
1215+
expect(result.isNewWallet).toBe(true);
1216+
expect(result.wallet.status).toBe(WalletStatus.ACTIVE);
1217+
expect(mockFetch).toHaveBeenCalledTimes(3);
1218+
});
1219+
1220+
it('wallet creation completes even when Friendbot exhausts all retries', async () => {
1221+
const { orchestrator: retryOrchestrator } = makeOrchestratorWithRetry();
1222+
1223+
mockPrisma.$transaction.mockImplementation(async (cb: any) =>
1224+
cb(mockPrisma),
1225+
);
1226+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
1227+
mockPrisma.wallet.create.mockResolvedValue(provisioningWallet);
1228+
mockPrisma.wallet.update.mockResolvedValue(activeWallet);
1229+
1230+
// Friendbot always returns 503
1231+
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
1232+
1233+
const result = await retryOrchestrator.createWallet({
1234+
userId: 'user-123',
1235+
network: WalletNetwork.TESTNET,
1236+
});
1237+
1238+
// Wallet is still created and activated — Friendbot is non-blocking
1239+
expect(result.isNewWallet).toBe(true);
1240+
expect(result.wallet.status).toBe(WalletStatus.ACTIVE);
1241+
});
1242+
1243+
it('falls back to direct key generation when retry service is absent', async () => {
1244+
// Default orchestrator created in beforeEach has no retry service
1245+
mockPrisma.$transaction.mockImplementation(async (cb: any) =>
1246+
cb(mockPrisma),
1247+
);
1248+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
1249+
mockPrisma.wallet.create.mockResolvedValue(provisioningWallet);
1250+
mockPrisma.wallet.update.mockResolvedValue(activeWallet);
1251+
1252+
const result = await orchestrator.createWallet({
1253+
userId: 'user-123',
1254+
network: WalletNetwork.TESTNET,
1255+
});
1256+
1257+
expect(result.isNewWallet).toBe(true);
1258+
expect(mockKeyManagementService.generateKey).toHaveBeenCalledTimes(1);
1259+
});
1260+
});
10691261
});

src/wallets/wallet-creation-orchestrator.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ export class WalletCreationOrchestrator {
208208
requestId?: string,
209209
): Promise<WalletOrchestrationResult> {
210210
const startTime = Date.now();
211+
const requestIdLabel = requestId ? ` requestId=${requestId}` : '';
211212
let committedWallet: Wallet | undefined;
212213
this.logger.log(
213214
`Starting wallet creation orchestration for user ${request.userId} on ${request.network}${requestIdLabel}`,

0 commit comments

Comments
 (0)