Skip to content

Commit ca446e1

Browse files
tomas-samekTomáš Samek
andauthored
feat(events): opt-in async @eventhandler retries with backoff (#108) (#390)
* feat(events): opt-in async @eventhandler retries with backoff (#108) * refactor(events): single-literal retry emit; parameterize the invalid-retry compile-error tests (#108) --------- Co-authored-by: Tomáš Samek <jerry.samek@gmail.com>
1 parent acc2cb1 commit ca446e1

12 files changed

Lines changed: 619 additions & 33 deletions

File tree

docs/events.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,26 @@ If the handler runs longer than the budget, its worker thread is interrupted, th
143143
- **Async-only:** `timeout` requires `async = true`. A timeout on a synchronous handler is a **compile-time error** — a sync handler runs on the publisher's thread (and its unit-of-work scope), which cannot be preempted; time-boxing requires the off-thread, own-scope execution that `async = true` provides.
144144
- **Best-effort:** interruption can only stop a handler that respects `Thread.interrupt()` (e.g. is blocked on I/O or checks the flag). A handler running a tight uninterruptible loop will keep going — the timeout is reported, but Java cannot force-stop the thread.
145145

146+
### Retries with backoff
147+
148+
An async handler can retry on failure with `retries` + `backoff` + `backoffStrategy`:
149+
150+
```java
151+
@EventHandler(async = true, retries = 3, backoff = "PT0.1S", backoffStrategy = BackoffStrategy.EXPONENTIAL)
152+
public void onPayment(PaymentEvent event) {
153+
// re-invoked up to 3 times if it throws; 100ms, then 200ms, then 400ms apart
154+
}
155+
```
156+
157+
`retries = 3` means one initial call plus up to three retries (four attempts total). The first attempt that returns normally wins — no error is routed. Once the budget is exhausted, a **single** `EventHandlerError` is routed whose `attempts()` is the total number of attempts made (here, `4`). Backoff is `FIXED` (constant delay) or `EXPONENTIAL` (doubling) and is *scheduled*, so it never ties up an executor thread while waiting.
158+
159+
- **Opt-in:** the default is no retries (`retries = 0`).
160+
- **Async-only:** `retries` requires `async = true` (a **compile-time error** otherwise) — retrying waits for the backoff between attempts, which would block the publisher's thread.
161+
- **ISO-8601 backoff:** `backoff` is a `Duration` string (`"PT0.1S"`), like `timeout`.
162+
- **Composes with `timeout`:** if both are set, each attempt is time-boxed and a timed-out attempt counts as a failed attempt to be retried.
163+
- **Errors are not retried:** an `Error` (vs an `Exception`) stops the loop and is logged, never retried.
164+
- **Idempotency is your responsibility** — a retried handler runs its side effects more than once. Make the work safe to repeat.
165+
146166
## Graceful shutdown drain
147167

148168
When `Container.shutdown()` runs, in-flight async event handlers are allowed to

tiko-api/src/main/java/io/tiko/EventHandlerError.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,23 @@
33
/**
44
* Error context raised when an {@code @EventHandler} method throws — sync or async.
55
*
6-
* @param handler identifies which handler method threw
7-
* @param event the event instance the handler was processing
8-
* @param cause the throwable thrown by the handler ({@code CompletionException}
9-
* already unwrapped to the user's original throwable)
6+
* @param handler identifies which handler method threw
7+
* @param event the event instance the handler was processing
8+
* @param cause the throwable thrown by the handler ({@code CompletionException}
9+
* already unwrapped to the user's original throwable)
10+
* @param attempts the number of times the handler was invoked before this error was raised —
11+
* {@code 1} for a non-retrying handler, or the exhausted total for a handler with
12+
* {@code @EventHandler(retries = ...)} (#108)
1013
*/
11-
public record EventHandlerError(EventHandlerInfo handler, Object event, Throwable cause) implements ErrorContext {}
14+
public record EventHandlerError(EventHandlerInfo handler, Object event, Throwable cause, int attempts)
15+
implements ErrorContext {
16+
17+
/**
18+
* Single-attempt error — the common case (no retries). Delegates to the canonical constructor
19+
* with {@code attempts = 1}, so every existing call site and exhaustive {@code switch} over
20+
* {@link ErrorContext} keeps compiling.
21+
*/
22+
public EventHandlerError(EventHandlerInfo handler, Object event, Throwable cause) {
23+
this(handler, event, cause, 1);
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.tiko.annotations;
2+
3+
/**
4+
* How the delay between {@code @EventHandler(retries = ...)} attempts grows (#108).
5+
*
6+
* <p>The base delay is {@code @EventHandler(backoff = ...)}; this strategy decides how it
7+
* scales across successive retries. Both are honoured only for asynchronous handlers —
8+
* retries are async-only (see {@link EventHandler#retries()}).
9+
*/
10+
public enum BackoffStrategy {
11+
12+
/** Same delay before every retry: {@code backoff}, {@code backoff}, {@code backoff}, … */
13+
FIXED,
14+
15+
/**
16+
* Delay doubles each retry: {@code backoff}, {@code 2 × backoff}, {@code 4 × backoff}, …
17+
* Eases pressure on a struggling downstream by spacing retries out geometrically.
18+
*/
19+
EXPONENTIAL
20+
}

tiko-api/src/main/java/io/tiko/annotations/EventHandler.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,48 @@
136136
* @return an ISO-8601 duration string, or empty for no timeout
137137
*/
138138
String timeout() default "";
139+
140+
/**
141+
* Optional retry count for an asynchronous handler (#108). When greater than zero and the
142+
* handler throws, it is re-invoked up to this many times before the final failure is routed
143+
* to the {@link io.tiko.ErrorHandler} — so {@code retries = 3} means one initial call plus up
144+
* to three retries (four attempts total). On the first attempt that succeeds, no error is
145+
* routed; once the budget is exhausted a single {@link io.tiko.EventHandlerError} is routed
146+
* whose {@code attempts()} is the total number of attempts made.
147+
*
148+
* <p><strong>Requires {@code async = true}.</strong> Retries wait for {@link #backoff()}
149+
* between attempts; doing that on the publisher's thread would block it. Like {@link #timeout()},
150+
* a retry on a synchronous handler is a compile-time error. If both {@code timeout} and
151+
* {@code retries} are set, each attempt is time-boxed and a timed-out attempt counts as a
152+
* failed attempt. Errors (as opposed to exceptions) are never retried.
153+
*
154+
* <p><strong>Idempotency is the handler's responsibility</strong> — a retried handler may run
155+
* its side effects more than once.
156+
*
157+
* <p>Default: {@code 0} (no retries — opt-in).
158+
*
159+
* @return the number of retries after the initial attempt, or 0 for none
160+
*/
161+
int retries() default 0;
162+
163+
/**
164+
* Base delay between retry attempts, as an ISO-8601 {@link java.time.Duration} string
165+
* (e.g. {@code "PT0.1S"}). Scaled across attempts per {@link #backoffStrategy()}. Only
166+
* meaningful when {@link #retries()} is greater than zero.
167+
*
168+
* <p>Default: {@code ""} (retry immediately, no delay).
169+
*
170+
* @return an ISO-8601 duration string, or empty for no delay
171+
*/
172+
String backoff() default "";
173+
174+
/**
175+
* How {@link #backoff()} grows across successive retries. Only meaningful when
176+
* {@link #retries()} is greater than zero.
177+
*
178+
* <p>Default: {@link BackoffStrategy#FIXED}.
179+
*
180+
* @return the backoff growth strategy
181+
*/
182+
BackoffStrategy backoffStrategy() default BackoffStrategy.FIXED;
139183
}

tiko-processor/src/main/java/io/tiko/processor/TikoAnnotationProcessor.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,11 @@ private EventHandlerModel buildEventHandlerModel(ExecutableElement methodElement
878878
return null; // a timeout validation error was reported; skip this handler
879879
}
880880

881+
long backoffNanos = resolveHandlerBackoffNanos(methodElement, annotation);
882+
if (backoffNanos < 0 || !validateRetries(methodElement, annotation)) {
883+
return null; // a retry validation error was reported; skip this handler
884+
}
885+
881886
return EventHandlerModel.builder()
882887
.methodElement(methodElement)
883888
.declaringClass(declaringClass)
@@ -887,10 +892,76 @@ private EventHandlerModel buildEventHandlerModel(ExecutableElement methodElement
887892
.async(annotation.async())
888893
.hasEventWrapper(hasEventWrapper)
889894
.timeoutNanos(timeoutNanos)
895+
.retries(annotation.retries())
896+
.backoffNanos(backoffNanos)
897+
.backoffStrategy(annotation.backoffStrategy())
890898
.eventTriggers(eventTriggers)
891899
.build();
892900
}
893901

902+
/**
903+
* Validates {@code @EventHandler(retries = ...)} (#108): retries are async-only (they wait for
904+
* the backoff between attempts, which would block the publisher's thread) and the count cannot
905+
* be negative. Returns {@code false} (and reports a compile error) when invalid.
906+
*/
907+
private boolean validateRetries(ExecutableElement methodElement, EventHandler annotation) {
908+
int retries = annotation.retries();
909+
if (retries == 0) {
910+
return true;
911+
}
912+
if (retries < 0) {
913+
context.getErrorReporter()
914+
.error(
915+
methodElement,
916+
"@EventHandler retries '" + retries + "' must not be negative",
917+
"Use a non-negative retry count, e.g. retries = 3");
918+
return false;
919+
}
920+
if (!annotation.async()) {
921+
context.getErrorReporter()
922+
.error(
923+
methodElement,
924+
"@EventHandler retries requires async = true: retrying with backoff on the"
925+
+ " publisher's thread would block it",
926+
"Add async = true to retry off-thread",
927+
"Or remove retries");
928+
return false;
929+
}
930+
return true;
931+
}
932+
933+
/**
934+
* Resolves {@code @EventHandler(backoff = ...)} (#108) to a non-negative nanosecond base delay,
935+
* or {@code 0} when no backoff is declared. Returns {@code -1} (and reports a compile error)
936+
* when the value is not an ISO-8601 {@link java.time.Duration} or is negative.
937+
*/
938+
private long resolveHandlerBackoffNanos(ExecutableElement methodElement, EventHandler annotation) {
939+
String backoff = annotation.backoff();
940+
if (backoff == null || backoff.isBlank()) {
941+
return 0L;
942+
}
943+
java.time.Duration duration;
944+
try {
945+
duration = java.time.Duration.parse(backoff.trim());
946+
} catch (java.time.format.DateTimeParseException badFormat) {
947+
context.getErrorReporter()
948+
.error(
949+
methodElement,
950+
"@EventHandler backoff '" + backoff + "' is not a valid ISO-8601 Duration",
951+
"Use an ISO-8601 duration such as \"PT0.1S\" (100 ms) or \"PT2S\" (2 seconds)");
952+
return -1L;
953+
}
954+
if (duration.isNegative()) {
955+
context.getErrorReporter()
956+
.error(
957+
methodElement,
958+
"@EventHandler backoff '" + backoff + "' must not be negative",
959+
"Use a non-negative duration such as \"PT0.1S\"");
960+
return -1L;
961+
}
962+
return duration.toNanos();
963+
}
964+
894965
/**
895966
* Resolves {@code @EventHandler(timeout = ...)} (#107) to a positive nanosecond budget, or
896967
* {@code 0} when no timeout is declared. Returns {@code -1} (and reports a compile error) when

tiko-processor/src/main/java/io/tiko/processor/generator/EventRegistryGenerator.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,25 @@ private MethodSpec createDispatcherMethod(EventHandlerModel handler, int index)
222222
runBody.addStatement("$T.exit(__asyncPrev)", CHAIN_CONTEXT);
223223
runBody.endControlFlow();
224224

225-
if (handler.hasTimeout()) {
225+
if (handler.hasRetries()) {
226+
// Retry dispatch (#108): re-invoke on failure up to the budget, with backoff
227+
// scheduled between attempts and (when a timeout is also set) each attempt
228+
// time-boxed. The helper routes a single EventHandlerError carrying the attempt
229+
// count once the budget is exhausted. Composes the #107 timeout per attempt.
230+
method.addCode(CodeBlock.builder()
231+
.add(
232+
"$T.runAsyncWithRetry(() -> {\n$L}, new $T($L, $LL, $T.$L, $LL), __exec, __err, HANDLER_INFO_$L, event);\n",
233+
CHAIN_CONTEXT,
234+
runBody.build(),
235+
ClassName.get("io.tiko.runtime", "RetryPolicy"),
236+
handler.getRetries(),
237+
handler.getBackoffNanos(),
238+
ClassName.get("io.tiko.annotations", "BackoffStrategy"),
239+
handler.getBackoffStrategy().name(),
240+
handler.getTimeoutNanos(),
241+
index)
242+
.build());
243+
} else if (handler.hasTimeout()) {
226244
// Timed dispatch (#107): run the invocation under a wall-clock budget. The runtime
227245
// helper submits the body to the executor as an interruptible Future, interrupts it
228246
// on breach (best-effort), frees the slot, and routes an EventHandlerError whose

tiko-processor/src/main/java/io/tiko/processor/model/EventHandlerModel.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.tiko.processor.model;
22

3+
import io.tiko.annotations.BackoffStrategy;
34
import java.util.ArrayList;
45
import java.util.List;
56
import javax.lang.model.element.ExecutableElement;
@@ -19,6 +20,9 @@ public final class EventHandlerModel {
1920
private final boolean async;
2021
private final boolean hasEventWrapper; // Second parameter is Event<?> wrapper
2122
private final long timeoutNanos; // 0 = no timeout (#107); async-only, validated at build time
23+
private final int retries; // 0 = no retries (#108); async-only, validated at build time
24+
private final long backoffNanos; // base retry delay; 0 = immediate
25+
private final BackoffStrategy backoffStrategy;
2226
private final List<EventTriggerModel> eventTriggers;
2327

2428
private EventHandlerModel(Builder builder) {
@@ -30,6 +34,9 @@ private EventHandlerModel(Builder builder) {
3034
this.async = builder.async;
3135
this.hasEventWrapper = builder.hasEventWrapper;
3236
this.timeoutNanos = builder.timeoutNanos;
37+
this.retries = builder.retries;
38+
this.backoffNanos = builder.backoffNanos;
39+
this.backoffStrategy = builder.backoffStrategy;
3340
this.eventTriggers = List.copyOf(builder.eventTriggers);
3441
}
3542

@@ -75,6 +82,26 @@ public boolean hasTimeout() {
7582
return timeoutNanos > 0;
7683
}
7784

85+
/** Retry count after the initial attempt, or {@code 0} for none (#108). Always async-only. */
86+
public int getRetries() {
87+
return retries;
88+
}
89+
90+
/** Base retry backoff in nanoseconds, or {@code 0} for immediate retries. */
91+
public long getBackoffNanos() {
92+
return backoffNanos;
93+
}
94+
95+
/** How the backoff grows across retries. */
96+
public BackoffStrategy getBackoffStrategy() {
97+
return backoffStrategy;
98+
}
99+
100+
/** True when this handler declared {@code @EventHandler(retries > 0)}. */
101+
public boolean hasRetries() {
102+
return retries > 0;
103+
}
104+
78105
public List<EventTriggerModel> getEventTriggers() {
79106
return eventTriggers;
80107
}
@@ -95,6 +122,9 @@ public static final class Builder {
95122
private boolean async = false;
96123
private boolean hasEventWrapper = false;
97124
private long timeoutNanos = 0L;
125+
private int retries = 0;
126+
private long backoffNanos = 0L;
127+
private BackoffStrategy backoffStrategy = BackoffStrategy.FIXED;
98128
private List<EventTriggerModel> eventTriggers = new ArrayList<>();
99129

100130
private Builder() {}
@@ -139,6 +169,21 @@ public Builder timeoutNanos(long timeoutNanos) {
139169
return this;
140170
}
141171

172+
public Builder retries(int retries) {
173+
this.retries = retries;
174+
return this;
175+
}
176+
177+
public Builder backoffNanos(long backoffNanos) {
178+
this.backoffNanos = backoffNanos;
179+
return this;
180+
}
181+
182+
public Builder backoffStrategy(BackoffStrategy backoffStrategy) {
183+
this.backoffStrategy = backoffStrategy;
184+
return this;
185+
}
186+
142187
public Builder eventTriggers(List<EventTriggerModel> eventTriggers) {
143188
this.eventTriggers = new ArrayList<>(eventTriggers);
144189
return this;

0 commit comments

Comments
 (0)