Skip to content

Commit 866eef6

Browse files
committed
Add global retry limit
Assisted-By: Codex
1 parent c301fc4 commit 866eef6

10 files changed

Lines changed: 296 additions & 10 deletions

File tree

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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@
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;
40+
import com.github.sonus21.rqueue.models.Concurrency;
3941
import com.github.sonus21.rqueue.dao.RqueueSystemConfigDao;
4042
import com.github.sonus21.rqueue.models.db.MessageMetadata;
4143
import com.github.sonus21.rqueue.models.db.QueueConfig;
@@ -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,62 @@ 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))).when(rqueueMessageHandler)
182+
.getHandlerMethodMap();
183+
184+
container.afterPropertiesSet();
185+
186+
assertEquals(5, EndpointRegistry.get(slowQueue).getNumRetry());
187+
}
188+
189+
@Test
190+
void globalMaxRetryDoesNotOverrideExplicitRetryCount() throws Exception {
191+
beanProvider.getRqueueConfig().setMaxRetry(2);
192+
doReturn(handlerMap(mapping(slowQueue, 10, null))).when(rqueueMessageHandler)
193+
.getHandlerMethodMap();
194+
195+
container.afterPropertiesSet();
196+
197+
assertEquals(10, EndpointRegistry.get(slowQueue).getNumRetry());
198+
}
199+
200+
@Test
201+
void globalMaxRetryDoesNotRaiseLowerExplicitRetryCount() throws Exception {
202+
beanProvider.getRqueueConfig().setMaxRetry(5);
203+
doReturn(handlerMap(mapping(slowQueue, 2, null))).when(rqueueMessageHandler)
204+
.getHandlerMethodMap();
205+
206+
container.afterPropertiesSet();
207+
208+
assertEquals(2, EndpointRegistry.get(slowQueue).getNumRetry());
209+
}
210+
211+
@Test
212+
void globalMaxRetryCanDisableRetries() throws Exception {
213+
beanProvider.getRqueueConfig().setMaxRetry(0);
214+
doReturn(handlerMap(mapping(slowQueue, -1, null))).when(rqueueMessageHandler)
215+
.getHandlerMethodMap();
216+
217+
container.afterPropertiesSet();
218+
219+
assertEquals(0, EndpointRegistry.get(slowQueue).getNumRetry());
220+
}
221+
222+
@Test
223+
void globalMaxRetryDoesNotOverrideDeadLetterDefaultRetryCount() throws Exception {
224+
beanProvider.getRqueueConfig().setMaxRetry(0);
225+
doReturn(handlerMap(mapping(slowQueue, -1, slowQueue + "-dlq"))).when(rqueueMessageHandler)
226+
.getHandlerMethodMap();
227+
228+
container.afterPropertiesSet();
229+
230+
assertEquals(Constants.DEFAULT_RETRY_DEAD_LETTER_QUEUE,
231+
EndpointRegistry.get(slowQueue).getNumRetry());
232+
}
233+
175234
@Test
176235
void phaseSetting() {
177236
assertEquals(Integer.MAX_VALUE, container.getPhase());
@@ -534,6 +593,28 @@ private class TestListenerContainer extends RqueueMessageListenerContainer {
534593
}
535594
}
536595

596+
private MultiValueMap<MappingInformation, RqueueMessageHandler.HandlerMethodWithPrimary>
597+
handlerMap(MappingInformation mappingInformation) {
598+
LinkedMultiValueMap<MappingInformation, RqueueMessageHandler.HandlerMethodWithPrimary> map =
599+
new LinkedMultiValueMap<>();
600+
map.add(mappingInformation, new RqueueMessageHandler.HandlerMethodWithPrimary(null, false));
601+
return map;
602+
}
603+
604+
private MappingInformation mapping(String queue, int numRetry, String deadLetterQueueName) {
605+
return MappingInformation.builder()
606+
.queueNames(Collections.singleton(queue))
607+
.numRetry(numRetry)
608+
.deadLetterQueueName(deadLetterQueueName)
609+
.deadLetterConsumerEnabled(false)
610+
.visibilityTimeout(VISIBILITY_TIMEOUT)
611+
.active(false)
612+
.concurrency(new Concurrency(0, 0))
613+
.priority(Collections.singletonMap(Constants.DEFAULT_PRIORITY_KEY, 1))
614+
.batchSize(1)
615+
.build();
616+
}
617+
537618
@Getter
538619
private class StubMessageSchedulerListenerContainer extends RqueueMessageListenerContainer {
539620

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
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright (c) 2026 Sonu Kumar
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* You may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and limitations under the License.
14+
*
15+
*/
16+
package com.github.sonus21.rqueue.spring.boot.integration;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import com.github.sonus21.rqueue.annotation.RqueueListener;
21+
import com.github.sonus21.rqueue.core.RqueueMessageEnqueuer;
22+
import io.nats.client.JetStreamManagement;
23+
import java.time.Duration;
24+
import java.util.concurrent.CountDownLatch;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.atomic.AtomicInteger;
27+
import org.awaitility.Awaitility;
28+
import org.junit.jupiter.api.BeforeAll;
29+
import org.junit.jupiter.api.Tag;
30+
import org.junit.jupiter.api.Test;
31+
import org.springframework.beans.factory.annotation.Autowired;
32+
import org.springframework.boot.autoconfigure.SpringBootApplication;
33+
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
34+
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
35+
import org.springframework.boot.test.context.SpringBootTest;
36+
import org.springframework.context.annotation.Import;
37+
import org.springframework.stereotype.Component;
38+
39+
@SpringBootTest(
40+
classes = NatsGlobalRetryLimitE2EIT.TestApp.class,
41+
properties = {
42+
"rqueue.backend=nats",
43+
"rqueue.retry.max=2",
44+
"rqueue.retry.per.poll=1000",
45+
"rqueue.nats.naming.stream-prefix=" + NatsGlobalRetryLimitE2EIT.STREAM_PREFIX,
46+
"rqueue.nats.naming.subject-prefix=" + NatsGlobalRetryLimitE2EIT.SUBJECT_PREFIX
47+
})
48+
@Tag("nats")
49+
class NatsGlobalRetryLimitE2EIT extends AbstractNatsBootIT {
50+
51+
static final String QUEUE = "global-retry-nats";
52+
static final String STREAM_PREFIX = "rqueue-js-globalRetryE2E-";
53+
static final String SUBJECT_PREFIX = "rqueue.js.globalRetryE2E.";
54+
55+
@BeforeAll
56+
static void wipeOwnedStreams() {
57+
deleteStreamsWithPrefix(STREAM_PREFIX);
58+
}
59+
60+
@Autowired
61+
RqueueMessageEnqueuer enqueuer;
62+
63+
@Autowired
64+
JetStreamManagement jsm;
65+
66+
@Autowired
67+
FailingListener listener;
68+
69+
@Test
70+
void globalRetryLimitCapsImplicitRetryForever() throws Exception {
71+
enqueuer.enqueue(QUEUE, "payload");
72+
73+
assertThat(listener.twoAttempts.await(20, TimeUnit.SECONDS)).isTrue();
74+
Awaitility.await().during(Duration.ofSeconds(2)).atMost(Duration.ofSeconds(3)).untilAsserted(
75+
() -> assertThat(listener.attempts).hasValue(2));
76+
77+
assertThat(jsm
78+
.getConsumerInfo(STREAM_PREFIX + QUEUE, QUEUE + "-consumer")
79+
.getConsumerConfiguration()
80+
.getMaxDeliver())
81+
.isEqualTo(3L);
82+
}
83+
84+
@SpringBootApplication(
85+
exclude = {DataRedisAutoConfiguration.class, DataRedisReactiveAutoConfiguration.class})
86+
@Import(FailingListener.class)
87+
static class TestApp {}
88+
89+
@Component
90+
static class FailingListener {
91+
final AtomicInteger attempts = new AtomicInteger();
92+
final CountDownLatch twoAttempts = new CountDownLatch(2);
93+
94+
@RqueueListener(value = QUEUE)
95+
void onMessage(String ignored) {
96+
attempts.incrementAndGet();
97+
twoAttempts.countDown();
98+
throw new IllegalStateException("force retry");
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)