Skip to content

Commit 2769b64

Browse files
Add global retry limit (#298)
* Add global retry limit Assisted-By: Codex * Apply Palantir Java Format * Bump version to RC9 Assisted-By: Codex --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent c301fc4 commit 2769b64

12 files changed

Lines changed: 318 additions & 11 deletions

File tree

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ ext {
8484

8585
subprojects {
8686
group = "com.github.sonus21"
87-
version = "4.0.0-RC8"
87+
version = "4.0.0-RC9"
8888

8989
dependencies {
9090
// https://mvnrepository.com/artifact/org.springframework/spring-messaging

docs/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ foundational Spring Boot 4 and Jackson 3 migration notes; RC3 for the Java 17
1818
baseline change; RC4–RC6 below for the NATS backend, broker SPI, dashboard
1919
work, and middleware additions that build on top.
2020

21+
## Release [4.0.0.RC9] 2026-05-13
22+
23+
{: .highlight}
24+
Release candidate.
25+
26+
### Features
27+
* **Global retry limit** — added `rqueue.retry.max` to cap the implicit
28+
retry-forever default for listeners that do not configure `numRetries`.
29+
Explicit per-listener retry counts and the existing DLQ retry default continue
30+
to take precedence. On NATS, the effective retry count maps to JetStream
31+
`maxDeliver` as `retries + 1`.
32+
2133
## Release [4.0.0.RC8] 2026-05-09
2234

2335
{: .highlight}

docs/configuration/retry-and-backoff.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ Note: Messages handled this way are neither retried nor moved to the Dead Letter
4444
When a message handler fails, the message can be retried immediately, delayed for a
4545
future retry, moved to a dead letter queue, or dropped.
4646

47+
### Global Retry Limit
48+
49+
Set `rqueue.retry.max=N` to limit listeners that do not configure `numRetries` and
50+
would otherwise retry forever. The default is `-1`, which leaves the retry-forever
51+
default unchanged. Explicit `@RqueueListener(numRetries = "...")` values and the
52+
dead-letter queue default retry count continue to take precedence.
53+
54+
For the NATS backend, this retry count is translated to JetStream `maxDeliver` as
55+
`N + 1`, because JetStream counts the initial delivery plus retries. For example,
56+
`rqueue.retry.max=3` creates consumers with at most four total deliveries.
57+
4758
### Immediate Retries
4859
To retry a message immediately within the same polling cycle, set
4960
`rqueue.retry.per.poll` to a positive integer (e.g., `2`). This will cause the
@@ -75,5 +86,3 @@ public class RqueueConfiguration {
7586
}
7687
```
7788

78-
79-

rqueue-core/src/main/java/com/github/sonus21/rqueue/config/RqueueConfig.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ private static String generateBrokerId() {
134134
@Value("${rqueue.retry.per.poll:1}")
135135
private int retryPerPoll;
136136

137+
@Value("${rqueue.retry.max:-1}")
138+
private int maxRetry = -1;
139+
137140
@Value("${rqueue.net.proxy.host:}")
138141
private String proxyHost;
139142

rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/RqueueMessageListenerContainer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,20 +471,20 @@ private AsyncTaskExecutor createTaskExecutor(
471471

472472
private List<QueueDetail> getQueueDetail(String queue, MappingInformation mappingInformation) {
473473
int numRetry = mappingInformation.getNumRetry();
474+
RqueueConfig rqueueConfig = rqueueBeanProvider.getRqueueConfig();
474475
if (!StringUtils.isEmpty(mappingInformation.getDeadLetterQueueName()) && numRetry == -1) {
475476
log.warn(
476477
"Dead letter queue {} is set but retry is not set",
477478
mappingInformation.getDeadLetterQueueName());
478479
numRetry = Constants.DEFAULT_RETRY_DEAD_LETTER_QUEUE;
479480
} else if (numRetry == -1) {
480-
numRetry = Integer.MAX_VALUE;
481+
numRetry = rqueueConfig.getMaxRetry() >= 0 ? rqueueConfig.getMaxRetry() : Integer.MAX_VALUE;
481482
}
482483
String priorityGroup = mappingInformation.getPriorityGroup();
483484
Map<String, Integer> priority = mappingInformation.getPriority();
484485
if (StringUtils.isEmpty(priorityGroup) && priority.size() == 1) {
485486
priorityGroup = Constants.DEFAULT_PRIORITY_GROUP;
486487
}
487-
RqueueConfig rqueueConfig = rqueueBeanProvider.getRqueueConfig();
488488
QueueDetail queueDetail = QueueDetail.builder()
489489
.name(queue)
490490
.queueName(rqueueConfig.getQueueName(queue))

rqueue-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
{
22
"groups": [],
33
"properties": [
4+
{
5+
"name": "rqueue.retry.max",
6+
"type": "java.lang.Integer",
7+
"description": "Global retry limit used when a listener does not configure numRetries and would otherwise retry forever. -1 preserves the retry-forever default. Explicit listener retry counts and DLQ defaults take precedence.",
8+
"defaultValue": -1,
9+
"sourceType": "com.github.sonus21.rqueue.config.RqueueConfig"
10+
},
411
{
512
"name": "rqueue.nats.auto-create-streams",
613
"type": "java.lang.Boolean",

rqueue-core/src/test/java/com/github/sonus21/rqueue/config/RqueueConfigTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,9 @@ void workerRegistryProperties() {
154154
assertEquals(
155155
Duration.ofSeconds(15), rqueueConfigVersion2.getWorkerRegistryQueueHeartbeatInterval());
156156
}
157+
158+
@Test
159+
void defaultMaxRetryIsDisabled() {
160+
assertEquals(-1, rqueueConfigVersion2.getMaxRetry());
161+
}
157162
}

rqueue-core/src/test/java/com/github/sonus21/rqueue/listener/RqueueMessageListenerContainerTest.java

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
import com.github.sonus21.rqueue.common.RqueueLockManager;
3434
import com.github.sonus21.rqueue.config.RqueueConfig;
3535
import com.github.sonus21.rqueue.config.RqueueWebConfig;
36+
import com.github.sonus21.rqueue.core.EndpointRegistry;
3637
import com.github.sonus21.rqueue.core.RqueueBeanProvider;
3738
import com.github.sonus21.rqueue.core.RqueueMessage;
3839
import com.github.sonus21.rqueue.core.RqueueMessageTemplate;
3940
import com.github.sonus21.rqueue.dao.RqueueSystemConfigDao;
41+
import com.github.sonus21.rqueue.models.Concurrency;
4042
import com.github.sonus21.rqueue.models.db.MessageMetadata;
4143
import com.github.sonus21.rqueue.models.db.QueueConfig;
4244
import com.github.sonus21.rqueue.models.enums.MessageStatus;
@@ -67,6 +69,7 @@
6769
import org.springframework.data.redis.connection.RedisConnectionFactory;
6870
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
6971
import org.springframework.util.LinkedMultiValueMap;
72+
import org.springframework.util.MultiValueMap;
7073

7174
@CoreUnitTest
7275
class RqueueMessageListenerContainerTest extends TestBase {
@@ -172,6 +175,68 @@ void setTaskExecutor() {
172175
((ThreadPoolTaskExecutor) container.getTaskExecutor()).getThreadNamePrefix());
173176
}
174177

178+
@Test
179+
void globalMaxRetryCapsDefaultRetryForever() throws Exception {
180+
beanProvider.getRqueueConfig().setMaxRetry(5);
181+
doReturn(handlerMap(mapping(slowQueue, -1, null)))
182+
.when(rqueueMessageHandler)
183+
.getHandlerMethodMap();
184+
185+
container.afterPropertiesSet();
186+
187+
assertEquals(5, EndpointRegistry.get(slowQueue).getNumRetry());
188+
}
189+
190+
@Test
191+
void globalMaxRetryDoesNotOverrideExplicitRetryCount() throws Exception {
192+
beanProvider.getRqueueConfig().setMaxRetry(2);
193+
doReturn(handlerMap(mapping(slowQueue, 10, null)))
194+
.when(rqueueMessageHandler)
195+
.getHandlerMethodMap();
196+
197+
container.afterPropertiesSet();
198+
199+
assertEquals(10, EndpointRegistry.get(slowQueue).getNumRetry());
200+
}
201+
202+
@Test
203+
void globalMaxRetryDoesNotRaiseLowerExplicitRetryCount() throws Exception {
204+
beanProvider.getRqueueConfig().setMaxRetry(5);
205+
doReturn(handlerMap(mapping(slowQueue, 2, null)))
206+
.when(rqueueMessageHandler)
207+
.getHandlerMethodMap();
208+
209+
container.afterPropertiesSet();
210+
211+
assertEquals(2, EndpointRegistry.get(slowQueue).getNumRetry());
212+
}
213+
214+
@Test
215+
void globalMaxRetryCanDisableRetries() throws Exception {
216+
beanProvider.getRqueueConfig().setMaxRetry(0);
217+
doReturn(handlerMap(mapping(slowQueue, -1, null)))
218+
.when(rqueueMessageHandler)
219+
.getHandlerMethodMap();
220+
221+
container.afterPropertiesSet();
222+
223+
assertEquals(0, EndpointRegistry.get(slowQueue).getNumRetry());
224+
}
225+
226+
@Test
227+
void globalMaxRetryDoesNotOverrideDeadLetterDefaultRetryCount() throws Exception {
228+
beanProvider.getRqueueConfig().setMaxRetry(0);
229+
doReturn(handlerMap(mapping(slowQueue, -1, slowQueue + "-dlq")))
230+
.when(rqueueMessageHandler)
231+
.getHandlerMethodMap();
232+
233+
container.afterPropertiesSet();
234+
235+
assertEquals(
236+
Constants.DEFAULT_RETRY_DEAD_LETTER_QUEUE,
237+
EndpointRegistry.get(slowQueue).getNumRetry());
238+
}
239+
175240
@Test
176241
void phaseSetting() {
177242
assertEquals(Integer.MAX_VALUE, container.getPhase());
@@ -534,6 +599,28 @@ private class TestListenerContainer extends RqueueMessageListenerContainer {
534599
}
535600
}
536601

602+
private MultiValueMap<MappingInformation, RqueueMessageHandler.HandlerMethodWithPrimary>
603+
handlerMap(MappingInformation mappingInformation) {
604+
LinkedMultiValueMap<MappingInformation, RqueueMessageHandler.HandlerMethodWithPrimary> map =
605+
new LinkedMultiValueMap<>();
606+
map.add(mappingInformation, new RqueueMessageHandler.HandlerMethodWithPrimary(null, false));
607+
return map;
608+
}
609+
610+
private MappingInformation mapping(String queue, int numRetry, String deadLetterQueueName) {
611+
return MappingInformation.builder()
612+
.queueNames(Collections.singleton(queue))
613+
.numRetry(numRetry)
614+
.deadLetterQueueName(deadLetterQueueName)
615+
.deadLetterConsumerEnabled(false)
616+
.visibilityTimeout(VISIBILITY_TIMEOUT)
617+
.active(false)
618+
.concurrency(new Concurrency(0, 0))
619+
.priority(Collections.singletonMap(Constants.DEFAULT_PRIORITY_KEY, 1))
620+
.batchSize(1)
621+
.build();
622+
}
623+
537624
@Getter
538625
private class StubMessageSchedulerListenerContainer extends RqueueMessageListenerContainer {
539626

rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,15 +510,16 @@ public static Duration resolveAckWait(QueueDetail q, RqueueNatsConfig config) {
510510
/**
511511
* Resolve the JetStream {@code maxDeliver} from per-queue {@link QueueDetail#getNumRetry()}
512512
* (counted as initial delivery + N retries = numRetry + 1). The {@link Integer#MAX_VALUE}
513-
* "retry forever" sentinel maps to JetStream's unlimited value ({@code -1}); non-positive
514-
* numRetry falls back to {@code RqueueNatsConfig.ConsumerDefaults.getMaxDeliver()}.
513+
* "retry forever" sentinel maps to JetStream's unlimited value ({@code -1}); zero means
514+
* one total delivery and no retries. Negative numRetry falls back to
515+
* {@code RqueueNatsConfig.ConsumerDefaults.getMaxDeliver()}.
515516
*/
516517
public static long resolveMaxDeliver(QueueDetail q, RqueueNatsConfig config) {
517518
int numRetry = q.getNumRetry();
518519
if (numRetry == Integer.MAX_VALUE) {
519520
return -1L;
520521
}
521-
if (numRetry > 0) {
522+
if (numRetry >= 0) {
522523
return numRetry + 1L;
523524
}
524525
return config.getConsumerDefaults().getMaxDeliver();

rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBrokerResolveTest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,9 @@ void resolveMaxDeliver_isNumRetryPlusOne() {
7171
}
7272

7373
@Test
74-
void resolveMaxDeliver_fallsBackToConfigDefaultWhenZero() {
74+
void resolveMaxDeliver_zeroRetryMeansOneDelivery() {
7575
QueueDetail q = queue(30_000L, 0);
76-
// RqueueNatsConfig.defaults().consumerDefaults.maxDeliver = 3
77-
assertEquals(3L, JetStreamMessageBroker.resolveMaxDeliver(q, RqueueNatsConfig.defaults()));
76+
assertEquals(1L, JetStreamMessageBroker.resolveMaxDeliver(q, RqueueNatsConfig.defaults()));
7877
}
7978

8079
@Test

0 commit comments

Comments
 (0)