A failed transport attempt can remain PENDING while its next_due timestamp prevents immediate reprocessing.
Spring scheduled retries: leave failed work pending until its due time
One fake failure, then success
The scheduled fixture gives EVENT-42 a fake transport that throws once. The poller records one failure, clears the claim and sets next_due 80 milliseconds after that failure. A later scheduled tick claims the same event ID, calls the local consumer and acknowledges the row. The test waits for delivery, then checks two transport calls, one failure record and stock changing from 10 to 8 once.
The separate logical-clock test checks exact due-time boundaries and a capped delay. This scheduled test checks that Spring runs a callback again after a real short wait. It does not prove the scheduler runs exactly at the due timestamp; process load can delay it.
Classify before retrying
The fixture treats every RuntimeException as retryable. A deployed adapter must separate temporary transport trouble from invalid payloads, permanent authorization failure and exhausted budgets. Add a maximum elapsed time, a parked state and alerting. Retry with the same event ID because a lost response may hide a committed consumer mutation.
The consumer in the fixture writes a unique event marker with stock. The lost-acknowledgement check shows why that marker matters even when a transport call appears to finish. Do not use sleep inside the callback to delay a retry; persist the due time and release the scheduler thread.
Checked source
catch (RuntimeException transportFailure) {
jdbc.update("update scheduled_outbox set failures = failures + 1, next_due = ?, claim_token = null, lease_until = null where event_id = ? and claim_token = ?", now + 80, eventId, token);
}Verification boundary
ScheduledOutboxPollerTest.failedTransportWaitsThenRetriesSameEvent. The excerpt is shortened or a labelled design sketch; the source kit contains the complete checked fixture.
Costs and limits
The excerpt shortens the checked SQL lease predicate. The test uses a fake exception and a short wall-clock delay; there is no external transport, jitter, error taxonomy or load test.
Common Mistakes
- Do not retry a permanent rejection forever.
- Do not sleep while holding the polling thread.
- Do not create a new event ID for the same retry.
Read next
Spring TaskScheduler: poll committed outbox rows in the background, Spring outbox retries: due time, delay cap, and attempt accounting, Spring outbox retry budget: park a poison event for inspection, Spring scheduled relay: recover when the consumer commits before acknowledgement.
