Skip to content

Confirm TryExecutor.handleException Behavior #1198

@phungvannarich-kepler-aavn

Description

We are analyzing the behavior of the Java SDK TryExecutor.handleException() implementation (https://github.com/serverlessworkflow/sdk-java/blob/main/impl/core/src/main/java/io/serverlessworkflow/impl/executors/TryExecutor.java#L162C3-L197C4). The DSL spec states that an error must propagate when no catch filter/predicate matches the raised error, but the SDK currently returns CompletableFuture.completedFuture(taskContext.rawOutput()) before the filter is evaluated.

Please clarify the intended design with these questions:

  1. Unmatched filter flow

    • In the code below, completable is initialized to a completed (successful) future before any error filter or when/exceptWhen check. If the filter or predicate later evaluates to false, the method simply returns that success future instead of propagating the error. Is that intentional (design choice) or a bug? The DSL says the error should propagate (workflow should FAULT) when there is no match.
    CompletableFuture<WorkflowModel> completable =
        CompletableFuture.completedFuture(taskContext.rawOutput());
    if (filterMatches && WorkflowUtils.whenExceptTest(...)) {
        // catch.do and/or retry
    }
    return completable; // always SUCCESS, even when filter does not match
     **Example:** TLS workflow raises a timeout error while the catch block is configured for validation errors. The relevant YAML is:
    
     ```yaml
     do:
         - attemptTask:
                 try:
                     - failingTask:
                             raise:
                                 error:
                                     type: https://example.com/errors/timeout
                                     status: 408
                                     title: Request Timeout
                 catch:
                     errors:
                         with:
                             type: https://example.com/errors/validation
                     do:
                         - handleValidation:
                                 set:
                                     recovered: true
     ```
     Despite the timeout, the SDK still returns `CompletableFuture.completedFuture(...)` before seeing the mismatch, so the try task completes instead of faulting.
    
  2. when and exceptWhen predicates

    • Since the predicates short-circuit the condition, a false when or true exceptWhen also causes the if block to be skipped. Yet the method still returns the success future created above. Shouldn’t the error be re-thrown when when/exceptWhen reject the error, per the spec?

      Example: The error block raises a 503 while the when predicate only accepts status 400:

      do:
          - attemptTask:
                  try:
                      - failingTask:
                              raise:
                                  error:
                                      type: https://example.com/errors/transient
                                      status: 503
                  catch:
                      as: caughtError
                      when: ${ .status == 400 }
                      do:
                          - handleError:
                                  set:
                                      recovered: true

      Because the SDK returns the pre-resolved success future, the error never propagates even though when evaluated to false.

  3. catch.do failure propagation

    • The same logic is reused when catch.do contains nested try/catch. If an inner error is not matched, the inner handleException() call returns the already-completed success future, so the catch.do chain never faulted even though the inner error should escape and eventually fault the workflow. Was this swallowing behavior intended?

      Example: An outer catch.do contains an inner try that raises an error but its filter rejects it:

      do:
          - outerTry:
                  try:
                      - outerFailingTask:
                              raise: ...outer error...
                  catch:
                      as: outerError
                      do:
                          - innerTryInCatchDo:
                                  try:
                                      - innerFailingTask:
                                              raise: ...inner error...
                                  catch:
                                      errors:
                                          with:
                                              type: https://example.com/errors/wrong-type
                                      do:
                                          - innerRecovery: { ... }

      The inner error type is https://example.com/errors/inner, so the inner filter does not match. The SDK still returns the success future from the nested handleException(), meaning the outer try thinks catch.do succeeded rather than faulting.

  4. Error detail comparison

    • Can you confirm whether this line is a typo? https://github.com/serverlessworkflow/sdk-java/blob/main/impl/core/src/main/java/io/serverlessworkflow/impl/executors/TryExecutor.java#L210

      // current SDK code
      compareString(errorFilter.getDetails(), errorFilter.getDetails())
      
      // expected comparison
      compareString(errorFilter.getDetails(), error.details())
    • Why this matters: in the current code the detail filter is compared to itself, so that check is always true when details is present. This makes catch.errors.with.details effectively non-functional, because mismatched error details are never rejected.

    • Example scenario:

      do:
          - attemptTask:
                  try:
                      - failingTask:
                              raise:
                                  error:
                                      type: https://example.com/errors/security
                                      status: 403
                                      details: User not found in tenant catalog
                  catch:
                      errors:
                          with:
                              type: https://example.com/errors/security
                              details: Enforcement Failure - invalid email
                      do:
                          - handleSecurity:
                                  set:
                                      recovered: true
    • Expected behavior (DSL): detail mismatch means catch filter does not match, so the error should propagate and the workflow should fault.

    • Current SDK behavior: catch is treated as matched and catch.do runs, so the workflow completes successfully.

  5. catch.as binding

    • The caught error object is never injected into the expression context for catch.do (no taskContext or model mutation with the as variable). Is that deliberate, or should the error be bound so ${ .caughtError.detail } works for expressions?

      Example: A catch block binds the caught error to caughtError and reads its detail:

      do:
          - attemptTask:
                  try:
                      - failingTask:
                              raise: ...
                  catch:
                      as: caughtError
                      do:
                          - handleAnyError:
                                  set:
                                      recovered: true
                                      errorMessage: ${ .caughtError.detail }

      The SDK leaves ${ .caughtError } as null, so the expressions fail even though DSL semantics require the error object to be available.

We’d appreciate confirmation on whether these behaviors are intentional or if they should be treated as bugs so we can follow up accordingly.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions