Skip to content

Commit 0ee5dcb

Browse files
authored
RetryingTelegramBotExecutor implementation (#102)
1 parent 7bff245 commit 0ee5dcb

26 files changed

Lines changed: 980 additions & 176 deletions

telegram-bot-core/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,24 @@ Core abstracts Telegram API execution behind:
240240

241241
This allows multiple HTTP client implementations without leaking details into handlers.
242242

243+
### **Retry support**
244+
245+
Core also provides retry abstractions for Telegram Bot API calls:
246+
- RetryRule
247+
- RetryDelayStrategy
248+
- RetryingTelegramBotExecutor
249+
250+
Retry behavior is intentionally split into small extension points:
251+
- rules decide whether a failed request may be retried
252+
- delay strategies decide how long to wait before the next attempt
253+
- the retrying executor decorates another TelegramBotExecutor
254+
255+
Default reusable implementations include:
256+
- CompositeRetryRule
257+
- MaxAttemptsRetryRule
258+
- RetryableMethodsRetryRule
259+
- FixedRetryDelayStrategy
260+
243261
### **TelegramBotFileClient**
244262

245263
TelegramBotFileClient provides a focused API for downloading files from Telegram via the Bot API.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.ksilisk.telegrambot.core.executor.retry;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
5+
import java.time.Duration;
6+
7+
/**
8+
* Strategy interface that calculates the delay before the next retry attempt.
9+
*
10+
* <p>Implementations may use a fixed delay, exponential backoff,
11+
* jitter, or any custom retry timing policy.
12+
*/
13+
public interface RetryDelayStrategy {
14+
/**
15+
* Returns the delay to wait before the next retry attempt.
16+
*
17+
* @param request the original Telegram Bot API request
18+
* @param error the failure raised during request execution
19+
* @param attempt the current attempt number, starting from {@code 1}
20+
* @return the delay before the next retry attempt
21+
*/
22+
Duration nextDelay(BaseRequest<?, ?> request, Throwable error, int attempt);
23+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.ksilisk.telegrambot.core.executor.retry;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
import io.ksilisk.telegrambot.core.order.CoreOrdered;
5+
6+
/**
7+
* Strategy interface that decides whether a failed Telegram Bot API request
8+
* should be retried.
9+
*
10+
* <p>Implementations typically evaluate the request type, thrown error,
11+
* current attempt number, or any combination of these factors.
12+
*
13+
* <p>Rules are usually combined through a composite implementation.
14+
* If a rule returns {@code false}, retry is rejected.
15+
*
16+
* <p>This contract also extends {@link CoreOrdered}, so multiple rules
17+
* can be evaluated in a deterministic order.
18+
*/
19+
public interface RetryRule extends CoreOrdered {
20+
/**
21+
* Determines whether the given failed request should be retried.
22+
*
23+
* @param request the original Telegram Bot API request
24+
* @param error the failure raised during request execution
25+
* @param attempt the current attempt number, starting from {@code 1}
26+
* @return {@code true} if retry is allowed, otherwise {@code false}
27+
*/
28+
boolean shouldRetry(BaseRequest<?, ?> request, Throwable error, int attempt);
29+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package io.ksilisk.telegrambot.core.executor.retry;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
import com.pengrad.telegrambot.response.BaseResponse;
5+
import io.ksilisk.telegrambot.core.exception.request.TelegramRequestException;
6+
import io.ksilisk.telegrambot.core.executor.TelegramBotExecutor;
7+
import io.ksilisk.telegrambot.core.executor.retry.impl.CompositeRetryRule;
8+
9+
import java.time.Duration;
10+
11+
/**
12+
* {@link TelegramBotExecutor} decorator that retries failed Telegram Bot API
13+
* requests according to the configured {@link RetryRule} and
14+
* {@link RetryDelayStrategy}.
15+
*
16+
* <p>This executor delegates request execution to another
17+
* {@link TelegramBotExecutor}. When execution fails, it evaluates the retry
18+
* rule and, if retry is allowed, waits for the configured delay before
19+
* performing the next attempt.
20+
*
21+
* <p>This decorator does not define retry policy by itself. Retry decisions
22+
* are fully delegated to the provided {@link RetryRule} implementation.
23+
*/
24+
public class RetryingTelegramBotExecutor implements TelegramBotExecutor {
25+
private final TelegramBotExecutor delegate;
26+
private final CompositeRetryRule retryRule;
27+
private final RetryDelayStrategy retryDelayStrategy;
28+
29+
public RetryingTelegramBotExecutor(TelegramBotExecutor delegate,
30+
CompositeRetryRule retryRule,
31+
RetryDelayStrategy retryDelayStrategy) {
32+
this.delegate = delegate;
33+
this.retryRule = retryRule;
34+
this.retryDelayStrategy = retryDelayStrategy;
35+
}
36+
37+
@Override
38+
public <T extends BaseRequest<T, R>, R extends BaseResponse> R execute(BaseRequest<T, R> request)
39+
throws TelegramRequestException {
40+
int attempt = 1;
41+
42+
while (true) {
43+
try {
44+
return delegate.execute(request);
45+
} catch (Exception ex) {
46+
if (!retryRule.shouldRetry(request, ex, attempt)) {
47+
throw ex;
48+
}
49+
50+
Duration delay = retryDelayStrategy.nextDelay(request, ex, attempt);
51+
sleep(delay);
52+
attempt++;
53+
}
54+
}
55+
}
56+
57+
private void sleep(Duration delay) {
58+
try {
59+
Thread.sleep(delay.toMillis());
60+
} catch (InterruptedException ex) {
61+
Thread.currentThread().interrupt();
62+
throw new IllegalStateException("Retry sleep was interrupted", ex);
63+
}
64+
}
65+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.ksilisk.telegrambot.core.executor.retry.impl;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
import io.ksilisk.telegrambot.core.executor.retry.RetryRule;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
import java.util.List;
9+
10+
public class CompositeRetryRule implements RetryRule {
11+
private static final Logger log = LoggerFactory.getLogger(CompositeRetryRule.class);
12+
13+
private final List<RetryRule> rules;
14+
15+
public CompositeRetryRule(List<RetryRule> rules) {
16+
this.rules = List.copyOf(rules);
17+
}
18+
19+
@Override
20+
public boolean shouldRetry(BaseRequest<?, ?> request, Throwable error, int attempt) {
21+
for (RetryRule rule : rules) {
22+
if (!rule.shouldRetry(request, error, attempt)) {
23+
log.debug("Retry rejected by [{}] for Telegram method [{}], attempt [{}]",
24+
rule.getClass().getSimpleName(),
25+
request.getMethod(),
26+
attempt);
27+
return false;
28+
}
29+
}
30+
return true;
31+
}
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.ksilisk.telegrambot.core.executor.retry.impl;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
import io.ksilisk.telegrambot.core.executor.retry.RetryDelayStrategy;
5+
6+
import java.time.Duration;
7+
8+
/**
9+
* {@link RetryDelayStrategy} that always returns the same fixed delay
10+
* between retry attempts.
11+
*/
12+
public class FixedRetryDelayStrategy implements RetryDelayStrategy {
13+
private final Duration delay;
14+
15+
public FixedRetryDelayStrategy(Duration delay) {
16+
this.delay = delay;
17+
}
18+
19+
@Override
20+
public Duration nextDelay(BaseRequest<?, ?> request, Throwable error, int attempt) {
21+
return delay;
22+
}
23+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.ksilisk.telegrambot.core.executor.retry.impl;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
import io.ksilisk.telegrambot.core.executor.retry.RetryRule;
5+
6+
/**
7+
* {@link RetryRule} that rejects retry when the configured maximum number
8+
* of attempts has been reached.
9+
*/
10+
public class MaxAttemptsRetryRule implements RetryRule {
11+
private final int maxAttempts;
12+
13+
public MaxAttemptsRetryRule(int maxAttempts) {
14+
this.maxAttempts = maxAttempts;
15+
}
16+
17+
@Override
18+
public boolean shouldRetry(BaseRequest<?, ?> request, Throwable error, int attempt) {
19+
return attempt < maxAttempts;
20+
}
21+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.ksilisk.telegrambot.core.executor.retry.impl;
2+
3+
import com.pengrad.telegrambot.request.BaseRequest;
4+
import io.ksilisk.telegrambot.core.executor.retry.RetryRule;
5+
6+
import java.util.Set;
7+
8+
/**
9+
* {@link RetryRule} that allows retry only for Telegram Bot API methods
10+
* explicitly configured as retryable.
11+
*
12+
* <p>This rule acts as a method allow-list. If the request method is not
13+
* included in the configured retryable methods set, retry is rejected.
14+
*/
15+
public class RetryableMethodsRetryRule implements RetryRule {
16+
private final Set<String> retryableMethods;
17+
18+
public RetryableMethodsRetryRule(Set<String> retryableMethods) {
19+
this.retryableMethods = retryableMethods;
20+
}
21+
22+
@Override
23+
public boolean shouldRetry(BaseRequest<?, ?> request, Throwable error, int attempt) {
24+
String method = request.getMethod();
25+
26+
if (method == null || method.isBlank()) {
27+
return false;
28+
}
29+
30+
return retryableMethods.contains(method);
31+
}
32+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package io.ksilisk.telegrambot.core.properties;
2+
3+
import jakarta.validation.constraints.Max;
4+
import jakarta.validation.constraints.Min;
5+
6+
import java.time.Duration;
7+
import java.util.HashSet;
8+
import java.util.Set;
9+
10+
/**
11+
* Configuration properties for Telegram Bot API client retry support.
12+
*/
13+
public class ClientRetryProperties {
14+
private static final boolean DEFAULT_RETRY_ENABLED = false;
15+
private static final int DEFAULT_MAX_ATTEMPTS = 3;
16+
private static final Duration DEFAULT_RETRY_DELAY = Duration.ofSeconds(1);
17+
18+
/**
19+
* Enables retry decorator for TelegramBotExecutor.
20+
*/
21+
private boolean enabled = DEFAULT_RETRY_ENABLED;
22+
23+
/**
24+
* Total number of attempts including the first one.
25+
*/
26+
@Min(1)
27+
@Max(Integer.MAX_VALUE)
28+
private int maxAttempts = DEFAULT_MAX_ATTEMPTS;
29+
30+
/**
31+
* Explicit allow-list of Bot API method names.
32+
* Example: getMe, getFile, sendMessage
33+
*/
34+
private Set<String> retryableMethods = new HashSet<>();
35+
36+
/**
37+
* Initial delay before the next retry.
38+
*/
39+
private Duration delay = DEFAULT_RETRY_DELAY;
40+
41+
public boolean getEnabled() {
42+
return enabled;
43+
}
44+
45+
public void setEnabled(boolean enabled) {
46+
this.enabled = enabled;
47+
}
48+
49+
public int getMaxAttempts() {
50+
return maxAttempts;
51+
}
52+
53+
public void setMaxAttempts(int maxAttempts) {
54+
this.maxAttempts = maxAttempts;
55+
}
56+
57+
public Set<String> getRetryableMethods() {
58+
return retryableMethods;
59+
}
60+
61+
public void setRetryableMethods(Set<String> retryableMethods) {
62+
this.retryableMethods = retryableMethods;
63+
}
64+
65+
public Duration getDelay() {
66+
return delay;
67+
}
68+
69+
public void setDelay(Duration delay) {
70+
this.delay = delay;
71+
}
72+
}

0 commit comments

Comments
 (0)