Field note / Java
When Your SMS Provider Goes Down: @Retryable vs JobRunr in Spring Boot 4
Spring Framework 7 ships retries in core. Here's when @Retryable is enough, when you need durable jobs like JobRunr, and how to avoid texting a patient twice.
Your SMS provider is down for ten minutes. With the wrong retry strategy, a patient either never gets their appointment reminder or gets it three times.
Both outcomes are bad, and both come from the same mistake: treating “retry” as one thing. A retry that runs while a user waits on an HTTP request and a retry that has to survive an outage, a deploy and a restart are different tools. Spring Boot 4 makes the first one easier than ever, which makes it tempting to use it for the second.
This post walks through one small example, a clinic app that texts appointment reminders, in three versions: no retries, Spring Framework 7’s built-in @Retryable, and a durable background job with JobRunr.
The setup
One endpoint, POST /appointments/{id}/reminder, calls an SmsClient that wraps the provider’s HTTP API:
@Service
public class ReminderService {
private final SmsClient smsClient;
private final AppointmentRepository appointments;
public ReminderService(SmsClient smsClient, AppointmentRepository appointments) {
this.smsClient = smsClient;
this.appointments = appointments;
}
public void sendReminder(long appointmentId) {
Appointment appointment = appointments.findById(appointmentId).orElseThrow();
smsClient.send(appointment.patientPhone(), appointment.reminderText());
}
}
SmsClient throws SmsUnavailableException for a 5xx, a timeout or a connection reset, and InvalidRecipientException for a 4xx like a malformed number. That split matters for everything that follows.
Version 1: no retries
The provider returns a 503. The exception is logged, the endpoint returns a 500, and whoever triggered the reminder may or may not notice. If it was a scheduled batch, nobody does. The patient never gets the text.
This is the default state of most integrations I’ve worked on: not wrong on the happy path, silently lossy on the unhappy one.
Version 2: @Retryable in Spring Framework 7
Spring Framework 7 (the base of Spring Boot 4) moved retry support into core, in org.springframework.resilience. You no longer need the separate spring-retry project for the common case.
Enable it once:
@Configuration
@EnableResilientMethods
public class ResilienceConfig {
}
Then annotate the call:
@Retryable(
includes = SmsUnavailableException.class,
maxRetries = 3,
delay = 500,
multiplier = 2,
jitter = 100
)
public void sendReminder(long appointmentId) {
// same body as before
}
A few details worth knowing:
maxRetries = 3means up to four calls in total: the first attempt plus three retries.- Retry only transient failures.
includes = SmsUnavailableException.classmeans a bad phone number fails immediately. Retrying a 400 four times doesn’t fix the number; it just quadruples the noise. - Jitter matters more than it looks. If a thousand reminders fail at the same second, a fixed backoff makes them all retry at the same second too. Jitter spreads them out.
- There is no
@Recover. When retries run out, the last exception propagates to the caller. Your fallback is a plaintry/catcharound the call, which is honestly easier to read. - You can observe attempts. Spring publishes a
MethodRetryEventfor each failed attempt, so an@EventListenergives you a log line per retry without touching the business code.
For a provider blip of a second or two, this is exactly right. The user waits a little longer, the reminder goes out, and nobody writes a ticket.
The ceiling of in-request retries
Now make the provider slow instead of down: every call takes 30 seconds before failing.
Each request now holds a server thread for up to two minutes across four attempts. Under load, requests pile up, the thread pool fills, and the reminder endpoint starts taking unrelated endpoints down with it. You’ve turned the provider’s outage into yours.
Spring Framework 7 also ships @ConcurrencyLimit, which caps how many calls can be in that method at once. That protects the rest of the app, and you should use it on any call to a third party. But it doesn’t solve the actual problem: if the provider is down for ten minutes, no amount of in-request retrying will deliver that reminder. The work has to outlive the request.
Version 3: a durable job with JobRunr
The endpoint stops sending the SMS and starts recording the intent to send it:
@PostMapping("/appointments/{id}/reminder")
public ResponseEntity<Void> scheduleReminder(@PathVariable long id) {
jobScheduler.enqueue(() -> reminderService.sendReminder(id));
return ResponseEntity.accepted().build();
}
@Job(name = "Send appointment reminder %0", retries = 10)
public void sendReminder(long appointmentId) {
// same body as before, no @Retryable
}
The job is stored in the database before the endpoint returns 202 Accepted. From there:
- If the provider is down, the job fails and JobRunr retries it with exponential backoff, on its own schedule, without holding any request thread.
- If you deploy or the app crashes mid-retry, the job is still in the database. When the app comes back, it picks up where it left off. This is the property
@Retryablecan never give you. - The JobRunr dashboard shows each job as enqueued, processing, failed or succeeded, with the exception for every failed attempt.
Check the default retry count and backoff for the JobRunr version you run; set retries explicitly so the behavior is visible in the code rather than in a default.
The retry that texts the patient twice
Durable retries introduce a new failure mode. The provider accepts the message, sends it, and then the response times out on the way back. From your side, the call failed. JobRunr retries. The patient gets two identical texts.
Retries are only safe if the operation is idempotent. For an SMS, that means:
- Give every reminder its own id when you create it, not when you send it.
- Before sending, check whether that reminder is already marked as sent. Skip it if so.
- If the provider supports an idempotency key, send the reminder id as that key, so the provider can deduplicate on its side too.
- Mark the reminder as sent in the same place you checked.
public void sendReminder(long reminderId) {
Reminder reminder = reminders.findById(reminderId).orElseThrow();
if (reminder.isSent()) {
return;
}
smsClient.send(reminder.phone(), reminder.text(), reminder.idempotencyKey());
reminders.markSent(reminderId);
}
Step 2 alone doesn’t close the gap where the provider sent the message but your process died before step 4. That gap is exactly what the provider-side idempotency key covers. If your provider has no such key, be honest about it: your own check reduces duplicates, it doesn’t eliminate them.
When retries run out
Some outages are longer than any retry policy. When JobRunr exhausts its retries, the job lands in the Failed state. That’s not the end of the story; it’s the moment a human needs to know.
- Alert on failed jobs, not on individual failed attempts. Attempts are expected; exhausted jobs are an incident.
- Once the provider recovers, requeue failed jobs from the dashboard. No deploy, no SQL, no script.
- Because the job is idempotent, requeueing everything is safe, including the ones that half-succeeded.
The decision
@Retryable |
JobRunr (durable job) | |
|---|---|---|
| Who is waiting | A user, on this request | Nobody, it’s background work |
| Failure length it handles | Seconds | Minutes to hours |
| Survives restart or deploy | No | Yes |
| Visibility | Logs, MethodRetryEvent |
Dashboard, per-job history |
| Recovery after exhaustion | Caller’s catch |
Failed state, requeue |
Use @Retryable for short blips on calls a user is waiting on. Use a durable job for anything that must not be lost. And whichever you use, make the operation idempotent first, because every retry strategy eventually retries something that already worked.