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

mizosoft / methanol / #598

10 Sep 2025 12:10PM UTC coverage: 89.059% (+0.02%) from 89.044%
#598

Pull #133

github

mizosoft
Try increasing timeout
Pull Request #133: Bound WritableBodyPublisher memory usage

2349 of 2822 branches covered (83.24%)

Branch coverage included in aggregate %.

138 of 152 new or added lines in 11 files covered. (90.79%)

1 existing line in 1 file now uncovered.

7704 of 8466 relevant lines covered (91.0%)

0.91 hits per line

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

91.8
/methanol/src/main/java/com/github/mizosoft/methanol/internal/cache/RedirectingInterceptor.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.internal.cache;
24

25
import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED;
26
import static java.net.HttpURLConnection.HTTP_SEE_OTHER;
27
import static java.util.Objects.requireNonNull;
28

29
import com.github.mizosoft.methanol.HttpStatus;
30
import com.github.mizosoft.methanol.Methanol.Interceptor;
31
import com.github.mizosoft.methanol.MutableRequest;
32
import com.github.mizosoft.methanol.ResponseBuilder;
33
import com.github.mizosoft.methanol.internal.Utils;
34
import com.github.mizosoft.methanol.internal.extensions.Handlers;
35
import com.github.mizosoft.methanol.internal.flow.FlowSupport;
36
import java.io.IOException;
37
import java.io.UncheckedIOException;
38
import java.lang.System.Logger;
39
import java.net.URI;
40
import java.net.http.HttpClient.Redirect;
41
import java.net.http.HttpHeaders;
42
import java.net.http.HttpRequest;
43
import java.net.http.HttpRequest.BodyPublishers;
44
import java.net.http.HttpResponse;
45
import java.net.http.HttpResponse.BodyHandlers;
46
import java.nio.ByteBuffer;
47
import java.util.List;
48
import java.util.concurrent.CompletableFuture;
49
import java.util.concurrent.Executor;
50
import java.util.concurrent.Flow.Publisher;
51
import java.util.concurrent.atomic.AtomicInteger;
52
import org.checkerframework.checker.nullness.qual.Nullable;
53

54
/**
55
 * An {@link Interceptor} that follows redirects. The interceptor is applied prior to the cache
56
 * interceptor only if one is installed. Allowing the cache to intercept redirects increases its
57
 * efficiency as network access can be avoided in case a redirected URI is accessed repeatedly
58
 * (provided the redirecting response is cacheable). Additionally, this ensures correctness in case
59
 * a cacheable response is received for a redirected request. In such case, the response should be
60
 * cached for the URI the request was redirected to, not the initiating URI.
61
 *
62
 * <p>For best compatibility, the interceptor follows HttpClient's redirecting behaviour.
63
 */
64
public final class RedirectingInterceptor implements Interceptor {
65
  private static final int DEFAULT_MAX_REDIRECTS = 5;
66
  private static final int MAX_REDIRECTS =
1✔
67
      Integer.getInteger("jdk.httpclient.redirects.retrylimit", DEFAULT_MAX_REDIRECTS);
1✔
68

69
  private static final Logger logger = System.getLogger(Handlers.class.getName());
1✔
70

71
  private final Redirect policy;
72

73
  /** The executor used for invoking the response handler. */
74
  private final Executor handlerExecutor;
75

76
  public RedirectingInterceptor(Redirect policy, Executor handlerExecutor) {
1✔
77
    this.policy = requireNonNull(policy);
1✔
78
    this.handlerExecutor = requireNonNull(handlerExecutor);
1✔
79
  }
1✔
80

81
  @Override
82
  public <T> HttpResponse<T> intercept(HttpRequest request, Chain<T> chain)
83
      throws IOException, InterruptedException {
84
    return policy == Redirect.NEVER
1✔
85
        ? chain.forward(request)
1✔
86
        : Utils.get(exchange(request, chain, false));
1✔
87
  }
88

89
  @Override
90
  public <T> CompletableFuture<HttpResponse<T>> interceptAsync(
91
      HttpRequest request, Chain<T> chain) {
92
    return policy == Redirect.NEVER ? chain.forwardAsync(request) : exchange(request, chain, true);
1✔
93
  }
94

95
  private <T> CompletableFuture<HttpResponse<T>> exchange(
96
      HttpRequest request, Chain<T> chain, boolean async) {
97
    var publisherChain = Handlers.toPublisherChain(chain, handlerExecutor);
1✔
98
    return new Exchange(
1✔
99
            request,
100
            async ? ChainAdapter.async(publisherChain) : ChainAdapter.syncOnCaller(publisherChain))
1✔
101
        .exchange()
1✔
102
        .thenCompose(
1✔
103
            response ->
104
                Handlers.handleAsync(
1✔
105
                    response,
106
                    chain.bodyHandler(),
1✔
107
                    async ? handlerExecutor : FlowSupport.SYNC_EXECUTOR));
1✔
108
  }
109

110
  private final class Exchange {
111
    private final AtomicInteger redirectCount = new AtomicInteger();
1✔
112
    private final HttpRequest request;
113
    private final ChainAdapter chainAdapter;
114

115
    Exchange(HttpRequest request, ChainAdapter chainAdapter) {
1✔
116
      this.request = request;
1✔
117
      this.chainAdapter = chainAdapter;
1✔
118
    }
1✔
119

120
    CompletableFuture<HttpResponse<Publisher<List<ByteBuffer>>>> exchange() {
121
      return chainAdapter.forward(request).thenCompose(this::exchange);
1✔
122
    }
123

124
    @SuppressWarnings("FutureReturnValueIgnored")
125
    CompletableFuture<HttpResponse<Publisher<List<ByteBuffer>>>> exchange(
126
        HttpResponse<Publisher<List<ByteBuffer>>> response) {
127
      HttpRequest redirectRequest;
128
      if ((redirectRequest = createRedirectRequest(response)) == null
1✔
129
          || redirectCount.incrementAndGet() >= MAX_REDIRECTS) {
1!
130
        // Reached destination or exceeded allowed retries.
131
        return CompletableFuture.completedFuture(response);
1✔
132
      }
133

134
      // Properly release the redirecting response body.
135
      Handlers.handleAsync(response, BodyHandlers.discarding(), handlerExecutor)
1✔
136
          .whenComplete(
1✔
137
              (__, ex) -> {
138
                if (ex != null) {
1!
NEW
139
                  logger.log(
×
140
                      Logger.Level.WARNING, "Exception while releasing redirecting response", ex);
141
                }
142
              });
1✔
143

144
      // Follow redirection.
145
      return chainAdapter
1✔
146
          .forward(redirectRequest)
1✔
147
          .thenCompose(
1✔
148
              redirectResponse ->
149
                  exchange(
1✔
150
                      ResponseBuilder.from(redirectResponse)
1✔
151
                          .previousResponse(ResponseBuilder.from(response).dropBody().build())
1✔
152
                          .build()));
1✔
153
    }
154

155
    private @Nullable HttpRequest createRedirectRequest(HttpResponse<?> response) {
156
      if (policy == Redirect.NEVER) {
1!
157
        return null;
×
158
      }
159

160
      int statusCode = response.statusCode();
1✔
161
      if (isRedirecting(statusCode) && statusCode != HTTP_NOT_MODIFIED) {
1!
162
        var redirectUri = redirectUri(response.headers());
1✔
163
        var redirectMethod = redirectMethod(response.statusCode());
1✔
164
        if (canRedirectTo(redirectUri)) {
1✔
165
          boolean retainBody =
1✔
166
              statusCode != HTTP_SEE_OTHER && request.method().equalsIgnoreCase(redirectMethod);
1✔
167
          return MutableRequest.copyOf(request)
1✔
168
              .uri(redirectUri)
1✔
169
              .method(
1✔
170
                  redirectMethod,
171
                  request
172
                      .bodyPublisher()
1✔
173
                      .filter(__ -> retainBody)
1✔
174
                      .orElseGet(BodyPublishers::noBody));
1✔
175
        }
176
      }
177
      return null;
1✔
178
    }
179

180
    private URI redirectUri(HttpHeaders responseHeaders) {
181
      return responseHeaders
1✔
182
          .firstValue("Location")
1✔
183
          .map(request.uri()::resolve)
1✔
184
          .orElseThrow(() -> new UncheckedIOException(new IOException("Invalid redirection")));
1✔
185
    }
186

187
    // jdk.internal.net.http.RedirectFilter.redirectedMethod
188
    private String redirectMethod(int statusCode) {
189
      var originalMethod = request.method();
1✔
190
      switch (statusCode) {
1✔
191
        case 301:
192
        case 302:
193
          return originalMethod.equalsIgnoreCase("POST") ? "GET" : originalMethod;
1✔
194
        case 303:
195
          return "GET";
1✔
196
        case 307:
197
        case 308:
198
        default:
199
          return originalMethod;
1✔
200
      }
201
    }
202

203
    // jdk.internal.net.http.RedirectFilter.canRedirect
204
    private boolean canRedirectTo(URI redirectUri) {
205
      var oldScheme = request.uri().getScheme();
1✔
206
      var newScheme = redirectUri.getScheme();
1✔
207
      switch (policy) {
1!
208
        case ALWAYS:
209
          return true;
1✔
210
        case NEVER:
211
          return false;
×
212
        case NORMAL:
213
          return newScheme.equalsIgnoreCase(oldScheme) || newScheme.equalsIgnoreCase("https");
1✔
214
        default:
215
          throw new AssertionError("Unexpected policy: " + policy);
×
216
      }
217
    }
218

219
    // jdk.internal.net.http.RedirectFilter.isRedirecting
220
    private boolean isRedirecting(int statusCode) {
221
      // 309-399 Unassigned => don't follow
222
      if (!HttpStatus.isRedirection(statusCode) || statusCode > 308) {
1✔
223
        return false;
1✔
224
      }
225

226
      switch (statusCode) {
1✔
227
        // 300: MultipleChoice => don't follow
228
        // 304: Not Modified => don't follow
229
        // 305: Proxy Redirect => don't follow.
230
        // 306: Unused => don't follow
231
        case 300:
232
        case 304:
233
        case 305:
234
        case 306:
235
          return false;
1✔
236
        // 301, 302, 303, 307, 308: OK to follow.
237
        default:
238
          return true;
1✔
239
      }
240
    }
241
  }
242
}
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