Error handling - Java SDK
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:
DestroyWorkflowThreadErrorinterrupts 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.UnsupportedVersionis thrown byWorkflow.getVersion()when replayed history was produced by code outside the version range the current Workflow code declares. It extendsErrorspecifically 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:
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).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."ActivityFailure,ChildWorkflowFailure, orApplicationFailurethat you recognize and can recover from — handle it.- Everything else — rethrow it. A plain
RuntimeExceptionthat 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 anApplicationFailure(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:
DestroyWorkflowThreadErrorandUnsupportedVersionare swallowed instead of reaching the SDK, which can stall Worker cache eviction and interfere with replay.CanceledFailureis 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
eis a checked exception,wrap()returns aCheckedExceptionWrapperaround it. The SDK unwraps it automatically while propagating the failure and attaches the original exception as thecauseof the resultingApplicationFailure— so the caller still sees your original exception type and message in the cause chain. - If
ealready extendsRuntimeException,wrap()returns it unchanged. Callingwrap()on an unchecked exception is a safe no-op, so you don't need to check the exception type before calling it. - If
eextendsError,wrap()rethrows it directly instead of wrapping it, consistent with never catchingThrowableorError.
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 anApplicationFailureautomatically 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 newApplicationFailurewith 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 athrowsdeclaration.
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(), notgetMessage().getMessage()returns a decorated string such asmessage='Invalid credit card number', type='ValidationError', nonRetryable=true— meant for logs, not parsing.getOriginalMessage()returns the exact text you threw. - Match on
getType(), a stableString, notinstanceofyour original exception class.ApplicationFailureisfinaland the original exception object doesn't survive serialization: when an Activity in another process (or another SDK language) throws, the caller only ever gets anApplicationFailureback, never your custom exception type.typedefaults to the thrown exception's fully qualified class name unless you set it explicitly withApplicationFailure.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.