Building fault-tolerant Java services with Spring Retry
Network calls fail. Databases time out. APIs go down right when a customer hits the checkout. For Java developers shipping integrations to production, retry logic is not optional — it separates a smooth user experience from a frustrated support inbox. Spring Retry offers declarative and programmatic tools for handling transient failures without polluting business code with boilerplate try-catch loops.
It plugs neatly into Spring Boot, plays nicely with Spring AOP, and gives fine-grained control over retry count, delay, and which exceptions deserve another chance. Whether you are calling a payment processor like the Commonwealth Bank's merchant API or syncing with a legacy ERP over a flaky NBN link, the same patterns apply.
This guide covers practical steps for adding retry support to a real Spring Boot application: wiring up Maven dependencies, annotating service methods, configuring exponential backoff, writing programmatic templates, and verifying behaviour with unit tests. Sections build on each other, so follow in order or jump to what you need.
You will leave with a clear mental model for deciding when retry helps and when it makes things worse — a question that comes up often at YOW! Conference workshops or the Melbourne Java User Group.
Adding Spring Retry to a Spring Boot project
Spring Retry ships as a standalone library on Maven Central, separate from Spring Boot itself. Add the core dependency and let Spring Boot's auto-configuration handle AOP weaving once the right starter is on the classpath.
For Maven, the two artefacts you need are spring-retry and spring-boot-starter-aop. The AOP starter is essential because Spring Retry relies on proxy-based interception — without it, @Retryable annotations are silently ignored. Gradle users add the same coordinates in build.gradle.
Modern setups on JDK 17 or 21 need no extra configuration. For most Australian enterprise teams running current LTS releases, this is a non-issue.
Declarative retry with @Retryable and @Recover
The cleanest way to apply retry logic is the @Retryable annotation. Place it on a service-layer method that performs an external call, and Spring wraps the method in a proxy that intercepts exceptions and replays the call according to your rules.
Retrying a RestClientException makes sense, but retrying an IllegalArgumentException wastes resources. Use value or include to whitelist recoverable types and propagate everything else. Pair @Retryable with @Recover on a sibling method for a fallback path — logging the failure, writing to a dead-letter table, or pinging the on-call rotation during AEST business hours.
A common pattern in Australian fintech codebases is to recover by queuing the failed operation for an asynchronous batch process, so the customer gets immediate acknowledgement while reconciliation happens behind the scenes.
Tuning backoff, attempts, and listeners
Default behaviour — three attempts, one second apart — works for demos but rarely survives production. The @Backoff annotation controls delay with options for fixed waits, exponential growth, or a custom multiplier. Exponential backoff with jitter is the gold standard because it spreads retry storms across time, preventing the synchronised wave that takes down downstream services after a brief outage.
For visibility, register a RetryListener. It exposes onError, onSuccess, and onClosed hooks so you can emit Micrometer metrics or log structured JSON for Splunk. Teams shipping services to Westpac, ANZ, or NAB often expose a /actuator/retries endpoint surfacing live retry counters — invaluable when paged at 3 am during a Sydney data-centre incident.
Programmatic retry with RetryTemplate
Annotations are great for straightforward cases, but sometimes you need conditional logic that cannot be expressed declaratively. RetryTemplate gives full programmatic control. Build it once as a Spring bean with a RetryPolicy and a BackOffPolicy, then inject it wherever you need retry semantics.
This approach works when the decision depends on runtime data — inspecting a response payload for a specific error code, or branching based on the customer's SLA tier. Programmatic templates also shine when wrapping legacy static utilities or third-party SDKs you cannot annotate directly.
Idempotency and exception classification
Retry is dangerous when applied to non-idempotent operations. Sending a transfer-funds request twice because the first response timed out can lead to double charges — the kind of bug that ends up on the front page of the Australian Financial Review's technology section. Confirm the upstream service is idempotent via an idempotency key before retrying write operations.
Classify exceptions into three buckets: transient (network timeout, 503 Service Unavailable, database deadlock), permanent (validation errors, authentication failures, 404 Not Found), and unknown. Only retry the transient bucket. Use @Retryable's noRetryFor attribute to exclude permanent failures, and pair retry with a Resilience4j circuit breaker for sustained outages.
Testing retry behaviour
A retry implementation not covered by tests breaks silently the moment someone bumps a dependency version. Write JUnit tests with Mockito to throw the target exception on the first call and return successfully on the second. Use Awaitility or a CountDownLatch for asynchronous retries.
Integration tests should hit a WireMock stub returning 500 responses for the first N requests before flipping to 200. Run the suite against staging — often in the ap-southeast-2 (Sydney) or ap-southeast-4 (Melbourne) AWS regions — and confirm retry metrics appear in your observability stack.
Recommendations for production-ready retry logic
- Keep retry counts low — three to five attempts is usually enough before giving up.
- Always combine exponential backoff with jitter to avoid retry storms.
- Restrict
@Retryableto transient exceptions and exclude permanent ones. - Wrap write operations behind an idempotency key verified end-to-end with the downstream service.
- Expose retry counters as Micrometer metrics and dashboard them in Grafana.
- Pair retry with a Resilience4j circuit breaker for sustained outages.
- Test both the retry path and the recovery path; never trust an untested fallback.