Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public final class RetryPolicy {
private double backoffCoefficient = 1.0;
private Duration maxRetryInterval = Duration.ZERO;
private Duration retryTimeout = Duration.ZERO;
private double jitterFactor = 0.0;

/**
* Creates a new {@code RetryPolicy} object.
Expand Down Expand Up @@ -173,4 +174,36 @@ public Duration getMaxRetryInterval() {
public Duration getRetryTimeout() {
return this.retryTimeout;
}

/**
* Sets the jitter factor applied to the computed retry delay.
*
* <p>A value between 0.0 (no jitter) and 1.0 (up to 100% reduction). For each retry, the delay
* is reduced by a random fraction in the range {@code [0, jitterFactor)}, using a deterministic
* seed derived from the first-attempt timestamp and the attempt number. The seed must be
* deterministic: the delay drives the {@code finalFireAt} of a durable timer, and if replay
* computes a different value, the timer-chain check may create spurious sub-timers that shift
* subsequent sequence IDs and cause a NonDeterministicOrchestratorException.
* This desynchronizes concurrent workflow retries and avoids thundering herd behaviour.</p>
*
* @param jitterFactor the jitter factor; must be between 0.0 and 1.0 inclusive
* @return this retry policy object
* @throws IllegalArgumentException if {@code jitterFactor} is outside [0.0, 1.0]
*/
public RetryPolicy setJitterFactor(double jitterFactor) {
if (!Double.isFinite(jitterFactor) || jitterFactor < 0.0 || jitterFactor > 1.0) {
throw new IllegalArgumentException("The value for jitterFactor must be between 0.0 and 1.0 inclusive.");
}
this.jitterFactor = jitterFactor;
return this;
}

/**
* Gets the configured jitter factor.
*
* @return the configured jitter factor
*/
public double getJitterFactor() {
return this.jitterFactor;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CancellationException;
Expand Down Expand Up @@ -1503,7 +1504,7 @@ private Duration getNextDelay() {
(long) Helpers.powExact(this.policy.getBackoffCoefficient(), this.attemptNumber));
} catch (ArithmeticException overflowException) {
if (maxDelayInMillis > 0) {
return this.policy.getMaxRetryInterval();
nextDelayInMillis = maxDelayInMillis;
} else {
// If no maximum is specified, just throw
throw new ArithmeticException("The retry policy calculation resulted in an arithmetic "
Expand All @@ -1513,10 +1514,20 @@ private Duration getNextDelay() {

// NOTE: A max delay of zero or less is interpreted to mean no max delay
if (nextDelayInMillis > maxDelayInMillis && maxDelayInMillis > 0) {
return this.policy.getMaxRetryInterval();
} else {
return Duration.ofMillis(nextDelayInMillis);
nextDelayInMillis = maxDelayInMillis;
}

// Apply jitter: reduce delay by a random fraction in [0, jitterFactor).
// Seed is deterministic so that replay computes the same finalFireAt, preventing
// the createTimerChain callback from creating spurious extra sub-timers.
double jitterFactor = this.policy.getJitterFactor();
if (jitterFactor > 0.0) {
long seed = this.firstAttempt.toEpochMilli() + this.attemptNumber;
double reduction = new Random(seed).nextDouble() * jitterFactor;
nextDelayInMillis = (long) (nextDelayInMillis * (1.0 - reduction));
}

return Duration.ofMillis(nextDelayInMillis);
}

// If there's no declarative retry policy defined, then the custom code retry handler
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2026 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/

package io.dapr.durabletask;

import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.util.Random;

import static org.junit.jupiter.api.Assertions.*;

public class RetryPolicyTest {

// ---- default value ----

@Test
void jitterFactorDefaultsToZero() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
assertEquals(0.0, policy.getJitterFactor());
}

// ---- valid boundary values ----

@Test
void jitterFactorZeroIsAccepted() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
RetryPolicy returned = policy.setJitterFactor(0.0);
assertEquals(0.0, policy.getJitterFactor());
assertSame(policy, returned, "setJitterFactor should return this for chaining");
}

@Test
void jitterFactorOneIsAccepted() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
policy.setJitterFactor(1.0);
assertEquals(1.0, policy.getJitterFactor());
}

@Test
void jitterFactorMidRangeIsAccepted() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
policy.setJitterFactor(0.5);
assertEquals(0.5, policy.getJitterFactor());
}

// ---- invalid values ----

@Test
void jitterFactorBelowZeroThrows() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
assertThrows(IllegalArgumentException.class, () -> policy.setJitterFactor(-0.1));
}

@Test
void jitterFactorAboveOneThrows() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
assertThrows(IllegalArgumentException.class, () -> policy.setJitterFactor(1.1));
}

@Test
void jitterFactorNaNThrows() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
assertThrows(IllegalArgumentException.class, () -> policy.setJitterFactor(Double.NaN));
}

@Test
void jitterFactorPositiveInfinityThrows() {
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(1));
assertThrows(IllegalArgumentException.class, () -> policy.setJitterFactor(Double.POSITIVE_INFINITY));
}


/**

* With jitterFactor=0 the delay must be unchanged.
*/
@Test
void zeroJitterLeavesDelayUnchanged() {
long baseDelayMillis = 3000L;
double jitterFactor = 0.0;

long seed = 42L;
double reduction = new Random(seed).nextDouble() * jitterFactor;
long reduced = (long) (baseDelayMillis * (1.0 - reduction));

assertEquals(baseDelayMillis, reduced);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ public final class WorkflowTaskRetryPolicy {
private final Double backoffCoefficient;
private final Duration maxRetryInterval;
private final Duration retryTimeout;
private final Double jitterFactor;

/**
* Constructor for WorkflowTaskRetryPolicy.
* Constructor for WorkflowTaskRetryPolicy (without jitter).
* @param maxNumberOfAttempts Maximum number of attempts to retry the workflow.
* @param firstRetryInterval Interval to wait before the first retry.
* @param backoffCoefficient Coefficient to increase the retry interval.
Expand All @@ -39,12 +40,36 @@ public WorkflowTaskRetryPolicy(
Double backoffCoefficient,
Duration maxRetryInterval,
Duration retryTimeout
) {
this(maxNumberOfAttempts, firstRetryInterval, backoffCoefficient,
maxRetryInterval, retryTimeout, null);
}

/**
* Constructor for WorkflowTaskRetryPolicy.
* @param maxNumberOfAttempts Maximum number of attempts to retry the workflow.
* @param firstRetryInterval Interval to wait before the first retry.
* @param backoffCoefficient Coefficient to increase the retry interval.
* @param maxRetryInterval Maximum interval to wait between retries.
* @param retryTimeout Timeout for the whole retry process.
* @param jitterFactor Jitter factor between 0.0 and 1.0; reduces each retry delay by a random
* fraction in [0, jitterFactor) to desynchronize concurrent retries.
* 0.0 disables jitter (default).
*/
public WorkflowTaskRetryPolicy(
Integer maxNumberOfAttempts,
Duration firstRetryInterval,
Double backoffCoefficient,
Duration maxRetryInterval,
Duration retryTimeout,
Double jitterFactor
) {
this.maxNumberOfAttempts = maxNumberOfAttempts;
this.firstRetryInterval = firstRetryInterval;
this.backoffCoefficient = backoffCoefficient;
this.maxRetryInterval = maxRetryInterval;
this.retryTimeout = retryTimeout;
this.jitterFactor = jitterFactor;
}

public int getMaxNumberOfAttempts() {
Expand All @@ -67,6 +92,10 @@ public Duration getRetryTimeout() {
return retryTimeout;
}

public double getJitterFactor() {
return jitterFactor != null ? jitterFactor : 0.0;
}

public static Builder newBuilder() {
return new Builder();
}
Expand All @@ -78,6 +107,7 @@ public static class Builder {
private Double backoffCoefficient = 1.0;
private Duration maxRetryInterval;
private Duration retryTimeout;
private Double jitterFactor = 0.0;

private Builder() {
}
Expand All @@ -92,7 +122,8 @@ public WorkflowTaskRetryPolicy build() {
this.firstRetryInterval,
this.backoffCoefficient,
this.maxRetryInterval,
this.retryTimeout
this.retryTimeout,
this.jitterFactor
);
}

Expand Down Expand Up @@ -176,6 +207,26 @@ public Builder setRetryTimeout(Duration retryTimeout) {

return this;
}

/**
* Set the jitter factor applied to the computed retry delay.
*
* <p>A value between 0.0 (no jitter, default) and 1.0 (up to 100% reduction). For each retry,
* the computed delay is reduced by a random fraction in [0, jitterFactor).
* This desynchronizes concurrent workflow retries and avoids thundering herd behaviour.</p>
*
* @param jitterFactor Jitter factor between 0.0 and 1.0 inclusive
* @return This builder
*/
public Builder setJitterFactor(double jitterFactor) {
if (!Double.isFinite(jitterFactor) || jitterFactor < 0.0 || jitterFactor > 1.0) {
throw new IllegalArgumentException("The value for jitterFactor must be between 0.0 and 1.0 inclusive.");
}

this.jitterFactor = jitterFactor;

return this;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ private RetryPolicy toRetryPolicy(WorkflowTaskRetryPolicy workflowTaskRetryPolic
if (workflowTaskRetryPolicy.getRetryTimeout() != null) {
retryPolicy.setRetryTimeout(workflowTaskRetryPolicy.getRetryTimeout());
}
retryPolicy.setJitterFactor(workflowTaskRetryPolicy.getJitterFactor());

return retryPolicy;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,4 +563,44 @@ public void callActivityRetryPolicyDefaultMaxRetryIntervalShouldBeZeroWhenNotSet

assertEquals(Duration.ZERO, captor.getValue().getRetryPolicy().getMaxRetryInterval());
}

@Test
public void callActivityRetryPolicyJitterFactorShouldBePropagated() {
String expectedName = "TestActivity";
String expectedInput = "TestInput";
double expectedJitterFactor = 0.5;
WorkflowTaskRetryPolicy retryPolicy = WorkflowTaskRetryPolicy.newBuilder()
.setMaxNumberOfAttempts(5)
.setFirstRetryInterval(Duration.ofSeconds(1))
.setJitterFactor(expectedJitterFactor)
.build();
WorkflowTaskOptions options = new WorkflowTaskOptions(retryPolicy);
ArgumentCaptor<TaskOptions> captor = ArgumentCaptor.forClass(TaskOptions.class);

context.callActivity(expectedName, expectedInput, options, String.class);

verify(mockInnerContext, times(1))
.callActivity(eq(expectedName), eq(expectedInput), captor.capture(), eq(String.class));

assertEquals(expectedJitterFactor, captor.getValue().getRetryPolicy().getJitterFactor());
}

@Test
public void callActivityRetryPolicyDefaultJitterFactorShouldBeZeroWhenNotSet() {
String expectedName = "TestActivity";
String expectedInput = "TestInput";
WorkflowTaskRetryPolicy retryPolicy = WorkflowTaskRetryPolicy.newBuilder()
.setMaxNumberOfAttempts(5)
.setFirstRetryInterval(Duration.ofSeconds(1))
.build();
WorkflowTaskOptions options = new WorkflowTaskOptions(retryPolicy);
ArgumentCaptor<TaskOptions> captor = ArgumentCaptor.forClass(TaskOptions.class);

context.callActivity(expectedName, expectedInput, options, String.class);

verify(mockInnerContext, times(1))
.callActivity(eq(expectedName), eq(expectedInput), captor.capture(), eq(String.class));

assertEquals(0.0, captor.getValue().getRetryPolicy().getJitterFactor());
}
}
Loading
Loading