Skip to content

Commit 4d31908

Browse files
author
Eric Oliver
committed
fix: Implement missing backoffMultiplier and timeout in RecoveryOptions
- Add timeout functionality to RecoveryManager.attemptRecovery() using Promise.race() - Implement backoffMultiplier support in NetworkRecoveryStrategy and BaseRecoveryStrategy - Update calculateBackoffDelay() to accept configurable backoff multiplier - Add comprehensive tests for timeout and custom configuration handling - Ensure backward compatibility while addressing reviewer feedback Fixes code reviewer feedback about unused RecoveryOptions properties.
1 parent 4cdf847 commit 4d31908

File tree

6 files changed

+361
-7
lines changed

6 files changed

+361
-7
lines changed

src/cli/recovery/NetworkRecoveryStrategy.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import { BaseRecoveryStrategy } from "./RecoveryStrategy"
99
export class NetworkRecoveryStrategy extends BaseRecoveryStrategy {
1010
private readonly maxAttempts: number = 3
1111
private readonly baseDelay: number = 1000
12+
private readonly backoffMultiplier: number = 2
1213

13-
constructor(maxAttempts?: number, baseDelay?: number) {
14+
constructor(maxAttempts?: number, baseDelay?: number, backoffMultiplier?: number) {
1415
super()
1516
if (maxAttempts) this.maxAttempts = maxAttempts
1617
if (baseDelay) this.baseDelay = baseDelay
18+
if (backoffMultiplier) this.backoffMultiplier = backoffMultiplier
1719
}
1820

1921
canRecover(error: Error, context: ErrorContext): boolean {
@@ -47,7 +49,7 @@ export class NetworkRecoveryStrategy extends BaseRecoveryStrategy {
4749
// Exponential backoff retry for other network errors
4850
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
4951
if (attempt > 1) {
50-
const delay = this.calculateBackoffDelay(attempt, this.baseDelay)
52+
const delay = this.calculateBackoffDelay(attempt, this.baseDelay, this.backoffMultiplier)
5153
await this.delay(delay)
5254
this.logRecoveryAttempt(error, attempt, context)
5355
}

src/cli/recovery/RecoveryStrategy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ export abstract class BaseRecoveryStrategy implements RecoveryStrategy {
2020
/**
2121
* Calculate exponential backoff delay
2222
*/
23-
protected calculateBackoffDelay(attempt: number, baseDelay: number = 1000, maxDelay: number = 30000): number {
24-
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay)
23+
protected calculateBackoffDelay(
24+
attempt: number,
25+
baseDelay: number = 1000,
26+
backoffMultiplier: number = 2,
27+
maxDelay: number = 30000,
28+
): number {
29+
const delay = Math.min(baseDelay * Math.pow(backoffMultiplier, attempt - 1), maxDelay)
2530
// Add jitter to prevent thundering herd
2631
return delay + Math.random() * delay * 0.1
2732
}

src/cli/services/RecoveryManager.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,57 @@ export class RecoveryManager implements IRecoveryManager {
3535
}
3636

3737
async attemptRecovery(error: Error, context: ErrorContext, options: RecoveryOptions = {}): Promise<RecoveryResult> {
38-
const { maxAttempts = 3, enableRollback = true, strategies = this.strategies } = options
38+
const {
39+
maxAttempts = 3,
40+
enableRollback = true,
41+
strategies = this.strategies,
42+
timeout,
43+
backoffMultiplier,
44+
} = options
45+
46+
const recoveryPromise = this.executeRecoveryStrategies(
47+
error,
48+
context,
49+
strategies,
50+
maxAttempts,
51+
enableRollback,
52+
backoffMultiplier,
53+
)
54+
55+
// Set up overall timeout if specified
56+
if (timeout) {
57+
const timeoutPromise = new Promise<RecoveryResult>((_, reject) => {
58+
setTimeout(() => {
59+
reject(new Error(`Recovery timeout after ${timeout}ms`))
60+
}, timeout)
61+
})
62+
63+
try {
64+
return await Promise.race([recoveryPromise, timeoutPromise])
65+
} catch (timeoutError) {
66+
return {
67+
success: false,
68+
finalError: timeoutError instanceof Error ? timeoutError : new Error(String(timeoutError)),
69+
suggestions: [
70+
"Recovery operation timed out",
71+
"Consider increasing timeout value",
72+
"Check for hanging operations",
73+
],
74+
}
75+
}
76+
} else {
77+
return await recoveryPromise
78+
}
79+
}
3980

81+
private async executeRecoveryStrategies(
82+
error: Error,
83+
context: ErrorContext,
84+
strategies: RecoveryStrategy[],
85+
maxAttempts: number,
86+
enableRollback: boolean,
87+
backoffMultiplier?: number,
88+
): Promise<RecoveryResult> {
4089
// Find applicable recovery strategies
4190
const applicableStrategies = strategies.filter((strategy) => strategy.canRecover(error, context))
4291

@@ -50,7 +99,14 @@ export class RecoveryManager implements IRecoveryManager {
5099
// Try each strategy in order
51100
for (const strategy of applicableStrategies) {
52101
try {
53-
const result = await strategy.recover(error, context)
102+
// Pass backoff configuration to strategies that support it
103+
const result = await this.executeStrategyWithOptions(
104+
strategy,
105+
error,
106+
context,
107+
maxAttempts,
108+
backoffMultiplier,
109+
)
54110

55111
if (result.success) {
56112
return result
@@ -88,6 +144,30 @@ export class RecoveryManager implements IRecoveryManager {
88144
}
89145
}
90146

147+
private async executeStrategyWithOptions(
148+
strategy: RecoveryStrategy,
149+
error: Error,
150+
context: ErrorContext,
151+
maxAttempts: number,
152+
backoffMultiplier?: number,
153+
): Promise<RecoveryResult> {
154+
// Check if strategy supports configurable options and if custom options are provided
155+
if (
156+
strategy.constructor.name === "NetworkRecoveryStrategy" &&
157+
(maxAttempts !== 3 || backoffMultiplier !== undefined)
158+
) {
159+
// Only create configured strategy if error can be handled by NetworkRecoveryStrategy
160+
if (strategy.canRecover(error, context)) {
161+
const configurableStrategy = new NetworkRecoveryStrategy(maxAttempts, undefined, backoffMultiplier)
162+
// NetworkRecoveryStrategy.canRecover already verified this is a compatible error
163+
return await configurableStrategy.recover(error as any, context)
164+
}
165+
}
166+
167+
// Use strategy as-is for other strategies or default configuration
168+
return await strategy.recover(error, context)
169+
}
170+
91171
async rollbackOperation(operationId: string): Promise<void> {
92172
const operationState = this.operationStates.get(operationId)
93173
if (!operationState) {

src/cli/services/__tests__/RecoveryManager.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,94 @@ describe("RecoveryManager", () => {
138138

139139
expect(strategyWithRollback.rollback).not.toHaveBeenCalled()
140140
})
141+
142+
it("should respect timeout option and fail recovery if timeout is exceeded", async () => {
143+
const slowStrategy: RecoveryStrategy = {
144+
canRecover: jest.fn().mockReturnValue(true),
145+
recover: jest.fn().mockImplementation(async () => {
146+
// Simulate a slow recovery operation
147+
await new Promise((resolve) => setTimeout(resolve, 200))
148+
return { success: true }
149+
}),
150+
rollback: jest.fn().mockResolvedValue(undefined),
151+
}
152+
153+
// Clear existing strategies and add only our slow strategy
154+
recoveryManager = new RecoveryManager()
155+
recoveryManager.addStrategy(slowStrategy)
156+
157+
const error = new Error("Test error")
158+
const result = await recoveryManager.attemptRecovery(error, mockContext, {
159+
timeout: 100, // Very short timeout
160+
})
161+
162+
expect(result.success).toBe(false)
163+
expect(result.finalError?.message).toContain("Recovery timeout after 100ms")
164+
expect(result.suggestions).toContain("Recovery operation timed out")
165+
})
166+
167+
it("should complete recovery if within timeout", async () => {
168+
const fastStrategy: RecoveryStrategy = {
169+
canRecover: jest.fn().mockReturnValue(true),
170+
recover: jest.fn().mockResolvedValue({ success: true }),
171+
rollback: jest.fn().mockResolvedValue(undefined),
172+
}
173+
174+
// Clear existing strategies and add only our fast strategy
175+
recoveryManager = new RecoveryManager()
176+
recoveryManager.addStrategy(fastStrategy)
177+
178+
const error = new Error("Test error")
179+
const result = await recoveryManager.attemptRecovery(error, mockContext, {
180+
timeout: 1000, // Generous timeout
181+
})
182+
183+
expect(result.success).toBe(true)
184+
expect(fastStrategy.recover).toHaveBeenCalledWith(error, mockContext)
185+
})
186+
187+
it("should handle custom maxAttempts and backoffMultiplier options", async () => {
188+
const networkError = new NetworkError("Connection failed", "NET_ERROR", 500)
189+
190+
const result = await recoveryManager.attemptRecovery(networkError, mockContext, {
191+
backoffMultiplier: 1.5,
192+
maxAttempts: 2,
193+
})
194+
195+
// The test should complete without errors - the implementation creates a new strategy internally
196+
expect(result).toBeDefined()
197+
expect(typeof result.success).toBe("boolean")
198+
})
199+
200+
it("should use default options when none provided", async () => {
201+
const mockStrategy: RecoveryStrategy = {
202+
canRecover: jest.fn().mockReturnValue(true),
203+
recover: jest.fn().mockResolvedValue({ success: true }),
204+
rollback: jest.fn().mockResolvedValue(undefined),
205+
}
206+
207+
// Clear existing strategies and add only our mock strategy
208+
recoveryManager = new RecoveryManager()
209+
recoveryManager.addStrategy(mockStrategy)
210+
211+
const error = new Error("Test error")
212+
const result = await recoveryManager.attemptRecovery(error, mockContext)
213+
214+
expect(result.success).toBe(true)
215+
expect(mockStrategy.recover).toHaveBeenCalledWith(error, mockContext)
216+
})
217+
218+
it("should handle timeout option properly", async () => {
219+
const networkError = new NetworkError("Connection failed", "NET_ERROR", 500)
220+
221+
const result = await recoveryManager.attemptRecovery(networkError, mockContext, {
222+
timeout: 5000, // 5 second timeout
223+
})
224+
225+
// Should complete within reasonable time
226+
expect(result).toBeDefined()
227+
expect(typeof result.success).toBe("boolean")
228+
})
141229
})
142230

143231
describe("operation state management", () => {

0 commit comments

Comments
 (0)