Bug Description
When configuring a try/catch task with a retry policy that does not explicitly set all of backoff, limit, delay, or both jitter.from/jitter.to, a NullPointerException is thrown at runtime.
The most common case is omitting backoff:
java.lang.NullPointerException: Cannot invoke "io.serverlessworkflow.api.types.RetryBackoff.getConstantBackoff()" because "backoff" is null
Steps to Reproduce
Use the fluent DSL to define a retry policy that omits backoff:
tryCatch(
"tryApproval",
t -> t.tryCatch(tasks(
function("submitRequest", approvalService::submit),
function("checkApproval", approvalService::requireApproval)))
.catchHandler(handler -> handler
.errorsWith(err -> err.type("APPROVAL_REJECTED"))
.retry(r -> r
.limit(limit -> limit.attempt(a -> a.count(3)))
.delay("PT1S"))
.doTasks(tasks(
function("notifyRejection", approvalService::notifyRejection, VacationRequest.class)))))
Note that .retry(...) sets limit and delay but does not call .backoff(...).
Root Cause
There are five null-unsafe dereference paths when building the retry executor:
1. backoff — TryExecutor.buildIntervalFunction()
RetryBackoff backoff = retryPolicy.getBackoff();
if (backoff.getConstantBackoff() != null) { // NPE when backoff is null
When no backoff strategy is configured, getBackoff() returns null.
2. limit — TryExecutor.buildRetryExecutor()
retryPolicy.getLimit().getAttempt().getCount() // NPE when limit or attempt is null
A user can configure retry with only delay and backoff but no limit.
3. delay — AbstractRetryIntervalFunction constructor → WorkflowUtils.fromTimeoutAfter()
public static WorkflowValueResolver<Duration> fromTimeoutAfter(
WorkflowApplication application, TimeoutAfter timeout) {
if (timeout.getDurationExpression() != null) { // NPE when timeout (delay) is null
A user can configure retry with only limit and backoff but no delay.
4 & 5. jitter.from / jitter.to — AbstractRetryIntervalFunction constructor
if (jitter != null) {
minJitteringResolver = Optional.of(WorkflowUtils.fromTimeoutAfter(appl, jitter.getFrom())); // NPE if from is null
maxJitteringResolver = Optional.of(WorkflowUtils.fromTimeoutAfter(appl, jitter.getTo())); // NPE if to is null
}
The top-level jitter != null check is present, but from and to are independent setters in RetryPolicyJitterBuilder. A user can set only one of them (e.g., .jitter(j -> j.from("PT0.1S")))
leaving the other null.
Summary
| Field |
Null when... |
NPE location |
backoff |
User omits .backoff(...) |
TryExecutor.buildIntervalFunction() |
limit |
User omits .limit(...) |
TryExecutor.buildRetryExecutor() |
delay |
User omits .delay(...) |
WorkflowUtils.fromTimeoutAfter() |
jitter.from |
User sets jitter with only .to(...) |
AbstractRetryIntervalFunction constructor |
jitter.to |
User sets jitter with only .from(...) |
AbstractRetryIntervalFunction constructor |
Suggested Fix
Add null checks with sensible defaults for each field:
-
backoff: when null, default to constant backoff (use the delay interval unchanged between retries):
if (backoff == null || backoff.getConstantBackoff() != null) {
-
limit: when null or attempt is null, default to a reasonable max (e.g., Integer.MAX_VALUE or a configurable default).
-
delay: when null, default to Duration.ZERO (immediate retry) in fromTimeoutAfter, or guard the call site.
-
jitter.from / jitter.to: when either is null, default to Duration.ZERO by wrapping the fromTimeoutAfter calls with null checks:
minJitteringResolver = jitter.getFrom() != null
? Optional.of(WorkflowUtils.fromTimeoutAfter(appl, jitter.getFrom()))
: Optional.empty();
Bug Description
When configuring a
try/catchtask with a retry policy that does not explicitly set all ofbackoff,limit,delay, or bothjitter.from/jitter.to, aNullPointerExceptionis thrown at runtime.The most common case is omitting
backoff:Steps to Reproduce
Use the fluent DSL to define a retry policy that omits
backoff:Note that
.retry(...)setslimitanddelaybut does not call.backoff(...).Root Cause
There are five null-unsafe dereference paths when building the retry executor:
1.
backoff—TryExecutor.buildIntervalFunction()When no backoff strategy is configured,
getBackoff()returnsnull.2.
limit—TryExecutor.buildRetryExecutor()A user can configure retry with only
delayandbackoffbut nolimit.3.
delay—AbstractRetryIntervalFunctionconstructor →WorkflowUtils.fromTimeoutAfter()A user can configure retry with only
limitandbackoffbut nodelay.4 & 5.
jitter.from/jitter.to—AbstractRetryIntervalFunctionconstructorThe top-level
jitter != nullcheck is present, butfromandtoare independent setters inRetryPolicyJitterBuilder. A user can set only one of them (e.g.,.jitter(j -> j.from("PT0.1S")))leaving the other null.
Summary
backoff.backoff(...)TryExecutor.buildIntervalFunction()limit.limit(...)TryExecutor.buildRetryExecutor()delay.delay(...)WorkflowUtils.fromTimeoutAfter()jitter.from.to(...)AbstractRetryIntervalFunctionconstructorjitter.to.from(...)AbstractRetryIntervalFunctionconstructorSuggested Fix
Add null checks with sensible defaults for each field:
backoff: when null, default to constant backoff (use the delay interval unchanged between retries):limit: when null orattemptis null, default to a reasonable max (e.g.,Integer.MAX_VALUEor a configurable default).delay: when null, default toDuration.ZERO(immediate retry) infromTimeoutAfter, or guard the call site.jitter.from/jitter.to: when either is null, default toDuration.ZEROby wrapping thefromTimeoutAftercalls with null checks: