Skip to content

Commit b05cd0f

Browse files
committed
feat: Implement advanced retry features with handleFailure predicate and timeout controls
1 parent fcf15cc commit b05cd0f

8 files changed

Lines changed: 565 additions & 1 deletion

File tree

packages/durabletask-js/src/exception/timeout-error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
export class TimeoutError extends Error {
55
constructor() {
66
super("TimeoutError");
7+
this.name = "TimeoutError";
78
}
89
}

packages/durabletask-js/src/orchestration/exception/orchestration-failed-error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export class OrchestrationFailedError extends Error {
88

99
constructor(message: string, details: FailureDetails) {
1010
super(message);
11+
this.name = "OrchestrationFailedError";
1112
this._failureDetails = details;
1213
}
1314

packages/durabletask-js/src/task/exception/orchestration-state-error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
export class OrchestrationStateError extends Error {
55
constructor(message: string) {
66
super(message);
7+
this.name = "OrchestrationStateError";
78
}
89
}

packages/durabletask-js/src/task/exception/task-failed-error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class TaskFailedError extends Error {
99

1010
constructor(message: string, details: pb.TaskFailureDetails) {
1111
super(message);
12+
this.name = "TaskFailedError";
1213

1314
this._details = new FailureDetails(
1415
details.getErrormessage(),

packages/durabletask-js/src/task/retryable-task.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export class RetryableTask<T> extends CompletableTask<T> {
156156
*
157157
* @remarks
158158
* Returns undefined if:
159+
* - The handleFailure predicate returns false for the last failure
159160
* - The maximum number of attempts has been reached
160161
* - The retry timeout has been exceeded
161162
*
@@ -165,6 +166,19 @@ export class RetryableTask<T> extends CompletableTask<T> {
165166
* The delay is capped at maxRetryInterval.
166167
*/
167168
computeNextDelayInMilliseconds(currentTime: Date): number | undefined {
169+
// Check if handleFailure predicate says we should NOT retry this failure type
170+
if (this._lastFailure) {
171+
const failureDetails = {
172+
errorType: this._lastFailure.getErrortype() || "Error",
173+
message: this._lastFailure.getErrormessage() || "",
174+
stackTrace: this._lastFailure.getStacktrace()?.getValue(),
175+
};
176+
177+
if (!this._retryPolicy.shouldRetry(failureDetails)) {
178+
return undefined;
179+
}
180+
}
181+
168182
// Check if we've exhausted max attempts
169183
if (this._attemptCount >= this._retryPolicy.maxNumberOfAttempts) {
170184
return undefined;

packages/durabletask-js/src/utils/pb-helper.util.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ export function newSubOrchestrationFailedEvent(eventId: number, ex: Error): pb.H
183183

184184
export function newFailureDetails(e: any): pb.TaskFailureDetails {
185185
const failure = new pb.TaskFailureDetails();
186-
failure.setErrortype(e.constructor.name);
186+
// Use error.name if set (allows custom error names), otherwise fall back to constructor name
187+
failure.setErrortype(e.name || e.constructor.name);
187188
failure.setErrormessage(e.message);
188189

189190
// Construct a google_protobuf_wrappers_pb.StringValue

packages/durabletask-js/test/retryable-task.spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,108 @@ describe("RetryableTask", () => {
194194
// Assert - should still allow retry (timeout is infinite)
195195
expect(delay).toBeGreaterThan(0);
196196
});
197+
198+
it("should return undefined when handleFailure predicate returns false", () => {
199+
// Arrange
200+
const retryPolicy = new RetryPolicy({
201+
maxNumberOfAttempts: 10,
202+
firstRetryIntervalInMilliseconds: 1000,
203+
handleFailure: (failure) => failure.errorType !== "FatalError",
204+
});
205+
const action = new pb.OrchestratorAction();
206+
const task = new RetryableTask<string>(retryPolicy, action, new Date(), "activity");
207+
208+
// Record a FatalError (should NOT be retried)
209+
const failureDetails = new pb.TaskFailureDetails();
210+
failureDetails.setErrortype("FatalError");
211+
failureDetails.setErrormessage("This is a fatal error");
212+
task.recordFailure("This is a fatal error", failureDetails);
213+
214+
const currentTime = new Date();
215+
216+
// Act
217+
const delay = task.computeNextDelayInMilliseconds(currentTime);
218+
219+
// Assert - should return undefined because handleFailure returns false for FatalError
220+
expect(delay).toBeUndefined();
221+
});
222+
223+
it("should allow retry when handleFailure predicate returns true", () => {
224+
// Arrange
225+
const retryPolicy = new RetryPolicy({
226+
maxNumberOfAttempts: 10,
227+
firstRetryIntervalInMilliseconds: 1000,
228+
handleFailure: (failure) => failure.errorType !== "FatalError",
229+
});
230+
const action = new pb.OrchestratorAction();
231+
const task = new RetryableTask<string>(retryPolicy, action, new Date(), "activity");
232+
233+
// Record a TransientError (should be retried)
234+
const failureDetails = new pb.TaskFailureDetails();
235+
failureDetails.setErrortype("TransientError");
236+
failureDetails.setErrormessage("This is a transient error");
237+
task.recordFailure("This is a transient error", failureDetails);
238+
239+
const currentTime = new Date();
240+
241+
// Act
242+
const delay = task.computeNextDelayInMilliseconds(currentTime);
243+
244+
// Assert - should return a delay because handleFailure returns true for TransientError
245+
expect(delay).toBeDefined();
246+
expect(delay).toBeGreaterThan(0);
247+
});
248+
249+
it("should filter based on error message content in handleFailure", () => {
250+
// Arrange
251+
const retryPolicy = new RetryPolicy({
252+
maxNumberOfAttempts: 10,
253+
firstRetryIntervalInMilliseconds: 1000,
254+
handleFailure: (failure) => failure.message?.includes("timeout") ?? false,
255+
});
256+
const action = new pb.OrchestratorAction();
257+
const task = new RetryableTask<string>(retryPolicy, action, new Date(), "activity");
258+
259+
// Record a validation error (should NOT be retried - no "timeout" in message)
260+
const failureDetails = new pb.TaskFailureDetails();
261+
failureDetails.setErrortype("ValidationError");
262+
failureDetails.setErrormessage("Invalid input: field is required");
263+
task.recordFailure("Invalid input: field is required", failureDetails);
264+
265+
const currentTime = new Date();
266+
267+
// Act
268+
const delay = task.computeNextDelayInMilliseconds(currentTime);
269+
270+
// Assert - should return undefined because message doesn't contain "timeout"
271+
expect(delay).toBeUndefined();
272+
});
273+
274+
it("should retry when error message matches handleFailure criteria", () => {
275+
// Arrange
276+
const retryPolicy = new RetryPolicy({
277+
maxNumberOfAttempts: 10,
278+
firstRetryIntervalInMilliseconds: 1000,
279+
handleFailure: (failure) => failure.message?.includes("timeout") ?? false,
280+
});
281+
const action = new pb.OrchestratorAction();
282+
const task = new RetryableTask<string>(retryPolicy, action, new Date(), "activity");
283+
284+
// Record a timeout error (should be retried - has "timeout" in message)
285+
const failureDetails = new pb.TaskFailureDetails();
286+
failureDetails.setErrortype("NetworkError");
287+
failureDetails.setErrormessage("Connection timeout - please retry");
288+
task.recordFailure("Connection timeout - please retry", failureDetails);
289+
290+
const currentTime = new Date();
291+
292+
// Act
293+
const delay = task.computeNextDelayInMilliseconds(currentTime);
294+
295+
// Assert - should return a delay because message contains "timeout"
296+
expect(delay).toBeDefined();
297+
expect(delay).toBeGreaterThan(0);
298+
});
197299
});
198300

199301
describe("incrementAttemptCount", () => {

0 commit comments

Comments
 (0)