Fault tolerance: skip & retry
|
This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. |
Two different failures need two different answers. A bad record is permanent — retrying will not fix it, so skip it and carry on. A transient failure — a deadlock, a timeout, a briefly unavailable service — will probably succeed on the next attempt, so retry it. Spring Batch expresses both declaratively.
Turning on fault tolerance
.faultTolerant() switches the chunk step into the fault-tolerant implementation, which is what makes the
skip and retry options available:
@Bean
public Step loadTradesStep(JobRepository jobRepository, PlatformTransactionManager tx,
ItemReader<TradeCsv> reader,
ItemProcessor<TradeCsv, Trade> processor,
ItemWriter<Trade> writer) {
return new StepBuilder("loadTradesStep", jobRepository)
.<TradeCsv, Trade>chunk(500, tx)
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.skip(FlatFileParseException.class)
.skip(ValidationException.class)
.skipLimit(50)
.retry(DeadlockLoserDataAccessException.class)
.retry(TransientDataAccessException.class)
.retryLimit(3)
.build();
}
Fault tolerance is not free: it changes how the chunk is executed (see Replay semantics below) and adds bookkeeping per item. Turn it on where it is needed, not everywhere.
Skip
Skipping discards the offending item and lets the chunk continue. It applies to exceptions thrown by the
reader, the processor or the writer, and each is counted separately (readSkipCount, processSkipCount,
writeSkipCount).
.faultTolerant()
.skip(FlatFileParseException.class) // a malformed line
.skip(ValidationException.class) // a record that fails business validation
.noSkip(FileNotFoundException.class) // but never skip a missing input file
.skipLimit(100) // fail the step after 100 skips
.skipLimit(n) is a safety valve, not a target: exceeding it fails the step with
SkipLimitExceededException, which is the correct outcome when a feed is broadly corrupt rather than
occasionally imperfect. .noSkip(…) carves exceptions out of a broader .skip(…) declaration — the
exception hierarchy is evaluated most-specific-first.
SkipPolicy
When the decision depends on more than the exception type, implement SkipPolicy:
public class BusinessHoursSkipPolicy implements SkipPolicy {
@Override
public boolean shouldSkip(Throwable throwable, long skipCount) throws SkipLimitExceededException {
if (throwable instanceof FileNotFoundException) {
return false; // never skip
}
if (throwable instanceof ValidationException && skipCount < 500) {
return true; // tolerate many bad records
}
if (throwable instanceof FlatFileParseException && skipCount < 10) {
return true; // tolerate few parse errors
}
return false;
}
}
.faultTolerant()
.skipPolicy(new BusinessHoursSkipPolicy())
LimitCheckingExceptionHierarchySkipPolicy is the built-in policy behind the .skip(…)/.skipLimit(…)
shorthand: it takes a map of exception class to "skippable?" and a limit, walking the exception hierarchy to
find the most specific entry. AlwaysSkipItemSkipPolicy and NeverSkipItemSkipPolicy are the degenerate
cases, useful in tests.
SkipListener
A skipped item that nobody records is data silently lost. A SkipListener is the place to write it to a
rejects file or a quarantine table:
@Component
public class RejectedItemListener implements SkipListener<TradeCsv, Trade> {
private static final Logger log = LoggerFactory.getLogger(RejectedItemListener.class);
private final RejectRepository rejectRepository;
public RejectedItemListener(RejectRepository rejectRepository) {
this.rejectRepository = rejectRepository;
}
@Override
public void onSkipInRead(Throwable throwable) {
log.warn("unparseable input line", throwable);
rejectRepository.saveReadFailure(throwable.getMessage());
}
@Override
public void onSkipInProcess(TradeCsv item, Throwable throwable) {
rejectRepository.save(item.getExternalId(), "process", throwable.getMessage());
}
@Override
public void onSkipInWrite(Trade item, Throwable throwable) {
rejectRepository.save(item.getId(), "write", throwable.getMessage());
}
}
Register it with .listener(rejectedItemListener) on the fault-tolerant builder. Note that
onSkipInWrite runs after the chunk’s transaction rolled back, so the listener’s own writes need their own
transaction (REQUIRES_NEW) or a non-transactional sink.
Retry
Retry re-attempts the operation, transparently, before any skip decision is made:
.faultTolerant()
.retry(DeadlockLoserDataAccessException.class)
.retry(OptimisticLockingFailureException.class)
.retryLimit(3)
.backOffPolicy(exponentialBackOff())
.noRetry(DataIntegrityViolationException.class) // a constraint violation will not fix itself
Which retry API applies depends on which step builder is used, and the two cannot be mixed.
-
The
.faultTolerant()builder shown above isFaultTolerantStepBuilder, which is still built on the Spring Retry library: its.retryPolicy(…)and.listener(…)takeorg.springframework.retry.RetryPolicy,org.springframework.retry.backoff.BackOffPolicyandorg.springframework.retry.RetryListener. Spring Batch 6.0 still declares aspring-retrydependency. -
The newer
ChunkOrientedStepBuilder(see Chunk-oriented processing) uses Spring Framework 7’s own retry support inorg.springframework.core.retryinstead.
So a RetryListener written against org.springframework.core.retry cannot be attached to a
.faultTolerant() step, and vice versa — match the imports to the builder in use.
RetryPolicy and BackOffPolicy
@Bean
public BackOffPolicy exponentialBackOff() {
ExponentialBackOffPolicy policy = new ExponentialBackOffPolicy();
policy.setInitialInterval(200L); // ms before the first retry
policy.setMultiplier(2.0); // 200, 400, 800 ...
policy.setMaxInterval(5_000L);
return policy;
}
Back-off matters: retrying a contended row three times without pausing usually reproduces the same deadlock.
For a policy that cannot be expressed as "these exception types, n times", supply a RetryPolicy directly with
.retryPolicy(…); the interface and the standalone RetryTemplate are covered in
Repeat & retry internals.
RetryListener
The listener below implements the Spring Framework 7 org.springframework.core.retry.RetryListener, so it
attaches to a ChunkOrientedStepBuilder step. A .faultTolerant() step needs the Spring Retry equivalent
(org.springframework.retry.RetryListener, whose callbacks are onError/close) passed to .listener(…).
@Component
public class RetryLoggingListener implements RetryListener {
private static final Logger log = LoggerFactory.getLogger(RetryLoggingListener.class);
@Override
public void onRetryFailure(RetryExecution execution, Retryable<?> retryable, Throwable throwable) {
log.warn("retrying after failure", throwable);
}
@Override
public void onRetryPolicyExhaustion(RetryExecution execution, Retryable<?> retryable, Throwable throwable) {
log.error("retries exhausted", throwable);
}
}
Retry listeners are the natural place for a metric — a counter of retried operations is an early warning that a downstream system is degrading. See Observability.
noRollback
By default any exception rolls the chunk’s transaction back. .noRollback(…) declares exceptions that
should not:
.faultTolerant()
.noRollback(DuplicateKeyIgnoredException.class)
Use it only when the exception genuinely leaves the transaction usable — typically a business-level signal thrown after the writer already handled the situation. A database that has marked the transaction rollback-only will not honour the request no matter what the step says.
Replay semantics, and why the processor must be idempotent
This is the part that surprises people. When a chunk fails and skipping is enabled, the framework cannot know which item caused the failure — the writer was handed the whole list. So it:
-
rolls the transaction back;
-
replays the chunk, re-reading the items from its internal cache and re-processing them;
-
re-writes them one at a time (the "scan" pass), so the failing item can be identified;
-
skips that one item and commits the rest.
The consequences:
-
the
ItemProcessorruns more than once for the same item. If it has side effects — sending an e-mail, incrementing a counter, calling a payment API — those side effects happen repeatedly. The processor must be idempotent, which is why side effects belong in the writer, not the processor. See ItemProcessors. -
the
ItemWritermay be called with single-item lists during the scan, so it must handle a list of any size; -
rollbackCountrises and throughput drops sharply while a chunk is being scanned — frequent skipping is expensive, which is another reasonskipLimitshould be modest.
If a processor genuinely cannot be made idempotent, .processorNonTransactional() tells the step to cache
processed items rather than re-process them on replay — at the cost of holding the outputs in memory:
.faultTolerant()
.skip(ValidationException.class)
.skipLimit(20)
.processorNonTransactional()