HttpSupport.java

1
package org.egothor.methodatlas.ai;
2
3
import java.io.IOException;
4
import java.net.URI;
5
import java.net.http.HttpClient;
6
import java.net.http.HttpRequest;
7
import java.net.http.HttpResponse;
8
import java.time.Duration;
9
import java.util.logging.Level;
10
import java.util.logging.Logger;
11
import java.util.regex.Matcher;
12
import java.util.regex.Pattern;
13
14
import com.fasterxml.jackson.databind.DeserializationFeature;
15
import com.fasterxml.jackson.databind.ObjectMapper;
16
17
/**
18
 * Small HTTP utility component used by AI provider clients for outbound network
19
 * communication and JSON processing support.
20
 *
21
 * <p>
22
 * This class centralizes common HTTP-related functionality required by the AI
23
 * provider integrations, including:
24
 * </p>
25
 * <ul>
26
 * <li>creation of a configured {@link HttpClient}</li>
27
 * <li>provision of a shared Jackson {@link ObjectMapper}</li>
28
 * <li>execution of JSON-oriented HTTP requests</li>
29
 * <li>construction of JSON {@code POST} requests</li>
30
 * </ul>
31
 *
32
 * <p>
33
 * The helper is intentionally lightweight and provider-agnostic. It does not
34
 * implement provider-specific authentication, endpoint selection, or response
35
 * normalization logic; those responsibilities remain in the concrete provider
36
 * clients.
37
 * </p>
38
 *
39
 * <p>
40
 * The internally managed {@link ObjectMapper} is configured to ignore unknown
41
 * JSON properties so that provider response deserialization remains resilient
42
 * to non-breaking API changes.
43
 * </p>
44
 *
45
 * <p>
46
 * Instances of this class are immutable after construction.
47
 * </p>
48
 *
49
 * @see HttpClient
50
 * @see ObjectMapper
51
 * @see AiProviderClient
52
 */
53
public final class HttpSupport {
54
55
    private static final Logger LOGGER = Logger.getLogger(HttpSupport.class.getName());
56
    private static final Pattern RETRY_AFTER_SECONDS = Pattern.compile("Please wait (\\d+) seconds");
57
    /* default */ static final long DEFAULT_RETRY_WAIT_SECONDS = 60L;
58
59
    private final HttpClient httpClient;
60
    private final ObjectMapper objectMapper;
61
    private final int maxRetries;
62
    private final RateLimitListener rateLimitListener;
63
64
    /**
65
     * Creates a new HTTP support helper with the specified connection timeout.
66
     *
67
     * <p>
68
     * Rate-limit events are silently discarded by this constructor.  Use
69
     * {@link #HttpSupport(Duration, int, RateLimitListener)} when callers need
70
     * to be informed of HTTP&nbsp;429 pauses.
71
     * </p>
72
     *
73
     * <p>
74
     * The constructor initializes a Jackson {@link ObjectMapper} configured with
75
     * {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} disabled.
76
     * </p>
77
     *
78
     * @param timeout    connection timeout used for the underlying HTTP client
79
     * @param maxRetries maximum number of retry attempts on HTTP 429 responses
80
     * @see #HttpSupport(Duration, int, RateLimitListener)
81
     */
82
    public HttpSupport(Duration timeout, int maxRetries) {
83
        this(timeout, maxRetries, (w, a, m) -> {});
84
    }
85
86
    /**
87
     * Creates a new HTTP support helper with the specified connection timeout and
88
     * a rate-limit callback.
89
     *
90
     * <p>
91
     * The supplied {@code rateLimitListener} is invoked on the calling thread
92
     * immediately before each rate-limit sleep caused by an HTTP&nbsp;429
93
     * response, allowing higher-level components to update progress indicators
94
     * or log messages without polling.
95
     * </p>
96
     *
97
     * <p>
98
     * The constructor initializes a Jackson {@link ObjectMapper} configured with
99
     * {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} disabled.
100
     * </p>
101
     *
102
     * @param timeout             connection timeout used for the underlying HTTP
103
     *                            client
104
     * @param maxRetries          maximum number of retry attempts on HTTP 429
105
     *                            responses
106
     * @param rateLimitListener   callback invoked before each rate-limit pause;
107
     *                            must not be {@code null}
108
     * @see RateLimitListener
109
     */
110
    public HttpSupport(Duration timeout, int maxRetries, RateLimitListener rateLimitListener) {
111
        this.httpClient = HttpClient.newBuilder().connectTimeout(timeout).build();
112
        this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
113
        this.maxRetries = maxRetries;
114
        this.rateLimitListener = rateLimitListener;
115
    }
116
117
    /**
118
     * Returns the configured HTTP client used by this helper.
119
     *
120
     * @return configured HTTP client instance
121
     */
122
    public HttpClient httpClient() {
123 1 1. httpClient : replaced return value with null for org/egothor/methodatlas/ai/HttpSupport::httpClient → NO_COVERAGE
        return httpClient;
124
    }
125
126
    /**
127
     * Returns the configured Jackson object mapper used for JSON serialization and
128
     * deserialization.
129
     *
130
     * @return configured object mapper instance
131
     */
132
    public ObjectMapper objectMapper() {
133 1 1. objectMapper : replaced return value with null for org/egothor/methodatlas/ai/HttpSupport::objectMapper → NO_COVERAGE
        return objectMapper;
134
    }
135
136
    /**
137
     * Executes an HTTP request expected to return a JSON response body and returns
138
     * the response content as text.
139
     *
140
     * <p>
141
     * The method sends the supplied request using the internally configured
142
     * {@link HttpClient}. Responses with HTTP status codes outside the successful
143
     * {@code 2xx} range are treated as failures and cause an {@link IOException} to
144
     * be thrown containing both the status code and response body.
145
     * </p>
146
     *
147
     * <p>
148
     * Despite the method name, the request itself is not required to be a
149
     * {@code POST} request; the method simply executes the provided request and
150
     * validates that the response indicates success.
151
     * </p>
152
     *
153
     * @param request HTTP request to execute
154
     * @return response body as text
155
     *
156
     * @throws IOException          if request execution fails or if the HTTP
157
     *                              response status code is outside the successful
158
     *                              {@code 2xx} range
159
     * @throws InterruptedException if the calling thread is interrupted while
160
     *                              waiting for the response
161
     */
162
    @SuppressWarnings("PMD.DoNotUseThreads")
163
    public String postJson(HttpRequest request) throws IOException, InterruptedException {
164
        int attempt = 0;
165
        int statusCode;
166
        String body;
167
        do {
168
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
169
            statusCode = response.statusCode();
170
            body = response.body();
171 5 1. postJson : removed conditional - replaced comparison check with false → SURVIVED
2. postJson : removed conditional - replaced comparison check with true → SURVIVED
3. postJson : removed conditional - replaced equality check with false → SURVIVED
4. postJson : changed conditional boundary → SURVIVED
5. postJson : removed conditional - replaced equality check with true → SURVIVED
            if (statusCode == 429 && attempt < maxRetries) {
172
                long waitSeconds = resolveRetryAfter(response);
173
                if (LOGGER.isLoggable(Level.WARNING)) {
174
                    LOGGER.warning("AI provider rate limit reached (HTTP 429) — waiting " + waitSeconds
175
                            + "s before retry " + (attempt + 1) + "/" + maxRetries
176
                            + ". No AI classification will occur during this pause.");
177
                }
178 2 1. postJson : Replaced integer addition with subtraction → SURVIVED
2. postJson : removed call to org/egothor/methodatlas/ai/RateLimitListener::onRateLimitPause → SURVIVED
                rateLimitListener.onRateLimitPause(waitSeconds, attempt + 1, maxRetries);
179 1 1. postJson : removed call to java/lang/Thread::sleep → SURVIVED
                Thread.sleep(Duration.ofSeconds(waitSeconds));
180
            }
181 1 1. postJson : Changed increment from 1 to -1 → TIMED_OUT
            attempt++;
182 5 1. postJson : removed conditional - replaced equality check with true → SURVIVED
2. postJson : removed conditional - replaced comparison check with false → TIMED_OUT
3. postJson : changed conditional boundary → KILLED
4. postJson : removed conditional - replaced equality check with false → KILLED
5. postJson : removed conditional - replaced comparison check with true → KILLED
        } while (statusCode == 429 && attempt <= maxRetries);
183 6 1. postJson : removed conditional - replaced comparison check with true → SURVIVED
2. postJson : changed conditional boundary → SURVIVED
3. postJson : changed conditional boundary → KILLED
4. postJson : removed conditional - replaced comparison check with false → KILLED
5. postJson : removed conditional - replaced comparison check with true → KILLED
6. postJson : removed conditional - replaced comparison check with false → KILLED
        if (statusCode < 200 || statusCode >= 300) {
184
            throw new IOException("HTTP " + statusCode + ": " + body);
185
        }
186 1 1. postJson : replaced return value with "" for org/egothor/methodatlas/ai/HttpSupport::postJson → KILLED
        return body;
187
    }
188
189
    /* default */ static long resolveRetryAfter(HttpResponse<String> response) {
190
        String header = response.headers().firstValue("Retry-After").orElse(null);
191 2 1. resolveRetryAfter : removed conditional - replaced equality check with true → KILLED
2. resolveRetryAfter : removed conditional - replaced equality check with false → KILLED
        if (header != null) {
192
            try {
193
                long parsed = Long.parseLong(header.trim());
194 3 1. resolveRetryAfter : removed conditional - replaced comparison check with false → KILLED
2. resolveRetryAfter : changed conditional boundary → KILLED
3. resolveRetryAfter : removed conditional - replaced comparison check with true → KILLED
                if (parsed > 0) {
195 1 1. resolveRetryAfter : replaced long return with 0 for org/egothor/methodatlas/ai/HttpSupport::resolveRetryAfter → KILLED
                    return parsed;
196
                }
197
            } catch (NumberFormatException ignored) {
198
                // Non-numeric Retry-After header value; fall through to body parsing.
199
            }
200
        }
201
        Matcher matcher = RETRY_AFTER_SECONDS.matcher(response.body());
202 2 1. resolveRetryAfter : removed conditional - replaced equality check with false → KILLED
2. resolveRetryAfter : removed conditional - replaced equality check with true → KILLED
        if (matcher.find()) {
203
            try {
204
                long parsed = Long.parseLong(matcher.group(1));
205 3 1. resolveRetryAfter : removed conditional - replaced comparison check with true → KILLED
2. resolveRetryAfter : changed conditional boundary → KILLED
3. resolveRetryAfter : removed conditional - replaced comparison check with false → KILLED
                if (parsed > 0) {
206 1 1. resolveRetryAfter : replaced long return with 0 for org/egothor/methodatlas/ai/HttpSupport::resolveRetryAfter → KILLED
                    return parsed;
207
                }
208
            } catch (NumberFormatException ignored) {
209
                // Regex matched but captured group is not a valid long; fall through.
210
            }
211
        }
212
        // Neither the Retry-After header nor the response body supplied a usable
213
        // positive wait time. Fall back to the conservative default so that retries
214
        // are not sent immediately against a provider that is already saturated.
215 1 1. resolveRetryAfter : replaced long return with 0 for org/egothor/methodatlas/ai/HttpSupport::resolveRetryAfter → KILLED
        return DEFAULT_RETRY_WAIT_SECONDS;
216
    }
217
218
    /**
219
     * Creates a JSON-oriented HTTP {@code POST} request builder.
220
     *
221
     * <p>
222
     * The returned builder is preconfigured with:
223
     * </p>
224
     * <ul>
225
     * <li>the supplied target {@link URI}</li>
226
     * <li>the supplied request timeout</li>
227
     * <li>{@code Content-Type: application/json}</li>
228
     * <li>a {@code POST} request body containing the supplied JSON text</li>
229
     * </ul>
230
     *
231
     * <p>
232
     * Callers may further customize the returned builder, for example by adding
233
     * authentication or provider-specific headers, before invoking
234
     * {@link HttpRequest.Builder#build()}.
235
     * </p>
236
     *
237
     * @param uri     target URI of the request
238
     * @param body    serialized JSON request body
239
     * @param timeout request timeout
240
     * @return preconfigured HTTP request builder for a JSON {@code POST} request
241
     */
242
    public HttpRequest.Builder jsonPost(URI uri, String body, Duration timeout) {
243 1 1. jsonPost : replaced return value with null for org/egothor/methodatlas/ai/HttpSupport::jsonPost → KILLED
        return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
244
                .POST(HttpRequest.BodyPublishers.ofString(body));
245
    }
246
}

Mutations

123

1.1
Location : httpClient
Killed by : none
replaced return value with null for org/egothor/methodatlas/ai/HttpSupport::httpClient → NO_COVERAGE

133

1.1
Location : objectMapper
Killed by : none
replaced return value with null for org/egothor/methodatlas/ai/HttpSupport::objectMapper → NO_COVERAGE

171

1.1
Location : postJson
Killed by : none
removed conditional - replaced comparison check with false → SURVIVED
Covering tests

2.2
Location : postJson
Killed by : none
removed conditional - replaced comparison check with true → SURVIVED Covering tests

3.3
Location : postJson
Killed by : none
removed conditional - replaced equality check with false → SURVIVED Covering tests

4.4
Location : postJson
Killed by : none
changed conditional boundary → SURVIVED Covering tests

5.5
Location : postJson
Killed by : none
removed conditional - replaced equality check with true → SURVIVED Covering tests

178

1.1
Location : postJson
Killed by : none
Replaced integer addition with subtraction → SURVIVED
Covering tests

2.2
Location : postJson
Killed by : none
removed call to org/egothor/methodatlas/ai/RateLimitListener::onRateLimitPause → SURVIVED Covering tests

179

1.1
Location : postJson
Killed by : none
removed call to java/lang/Thread::sleep → SURVIVED
Covering tests

181

1.1
Location : postJson
Killed by : none
Changed increment from 1 to -1 → TIMED_OUT

182

1.1
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_retriesOnce_afterRateLimit()]
changed conditional boundary → KILLED

2.2
Location : postJson
Killed by : none
removed conditional - replaced equality check with true → SURVIVED
Covering tests

3.3
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_retriesOnce_afterRateLimit()]
removed conditional - replaced equality check with false → KILLED

4.4
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_retriesOnce_afterRateLimit()]
removed conditional - replaced comparison check with true → KILLED

5.5
Location : postJson
Killed by : none
removed conditional - replaced comparison check with false → TIMED_OUT

183

1.1
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_returnsBodyOnSuccess()]
changed conditional boundary → KILLED

2.2
Location : postJson
Killed by : none
removed conditional - replaced comparison check with true → SURVIVED
Covering tests

3.3
Location : postJson
Killed by : none
changed conditional boundary → SURVIVED Covering tests

4.4
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_throwsAfterExhaustingRetries()]
removed conditional - replaced comparison check with false → KILLED

5.5
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_returnsBodyOnSuccess()]
removed conditional - replaced comparison check with true → KILLED

6.6
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_returnsBodyOnSuccess()]
removed conditional - replaced comparison check with false → KILLED

186

1.1
Location : postJson
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_returnsBodyOnSuccess()]
replaced return value with "" for org/egothor/methodatlas/ai/HttpSupport::postJson → KILLED

191

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_parsesBodyWhenHeaderAbsent()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_prefersHeaderOverBody()]
removed conditional - replaced equality check with false → KILLED

194

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_prefersHeaderOverBody()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_fallsBackToDefaultWhenHeaderIsZero()]
changed conditional boundary → KILLED

3.3
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_fallsBackToDefaultWhenHeaderIsZero()]
removed conditional - replaced comparison check with true → KILLED

195

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_prefersHeaderOverBody()]
replaced long return with 0 for org/egothor/methodatlas/ai/HttpSupport::resolveRetryAfter → KILLED

202

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_parsesBodyWhenHeaderAbsent()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_returnsDefaultWhenNoHintPresent()]
removed conditional - replaced equality check with true → KILLED

205

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_fallsBackToDefaultWhenBodySaysZeroSeconds()]
removed conditional - replaced comparison check with true → KILLED

2.2
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_fallsBackToDefaultWhenBodySaysZeroSeconds()]
changed conditional boundary → KILLED

3.3
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_parsesBodyWhenHeaderAbsent()]
removed conditional - replaced comparison check with false → KILLED

206

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_parsesBodyWhenHeaderAbsent()]
replaced long return with 0 for org/egothor/methodatlas/ai/HttpSupport::resolveRetryAfter → KILLED

215

1.1
Location : resolveRetryAfter
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:resolveRetryAfter_fallsBackToDefaultWhenBodySaysZeroSeconds()]
replaced long return with 0 for org/egothor/methodatlas/ai/HttpSupport::resolveRetryAfter → KILLED

243

1.1
Location : jsonPost
Killed by : org.egothor.methodatlas.ai.HttpSupportTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.HttpSupportTest]/[method:postJson_returnsBodyOnSuccess()]
replaced return value with null for org/egothor/methodatlas/ai/HttpSupport::jsonPost → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1