Skip to main content

Error handling - Java SDK

View Markdown

Temporal represents failures with a small set of typed exceptions that all extend TemporalFailure. For what each type means and when the Temporal Service raises it, see the Temporal Failures reference. This page covers the two things that Java Workflow and Activity code get wrong most often: catching a wider exception type than intended, and manually adding checked exceptions to method signatures instead of using the SDK's wrapping helpers.

Catch Exception, never Throwable or Error

Workflow and Activity code should only ever catch Exception or a narrower type. Never catch Throwable or Error.

The Java SDK uses subclasses of Error as internal control signals that must reach the SDK's own code uncaught:

  • DestroyWorkflowThreadError interrupts a Workflow thread so the Worker can release it back to the pool, for example when the Workflow Execution is evicted from the Worker's cache. If Workflow code catches it, the thread doesn't unwind and eviction can stall.
  • UnsupportedVersion is thrown by Workflow.getVersion() when replayed history was produced by code outside the version range the current Workflow code declares. It extends Error specifically so that application code won't catch it by mistake.

Use this rule of thumb when deciding what to catch in a Workflow, Update, or Signal handler:

  1. Error — never catch it. If you must run cleanup on any exit path, use a detached Cancellation Scope rather than a broad catch (see Cancellation below).
  2. CanceledFailure — rethrow it, optionally after cleanup in a detached Cancellation Scope. Don't swallow it: cancellation is cooperative, and swallowing it lets the Workflow Execution finish as "Completed" instead of "Canceled."
  3. ActivityFailure, ChildWorkflowFailure, or ApplicationFailure that you recognize and can recover from — handle it.
  4. Everything else — rethrow it. A plain RuntimeException that isn't recognized above fails the current Workflow Task, which retries indefinitely rather than failing the Workflow Execution. To fail the Workflow Execution deliberately, throw an ApplicationFailure (see Fail a Workflow deliberately below).

Don't swallow failures with a broad catch

A catch (Throwable t) (or catch (Exception e) that only logs and returns) placed around Workflow logic "to be safe" is the most common way this rule gets broken:

// Anti-pattern: never do this in Workflow, Update, or Signal code.
try {
return doWorkflowLogic();
} catch (Throwable t) {
logger.error("workflow failed", t);
return defaultResult();
}

This single catch block causes three separate problems at once:

  • DestroyWorkflowThreadError and UnsupportedVersion are swallowed instead of reaching the SDK, which can stall Worker cache eviction and interfere with replay.
  • CanceledFailure is swallowed, so a canceled Workflow Execution reports "Completed" instead of "Canceled."
  • Every other exception — including real bugs — disappears with only a log line instead of failing the Workflow Task or Workflow Execution, so there's no signal in the Event History that anything went wrong.

Catch the narrowest type you can actually recover from, log and rethrow anything you don't recognize, and let Error and CanceledFailure propagate untouched:

try {
return doWorkflowLogic();
} catch (ActivityFailure e) {
if (e.getCause() instanceof ApplicationFailure appFailure
&& "ValidationError".equals(appFailure.getType())) {
return defaultResult();
}
throw e; // don't recognize it — propagate
}

Wrap checked exceptions instead of adding them to method signatures

Activity and Workflow method signatures shouldn't declare throws for checked exceptions. Instead, wrap a checked exception with Activity.wrap() inside an Activity, or Workflow.wrap() inside a Workflow, before rethrowing it:

static class GreetingActivitiesImpl implements GreetingActivities {
@Override
public String composeGreeting(String greeting, String name) {
try {
return callExternalService(greeting, name); // declares `throws IOException`
} catch (IOException e) {
throw Activity.wrap(e);
}
}
}

wrap() only does something to a checked exception. Given that, you don't need to reason about what to pass it:

  • If e is a checked exception, wrap() returns a CheckedExceptionWrapper around it. The SDK unwraps it automatically while propagating the failure and attaches the original exception as the cause of the resulting ApplicationFailure — so the caller still sees your original exception type and message in the cause chain.
  • If e already extends RuntimeException, wrap() returns it unchanged. Calling wrap() on an unchecked exception is a safe no-op, so you don't need to check the exception type before calling it.
  • If e extends Error, wrap() rethrows it directly instead of wrapping it, consistent with never catching Throwable or Error.

This also answers the two questions that come up most when working with wrapped exceptions:

  • Do you need to wrap exceptions you throw yourself? Only checked exceptions need wrap(). Any unhandled exception an Activity or Workflow throws — checked or not — is already converted to an ApplicationFailure automatically when it crosses the Activity or Workflow boundary. wrap() exists to satisfy the Java compiler when you want to throw a checked exception from a method that doesn't declare it, not to make the exception propagate.
  • Do you need to re-wrap an exception after unwrapping it? No. Once you've unwrapped a cause to inspect it, either rethrow the failure you caught (throw e;) or throw a new ApplicationFailure with the original exception set as its cause. There's no wrapper left to reapply — wrap() only matters at the point where a checked exception would otherwise need a throws declaration.

Read a failure's cause chain

An exception thrown from an Activity or Child Workflow arrives at the caller wrapped with context about where it failed. A failure from an Activity called from a Child Workflow called from a parent Workflow looks like this by the time it reaches a synchronous client call:

WorkflowFailedException (thrown to the client)
└─ ChildWorkflowFailure (the child Workflow Execution failed)
└─ ActivityFailure (the Activity Execution failed)
└─ ApplicationFailure (what your code actually threw)

Each wrapper adds context — ActivityFailure carries the Activity Type and Activity Id, ChildWorkflowFailure carries the Workflow Type and Workflow Id — while getCause() on each layer moves toward what actually failed. See Temporal Failures reference for what each of these types means for retry behavior.

Two details matter once you reach an ApplicationFailure:

  • Read getOriginalMessage(), not getMessage(). getMessage() returns a decorated string such as message='Invalid credit card number', type='ValidationError', nonRetryable=true — meant for logs, not parsing. getOriginalMessage() returns the exact text you threw.
  • Match on getType(), a stable String, not instanceof your original exception class. ApplicationFailure is final and the original exception object doesn't survive serialization: when an Activity in another process (or another SDK language) throws, the caller only ever gets an ApplicationFailure back, never your custom exception type. type defaults to the thrown exception's fully qualified class name unless you set it explicitly with ApplicationFailure.newFailure(message, type, ...).
try {
return activities.processCreditCard(orderId);
} catch (ActivityFailure e) {
if (e.getCause() instanceof ApplicationFailure appFailure) {
if ("ValidationError".equals(appFailure.getType())) {
return Result.rejected(appFailure.getOriginalMessage());
}
}
throw e;
}

Handle Activity and Child Workflow failures

Catch ActivityFailure (or ChildWorkflowFailure) around a call, not ApplicationFailure directly — the Activity or Child Workflow boundary always wraps the underlying failure. Always check for CanceledFailure as the cause before handling anything else, and rethrow it unhandled:

try {
return activities.charge(order);
} catch (ActivityFailure e) {
if (e.getCause() instanceof CanceledFailure) {
throw e; // never swallow cancellation
}
if (e.getCause() instanceof ApplicationFailure appFailure
&& "PaymentDeclined".equals(appFailure.getType())) {
return Result.declined(appFailure.getOriginalMessage());
}
throw e; // don't recognize it — propagate
}

For deciding which failures should skip retries, see Non-Retryable Errors, which has a Java example for both marking a failure non-retryable at the throw site and listing non-retryable types in a RetryPolicy. For undoing the effects of Activities that already succeeded before a later step failed, see the Saga Pattern, which has a Java example built on the SDK's Saga helper. For the full set of retry strategies, see Error Handling & Retry Patterns.

Cancellation

Cancellation is cooperative — the Worker never force-stops running Workflow code. A cancellation request cancels the current Cancellation Scope, and the next cancelable call inside it (an Activity, Timer, or Child Workflow) throws CanceledFailure. If you need cleanup to run after a cancellation — for example, compensating an Activity that already applied its effect — run it in a detached Cancellation Scope, since a normal scope is a child of the one that was just canceled and any call inside it would be canceled immediately:

try {
activities.longRunningWork();
} catch (CanceledFailure e) {
Workflow.newDetachedCancellationScope(() -> activities.compensate()).run();
throw e; // rethrow after cleanup so the Workflow Execution ends "Canceled"
}

Centralize failure conversion with a Worker Interceptor

Activity code that calls several external services often ends up repeating the same catch blocks to convert domain exceptions into ApplicationFailure with a consistent type and non-retryable classification. A WorkerInterceptor that overrides ActivityInboundCallsInterceptor.execute() centralizes that mapping in one place instead of repeating it in every Activity implementation:

public final class ErrorNormalizingWorkerInterceptor extends WorkerInterceptorBase {
@Override
public ActivityInboundCallsInterceptor interceptActivity(ActivityInboundCallsInterceptor next) {
return new ActivityInboundCallsInterceptorBase(next) {
@Override
public ActivityOutput execute(ActivityInput input) {
try {
return super.execute(input);
} catch (ApplicationFailure | TimeoutFailure | CanceledFailure f) {
throw f; // already a well-formed Temporal failure — pass through
} catch (PaymentDeclinedException e) {
throw ApplicationFailure.newNonRetryableFailure(e.getMessage(), "PaymentDeclined", e.toDetail());
} catch (Exception e) {
throw Activity.wrap(e); // uniform fallback: type = class name, retryable
}
}
};
}
}

Register it on the Worker Factory:

WorkerFactoryOptions.newBuilder()
.setWorkerInterceptors(new ErrorNormalizingWorkerInterceptor())
.build();

Because the interceptor sees the original exception before the SDK's default conversion runs, it's the layer to attach details and a non-retryable classification consistently, rather than depending on every Activity implementation to do it the same way. WorkerInterceptor and ActivityInboundCallsInterceptor are marked @Experimental. For the general interceptor model — inbound versus outbound, and the other call categories you can intercept — see Interceptors.

Fail a Workflow deliberately

Throwing ApplicationFailure from Workflow code is the only way to fail a Workflow Execution deliberately. Any other unhandled exception fails only the current Workflow Task, which the Worker retries indefinitely — this is intentional: a plain bug should be fixable with a new deployment, not permanently fail every Workflow Execution that hit it.

if (order.getTotal().compareTo(BigDecimal.ZERO) <= 0) {
throw ApplicationFailure.newNonRetryableFailure(
"Order total must be positive: " + order.getTotal(), "InvalidOrderTotal");
}

If you want specific plain exception types to fail the Workflow Execution instead of retrying the Workflow Task, list them with WorkflowImplementationOptions.setFailWorkflowExceptionTypes() when registering the Workflow implementation.

Never extend TemporalFailure or any of its subclasses in application code — throw ApplicationFailure instead. The SDK reserves the other subclasses (ActivityFailure, ChildWorkflowFailure, CanceledFailure, TimeoutFailure, TerminatedFailure, ServerFailure) for its own use.