• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mizosoft / methanol / #605

09 Oct 2025 12:32PM UTC coverage: 88.705% (+0.1%) from 88.592%
#605

push

github

mizosoft
Use a specific interceptor implementation instead of a new interface

2374 of 2876 branches covered (82.55%)

Branch coverage included in aggregate %.

144 of 182 new or added lines in 3 files covered. (79.12%)

52 existing lines in 4 files now uncovered.

7843 of 8642 relevant lines covered (90.75%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.15
/methanol/src/main/java/com/github/mizosoft/methanol/RetryingInterceptor.java
1
/*
2
 * Copyright (c) 2025 Moataz Hussein
3
 *
4
 * Permission is hereby granted, free of charge, to any person obtaining a copy
5
 * of this software and associated documentation files (the "Software"), to deal
6
 * in the Software without restriction, including without limitation the rights
7
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
 * copies of the Software, and to permit persons to whom the Software is
9
 * furnished to do so, subject to the following conditions:
10
 *
11
 * The above copyright notice and this permission notice shall be included in all
12
 * copies or substantial portions of the Software.
13
 *
14
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
 * SOFTWARE.
21
 */
22

23
package com.github.mizosoft.methanol;
24

25
import static com.github.mizosoft.methanol.internal.Utils.requirePositiveDuration;
26
import static com.github.mizosoft.methanol.internal.Validate.requireArgument;
27
import static java.util.Objects.requireNonNull;
28

29
import com.github.mizosoft.methanol.internal.Utils;
30
import com.github.mizosoft.methanol.internal.cache.HttpDates;
31
import com.github.mizosoft.methanol.internal.concurrent.Delayer;
32
import com.github.mizosoft.methanol.internal.util.Compare;
33
import com.google.errorprone.annotations.CanIgnoreReturnValue;
34
import java.io.IOException;
35
import java.net.http.HttpRequest;
36
import java.net.http.HttpResponse;
37
import java.time.Clock;
38
import java.time.Duration;
39
import java.time.ZoneOffset;
40
import java.util.ArrayList;
41
import java.util.List;
42
import java.util.Optional;
43
import java.util.Set;
44
import java.util.concurrent.CompletableFuture;
45
import java.util.concurrent.ThreadLocalRandom;
46
import java.util.concurrent.TimeUnit;
47
import java.util.function.BiPredicate;
48
import java.util.function.Function;
49
import java.util.function.Predicate;
50
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
51
import org.checkerframework.checker.nullness.qual.Nullable;
52

53
/**
54
 * An interceptor that retries requests based on a specified policy.
55
 *
56
 * @see Builder
57
 */
58
public final class RetryingInterceptor implements Methanol.Interceptor {
59
  private final BiPredicate<HttpRequest, Chain<?>> selector;
60
  private final int maxRetries;
61
  private final Function<HttpRequest, HttpRequest> beginWith;
62
  private final List<RetryCondition> conditions;
63
  private final BackoffStrategy backoffStrategy;
64
  private final @Nullable Duration timeout;
65
  private final Clock clock;
66
  private final Delayer delayer;
67

68
  private RetryingInterceptor(BiPredicate<HttpRequest, Chain<?>> selector, Builder builder) {
1✔
69
    this.selector = requireNonNull(selector);
1✔
70
    this.maxRetries = builder.maxRetries;
1✔
71
    this.beginWith = builder.beginWith;
1✔
72
    this.conditions = List.copyOf(builder.conditions);
1✔
73
    this.backoffStrategy = builder.backoffStrategy;
1✔
74
    this.timeout = builder.timeout;
1✔
75
    this.clock = builder.clock;
1✔
76
    this.delayer = builder.delayer;
1✔
77
  }
1✔
78

79
  @Override
80
  public <T> HttpResponse<T> intercept(HttpRequest request, Chain<T> chain)
81
      throws IOException, InterruptedException {
NEW
82
    if (!selector.test(request, chain)) {
×
NEW
83
      return chain.forward(request);
×
84
    }
85

NEW
86
    var retry = new Retry(request, Duration.ZERO);
×
NEW
87
    int retryCount = 0;
×
88
    while (true) {
NEW
89
      if (!retry.delay.isZero()) {
×
NEW
90
        TimeUnit.MILLISECONDS.sleep(retry.delay.toMillis());
×
91
      }
92

NEW
93
      HttpResponse<T> response = null;
×
NEW
94
      Throwable exception = null;
×
95
      try {
NEW
96
        response = chain.forward(retry.request);
×
NEW
97
      } catch (Throwable e) {
×
NEW
98
        exception = e;
×
NEW
99
      }
×
100

NEW
101
      var nextRetry = nextRetry(Context.of(request, response, exception, retryCount));
×
NEW
102
      if (nextRetry.isPresent()) {
×
NEW
103
        retry = nextRetry.get();
×
NEW
104
      } else if (response != null) {
×
NEW
105
        return response;
×
NEW
106
      } else if (exception instanceof IOException) {
×
NEW
107
        throw (IOException) exception;
×
NEW
108
      } else if (exception instanceof InterruptedException) {
×
NEW
109
        throw (InterruptedException) exception;
×
NEW
110
      } else if (exception instanceof RuntimeException) {
×
NEW
111
        throw (RuntimeException) exception;
×
112
      } else {
NEW
113
        throw new IOException(exception);
×
114
      }
NEW
115
    }
×
116
  }
117

118
  @Override
119
  public <T> CompletableFuture<HttpResponse<T>> interceptAsync(
120
      HttpRequest request, Chain<T> chain) {
121
    return selector.test(request, chain)
1✔
122
        ? continueRetry(new Retry(beginWith.apply(request), Duration.ZERO), chain, 0)
1✔
123
        : chain.forwardAsync(request);
1✔
124
  }
125

126
  private <T> CompletableFuture<HttpResponse<T>> continueRetry(
127
      Retry prevRetry, Chain<T> chain, int retryCount) {
128
    return delayer
1✔
129
        .delay(() -> {}, prevRetry.delay, Runnable::run)
1✔
130
        .thenCompose(
1✔
131
            __ ->
132
                chain
1✔
133
                    .forwardAsync(prevRetry.request)
1✔
134
                    .handle(
1✔
135
                        (response, exception) ->
136
                            nextRetry(
1✔
137
                                    Context.of(
1✔
138
                                        prevRetry.request,
139
                                        response,
140
                                        Utils.getDeepCompletionCause(exception),
1✔
141
                                        retryCount))
142
                                .map(nextRetry -> continueRetry(nextRetry, chain, retryCount + 1))
1✔
143
                                .orElseGet(
1✔
144
                                    () ->
145
                                        exception != null
1✔
146
                                            ? CompletableFuture.failedFuture(exception)
1✔
147
                                            : CompletableFuture.completedFuture(response))))
1✔
148
        .thenCompose(Function.identity());
1✔
149
  }
150

151
  private Optional<Retry> nextRetry(Context context) {
152
    return context.retryCount() < maxRetries
1✔
153
        ? eval(context).map(nextRequest -> new Retry(nextRequest, backoffStrategy.backoff(context)))
1✔
154
        : Optional.empty();
1✔
155
  }
156

157
  private Optional<HttpRequest> eval(Context context) {
158
    return conditions.stream()
1✔
159
        .map(condition -> condition.test(context))
1✔
160
        .flatMap(Optional::stream)
1✔
161
        .findFirst();
1✔
162
  }
163

164
  private static final class Retry {
165
    final HttpRequest request;
166
    final Duration delay;
167

168
    Retry(HttpRequest request, Duration delay) {
1✔
169
      this.request = request;
1✔
170
      this.delay = delay;
1✔
171
    }
1✔
172
  }
173

174
  /** Context for deciding whether an HTTP call should be retried. */
175
  public interface Context {
176
    /**
177
     * Returns the last-sent request. Note that this might be different from the {@link
178
     * HttpResponse#request() request} of this context's response (e.g. redirects).
179
     */
180
    HttpRequest request();
181

182
    /**
183
     * Returns the resulting response. Exactly one of {@code response()} or {@link #exception()} is
184
     * non-null.
185
     */
186
    Optional<HttpResponse<?>> response();
187

188
    /**
189
     * Returns the resulting exception. Exactly one of {@link #response()} or {@code exception()} is
190
     * non-null.
191
     */
192
    Optional<Throwable> exception();
193

194
    /** Returns the number of times the request has been retried. */
195
    int retryCount();
196

197
    /**
198
     * Creates a new retry context based on the given state.
199
     *
200
     * @throws IllegalArgumentException if it is not the case that exactly one of {@code response}
201
     *     or {@code exception} is non-null, or if {@code retryCount} is negative
202
     */
203
    static Context of(
204
        HttpRequest request,
205
        @Nullable HttpResponse<?> response,
206
        @Nullable Throwable exception,
207
        int retryCount) {
208
      return new ContextImpl(request, response, exception, retryCount);
1✔
209
    }
210
  }
211

212
  /** A strategy for backing off (delaying) before a retry retries. */
213
  @FunctionalInterface
214
  public interface BackoffStrategy {
215

216
    /**
217
     * Returns the {@link Duration} to wait for before retrying the request for the given retry
218
     * number.
219
     */
220
    Duration backoff(Context context);
221

222
    /**
223
     * Returns a {@code BackoffStrategy} that applies <a
224
     * href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/">full
225
     * jitter</a> to this {@code BackoffStrategy}. Calling this method is equivalent to {@link
226
     * #withJitter(double) withJitter(1.0)}.
227
     */
228
    default BackoffStrategy withJitter() {
229
      return withJitter(1.0);
1✔
230
    }
231

232
    /**
233
     * Returns a {@code BackoffStrategy} that applies <a
234
     * href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/">full
235
     * jitter</a> to this {@code BackoffStrategy}, where the degree of "fullness" is specified by
236
     * the given factor.
237
     */
238
    default BackoffStrategy withJitter(double factor) {
239
      requireArgument(
1✔
240
          Double.compare(factor, 0.0) >= 0 && Double.compare(factor, 1.0) <= 0,
1✔
241
          "Expected %f to be between 0.0 and 1.0",
242
          factor);
1✔
243
      return context -> {
1✔
244
        long delayMillis = backoff(context).toMillis();
1✔
245
        long jitterRangeMillis = Math.round(delayMillis * factor);
1✔
246
        return Duration.ofMillis(
1✔
247
            Math.max(
1✔
248
                0,
249
                delayMillis
250
                    - jitterRangeMillis
251
                    + Math.round(jitterRangeMillis * ThreadLocalRandom.current().nextDouble())));
1✔
252
      };
253
    }
254

255
    /** Returns a {@code BackoffStrategy} that applies no delays. */
256
    static BackoffStrategy none() {
257
      return __ -> Duration.ZERO;
1✔
258
    }
259

260
    /** Returns a {@code BackoffStrategy} that applies a fixed delay every retry. */
261
    static BackoffStrategy fixed(Duration delay) {
262
      requirePositiveDuration(delay);
1✔
263
      return __ -> delay;
1✔
264
    }
265

266
    /**
267
     * Returns a {@code BackoffStrategy} that applies a linearly increasing delay every retry, where
268
     * {@code base} specifies the first delay, and {@code cap} specifies the maximum delay.
269
     */
270
    static BackoffStrategy linear(Duration base, Duration cap) {
271
      requirePositiveDuration(base);
1✔
272
      return context -> {
1✔
273
        int retryCount = context.retryCount();
1✔
274
        return retryCount < Integer.MAX_VALUE // Avoid overflow.
1✔
275
            ? Compare.min(cap, base.multipliedBy(retryCount + 1))
1✔
276
            : cap;
1✔
277
      };
278
    }
279

280
    /**
281
     * Returns a {@code BackoffStrategy} that applies an exponentially (base 2) increasing delay
282
     * every retry, where {@code base} specifies the first delay, and {@code cap} specifies the
283
     * maximum delay.
284
     */
285
    static BackoffStrategy exponential(Duration base, Duration cap) {
286
      requirePositiveDuration(base);
1✔
287
      requirePositiveDuration(cap);
1✔
288
      requireArgument(
1✔
289
          base.compareTo(cap) <= 0,
1✔
290
          "Base delay (%s) must be less than or equal to cap delay (%s)",
291
          base,
292
          cap);
293
      return context -> {
1✔
294
        int retryCount = context.retryCount();
1✔
295
        return retryCount < Long.SIZE - 2 // Avoid overflow.
1✔
296
            ? Compare.min(cap, base.multipliedBy(1L << context.retryCount()))
1✔
297
            : cap;
1✔
298
      };
299
    }
300

301
    /**
302
     * Returns a {@code BackoffStrategy} that gets the delay from the value of response's {@code
303
     * Retry-After} header, or defers to the given {@code BackoffStrategy} if no such header exists.
304
     */
305
    static BackoffStrategy retryAfterOr(BackoffStrategy fallback) {
306
      return retryAfterOrBackoffStrategy(fallback, Utils.systemMillisUtc());
1✔
307
    }
308
  }
309

310
  static BackoffStrategy retryAfterOrBackoffStrategy(BackoffStrategy fallback, Clock clock) {
311
    requireNonNull(fallback);
1✔
312
    requireNonNull(clock);
1✔
313
    return context ->
1✔
314
        context
315
            .response()
1✔
316
            .flatMap(response -> tryFindDelayFromRetryAfter(response, clock))
1✔
317
            .orElseGet(() -> fallback.backoff(context));
1✔
318
  }
319

320
  private static Optional<Duration> tryFindDelayFromRetryAfter(
321
      HttpResponse<?> response, Clock clock) {
322
    return response
1✔
323
        .headers()
1✔
324
        .firstValue("Retry-After")
1✔
325
        .flatMap(
1✔
326
            value ->
327
                HttpDates.tryParseDeltaSeconds(value)
1✔
328
                    .or(
1✔
329
                        () ->
330
                            HttpDates.tryParseHttpDate(value)
1✔
331
                                .map(
1✔
332
                                    retryDate ->
333
                                        Compare.max(
1✔
334
                                            Duration.ZERO,
335
                                            Duration.between(
1✔
336
                                                clock.instant(),
1✔
337
                                                retryDate.toInstant(ZoneOffset.UTC))))));
1✔
338
  }
339

340
  public static Builder newBuilder() {
341
    return new Builder();
1✔
342
  }
343

344
  /** A builder of {@link RetryingInterceptor} instances. */
345
  public static final class Builder {
346
    private static final int DEFAULT_MAX_ATTEMPTS = 5;
347

348
    private int maxRetries = DEFAULT_MAX_ATTEMPTS;
1✔
349
    private Function<HttpRequest, HttpRequest> beginWith = Function.identity();
1✔
350
    private BackoffStrategy backoffStrategy = BackoffStrategy.none();
1✔
351
    private @MonotonicNonNull Duration timeout;
352
    private Clock clock = Utils.systemMillisUtc();
1✔
353
    private Delayer delayer = Delayer.defaultDelayer();
1✔
354

355
    private final List<RetryCondition> conditions = new ArrayList<>();
1✔
356

357
    Builder() {}
1✔
358

359
    @CanIgnoreReturnValue
360
    public Builder beginWith(Function<HttpRequest, HttpRequest> requestModifier) {
361
      this.beginWith = requireNonNull(requestModifier);
1✔
362
      return this;
1✔
363
    }
364

365
    @CanIgnoreReturnValue
366
    public Builder atMost(int maxRetries) {
367
      requireArgument(maxRetries > 0, "maxRetries must be positive");
1!
368
      this.maxRetries = maxRetries;
1✔
369
      return this;
1✔
370
    }
371

372
    @CanIgnoreReturnValue
373
    public Builder backoff(BackoffStrategy backoffStrategy) {
NEW
374
      this.backoffStrategy = requireNonNull(backoffStrategy);
×
NEW
375
      return this;
×
376
    }
377

378
    @SafeVarargs
379
    @CanIgnoreReturnValue
380
    public final Builder onException(Class<? extends Throwable>... exceptionTypes) {
381
      return onException(Set.of(exceptionTypes), Context::request);
1✔
382
    }
383

384
    @CanIgnoreReturnValue
385
    public Builder onException(
386
        Set<Class<? extends Throwable>> exceptionTypes,
387
        Function<Context, HttpRequest> requestModifier) {
388
      var exceptionTypesCopy = Set.copyOf(exceptionTypes);
1✔
389
      return onException(
1✔
390
          t -> exceptionTypesCopy.stream().anyMatch(c -> c.isInstance(t)), requestModifier);
1✔
391
    }
392

393
    @CanIgnoreReturnValue
394
    public Builder onException(Predicate<Throwable> exceptionPredicate) {
395
      return onException(exceptionPredicate, Context::request);
1✔
396
    }
397

398
    @CanIgnoreReturnValue
399
    public Builder onException(
400
        Predicate<Throwable> exceptionPredicate, Function<Context, HttpRequest> requestModifier) {
401
      conditions.add(
1✔
402
          new RetryCondition(
403
              ctx -> ctx.exception().map(exceptionPredicate::test).orElse(false), requestModifier));
1✔
404
      return this;
1✔
405
    }
406

407
    @CanIgnoreReturnValue
408
    public Builder onStatus(Integer... codes) {
409
      return onStatus(Set.of(codes), Context::request);
1✔
410
    }
411

412
    @CanIgnoreReturnValue
413
    public Builder onStatus(Set<Integer> codes, Function<Context, HttpRequest> requestModifier) {
414
      var codesCopy = Set.copyOf(codes);
1✔
415
      return onStatus(codesCopy::contains, requestModifier);
1✔
416
    }
417

418
    @CanIgnoreReturnValue
419
    public Builder onStatus(Predicate<Integer> statusPredicate) {
420
      return onStatus(statusPredicate, Context::request);
1✔
421
    }
422

423
    @CanIgnoreReturnValue
424
    public Builder onStatus(
425
        Predicate<Integer> statusPredicate, Function<Context, HttpRequest> requestModifier) {
426
      conditions.add(
1✔
427
          new RetryCondition(
428
              ctx -> ctx.response().map(r -> statusPredicate.test(r.statusCode())).orElse(false),
1✔
429
              requestModifier));
430
      return this;
1✔
431
    }
432

433
    @CanIgnoreReturnValue
434
    public Builder onResponse(Predicate<HttpResponse<?>> responsePredicate) {
435
      return onResponse(responsePredicate, Context::request);
1✔
436
    }
437

438
    @CanIgnoreReturnValue
439
    public Builder onResponse(
440
        Predicate<HttpResponse<?>> responsePredicate,
441
        Function<Context, HttpRequest> requestModifier) {
442
      conditions.add(
1✔
443
          new RetryCondition(
444
              ctx -> ctx.response().map(responsePredicate::test).orElse(false), requestModifier));
1✔
445
      return this;
1✔
446
    }
447

448
    @CanIgnoreReturnValue
449
    public Builder on(Predicate<Context> predicate) {
NEW
450
      return on(predicate, Context::request);
×
451
    }
452

453
    @CanIgnoreReturnValue
454
    public Builder on(
455
        Predicate<Context> predicate, Function<Context, HttpRequest> requestModifier) {
NEW
456
      this.conditions.add(new RetryCondition(predicate, requestModifier));
×
NEW
457
      return this;
×
458
    }
459

460
    @CanIgnoreReturnValue
461
    public Builder timeout(Duration timeout) {
NEW
462
      throw new UnsupportedOperationException("Timeouts are not supported yet");
×
463
      //      this.timeout = requirePositiveDuration(timeout);
464
      //      return this;
465
    }
466

467
    @CanIgnoreReturnValue
468
    Builder delayer(Delayer delayer) {
NEW
469
      this.delayer = requireNonNull(delayer);
×
NEW
470
      return this;
×
471
    }
472

473
    @CanIgnoreReturnValue
474
    Builder clock(Clock clock) {
NEW
475
      this.clock = requireNonNull(clock);
×
NEW
476
      return this;
×
477
    }
478

479
    public RetryingInterceptor build() {
480
      return build((__, ___) -> true);
1✔
481
    }
482

483
    public RetryingInterceptor build(Predicate<HttpRequest> selector) {
484
      requireNonNull(selector);
1✔
485
      return build((request, __) -> selector.test(request));
1✔
486
    }
487

488
    public RetryingInterceptor build(BiPredicate<HttpRequest, Chain<?>> selector) {
489
      return new RetryingInterceptor(selector, this);
1✔
490
    }
491
  }
492

493
  private static final class RetryCondition {
494
    final Predicate<Context> predicate;
495
    final Function<Context, HttpRequest> requestModifier;
496

497
    RetryCondition(Predicate<Context> predicate, Function<Context, HttpRequest> requestModifier) {
1✔
498
      this.predicate = requireNonNull(predicate);
1✔
499
      this.requestModifier = requireNonNull(requestModifier);
1✔
500
    }
1✔
501

502
    Optional<HttpRequest> test(Context context) {
503
      return predicate.test(context)
1✔
504
          ? Optional.of(requestModifier.apply(context))
1✔
505
          : Optional.empty();
1✔
506
    }
507
  }
508

509
  @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
510
  private static final class ContextImpl implements Context {
511
    private final HttpRequest request;
512
    private final Optional<HttpResponse<?>> response;
513
    private final Optional<Throwable> exception;
514
    private final int retryCount;
515

516
    ContextImpl(
517
        HttpRequest request,
518
        @Nullable HttpResponse<?> response,
519
        @Nullable Throwable exception,
520
        int retryCount) {
1✔
521
      requireArgument(
1✔
522
          response != null ^ exception != null,
523
          "Exactly one of response or exception must be non-null");
524
      requireArgument(retryCount >= 0, "Expected retryCount to be non-negative");
1!
525
      this.request = request;
1✔
526
      this.response = Optional.ofNullable(response);
1✔
527
      this.exception = Optional.ofNullable(exception);
1✔
528
      this.retryCount = retryCount;
1✔
529
    }
1✔
530

531
    @Override
532
    public HttpRequest request() {
533
      return request;
1✔
534
    }
535

536
    @Override
537
    public Optional<HttpResponse<?>> response() {
538
      return response;
1✔
539
    }
540

541
    @Override
542
    public Optional<Throwable> exception() {
543
      return exception;
1✔
544
    }
545

546
    @Override
547
    public int retryCount() {
548
      return retryCount;
1✔
549
    }
550

551
    @Override
552
    public String toString() {
NEW
553
      return Utils.toStringIdentityPrefix(this)
×
554
          + "[request="
555
          + request
556
          + ", response="
557
          + response
558
          + ", exception="
559
          + exception
560
          + ", retryCount="
561
          + retryCount
562
          + ']';
563
    }
564
  }
565
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc