Skip to content

NPE in TryExecutor when retry is configured without explicit backoff strategy #1517

Description

@mcruzdev

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. backoffTryExecutor.buildIntervalFunction()

RetryBackoff backoff = retryPolicy.getBackoff();
if (backoff.getConstantBackoff() != null) {  // NPE when backoff is null

When no backoff strategy is configured, getBackoff() returns null.

2. limitTryExecutor.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. delayAbstractRetryIntervalFunction 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.toAbstractRetryIntervalFunction 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();

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

No fields configured for Task.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions