This version is still in development and is not considered stable yet. For the latest stable version, please use Spring Batch Documentation 6.0.5!

Configuring Retry Logic

In most cases, you want an exception to cause either a skip or a Step failure. However, not all exceptions are deterministic. If a FlatFileParseException is encountered while reading, it is always thrown for that record. Resetting the ItemReader does not help. However, for other exceptions (such as a DeadlockLoserDataAccessException, which indicates that the current process has attempted to update a record that another process holds a lock on), waiting and trying again might result in success.

  • Java

  • XML

In Java, retry should be configured as follows:

@Bean
public Step step1(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
    // retry policy configuration
    int retryLimit = 3;
    var retrybaleExceptions = Set.of(DeadlockLoserDataAccessException.class);
    RetryPolicy retryPolicy = RetryPolicy.builder()
        .maxRetries(retryLimit)
        .includes(retrybaleExceptions)
        .build();

	return new StepBuilder("step1", jobRepository)
				.<String, String>chunk(2).transactionManager(transactionManager)
				.reader(itemReader())
				.writer(itemWriter())
				.faultTolerant()
				.retryPolicy(retryPolicy)
				.build();
}

In XML, retry should be configured as follows:

<step id="step1">
   <tasklet>
      <chunk reader="itemReader" writer="itemWriter"
             commit-interval="2" retry-limit="3">
         <retryable-exception-classes>
            <include class="org.springframework.dao.DeadlockLoserDataAccessException"/>
         </retryable-exception-classes>
      </chunk>
   </tasklet>
</step>

The Step allows a limit for the number of times an individual item can be retried and a list of exceptions that are “retryable”.

Retry and ItemReader

Retrying a failed ItemProcessor or ItemWriter call is always safe with respect to the forward-only ItemReader contract: the item being retried is already held by the step, so a retry simply re-invokes process/write with the exact same item.

Retrying a failed ItemReader#read() call is different, because the item does not exist yet: read() is what produces it. Whether a retry re-attempts the same logical item or silently moves on to the next one depends entirely on the reader implementation, not on the framework: on a failed read, ChunkOrientedStep simply calls read() again on the same reader instance, with no coordination beyond that. This is safe as long as the reader does not advance its internal position until it is about to return an item successfully. Built-in paging-style readers (such as JdbcPagingItemReader) already follow this rule: each page is fetched by a fresh, self-contained query, and the current position advances only once that query succeeds. Cursor-based readers (such as JdbcCursorItemReader) hold a live ResultSet/Connection across calls, so a retry can only help with narrower transient hiccups that do not actually invalidate the cursor.

A custom reader that consumes its underlying source before deciding whether to throw is not retry-safe:

public class UnsafeItemReader extends ListItemReader<String> {

	@Override
	public String read() {
		String item = super.read(); (1)
		validate(item); (2)
		return item;
	}

}
1 The delegate is consumed here, advancing its position, regardless of what happens next.
2 If this throws and the read is retried, the next call to read() invokes super.read() again and returns a different item. The item that failed validation is never retried, never skipped, and never seen again — it is silently dropped.

Contrast this with a reader whose position only advances after a successful read:

public class SafeItemReader implements ItemReader<String> {

	private long lastId = 0;

	@Override
	public String read() {
		Row row = fetchNext(lastId); // does not mutate any reader state
		if (row == null) {
			return null;
		}
		lastId = row.id(); // only advances on success
		return row.value();
	}

}

Here, a read() call that throws before returning has not moved lastId, so a retry re-issues the exact same fetch and returns the exact same next item.

This is also why retryable exceptions on read should be reserved for genuinely transient conditions (a dropped connection, a timeout, a deadlock), as described above. A deterministic exception, such as one raised while parsing invalid input, is thrown for the same record every time: retrying it only spends the configured retry attempts before eventually failing (or being skipped, if a skip policy also applies) anyway. Route deterministic read failures to a SkipPolicy instead of a RetryPolicy.