Skip to content

Commit 7d773de

Browse files
committed
Add retry logic and conditional tests for SDKMAN API resilience
Improve test reliability in CI environments by addressing SDKMAN API rate limiting and availability issues: JdkResolver improvements: - Add retry logic with exponential backoff (3 retries, 1s initial delay) - Detect retryable errors (503, 502, 504, 429, timeouts, connection issues) - Graceful handling of transient network failures - Maintain existing API behavior for successful requests Test improvements: - Add @EnabledIf conditional execution for network-dependent tests - Tests only run when SDKMAN API is available (prevents flaky CI failures) - Quick API availability check with 5-second timeout - Skip tests gracefully when API returns 503/502/504 errors This addresses the CI flakiness caused by SDKMAN API bandwidth limiting and intermittent 503 errors while maintaining full test coverage when the API is available.
1 parent 89b715b commit 7d773de

File tree

2 files changed

+100
-5
lines changed

2 files changed

+100
-5
lines changed

maven-wrapper/src/main/java/org/apache/maven/wrapper/JdkResolver.java

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,50 @@ private JdkMetadata resolveJdkFromSdkman(String sdkmanVersion, String platform)
274274
}
275275

276276
/**
277-
* Makes an HTTP GET request to SDKMAN API.
278-
* For download URLs: returns the redirect location (302 redirect).
279-
* For version lists: returns the response body content.
277+
* Makes an HTTP request with retry logic for transient failures.
278+
* Returns either the redirect location (for download URLs) or response body (for version lists).
280279
*/
281280
private String makeHttpRequest(String urlString) throws IOException {
281+
return makeHttpRequestWithRetry(urlString, 3, 1000); // 3 retries with 1 second initial delay
282+
}
283+
284+
/**
285+
* Makes an HTTP request with configurable retry logic and exponential backoff.
286+
*/
287+
private String makeHttpRequestWithRetry(String urlString, int maxRetries, long initialDelayMs) throws IOException {
288+
IOException lastException = null;
289+
290+
for (int attempt = 0; attempt <= maxRetries; attempt++) {
291+
try {
292+
return performHttpRequest(urlString);
293+
} catch (IOException e) {
294+
lastException = e;
295+
296+
// Check if this is a retryable error
297+
if (attempt < maxRetries && isRetryableError(e)) {
298+
long delayMs = initialDelayMs * (1L << attempt); // Exponential backoff
299+
Logger.info("SDKMAN API request failed (attempt " + (attempt + 1) + "/" + (maxRetries + 1)
300+
+ "), retrying in " + delayMs + "ms: " + e.getMessage());
301+
302+
try {
303+
Thread.sleep(delayMs);
304+
} catch (InterruptedException ie) {
305+
Thread.currentThread().interrupt();
306+
throw new IOException("Request interrupted during retry delay", ie);
307+
}
308+
} else {
309+
break; // Non-retryable error or max retries reached
310+
}
311+
}
312+
}
313+
314+
throw lastException;
315+
}
316+
317+
/**
318+
* Performs the actual HTTP request without retry logic.
319+
*/
320+
private String performHttpRequest(String urlString) throws IOException {
282321
URL url = new URL(urlString);
283322
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
284323

@@ -322,6 +361,31 @@ private String makeHttpRequest(String urlString) throws IOException {
322361
}
323362
}
324363

364+
/**
365+
* Determines if an error is retryable (transient network issues, rate limiting, etc.).
366+
*/
367+
private boolean isRetryableError(IOException e) {
368+
String message = e.getMessage();
369+
if (message != null) {
370+
String lowerMessage = message.toLowerCase();
371+
// Retry on common transient errors
372+
return lowerMessage.contains("503")
373+
|| // Service Unavailable
374+
lowerMessage.contains("502")
375+
|| // Bad Gateway
376+
lowerMessage.contains("504")
377+
|| // Gateway Timeout
378+
lowerMessage.contains("429")
379+
|| // Too Many Requests
380+
lowerMessage.contains("timeout")
381+
|| // Various timeout errors
382+
lowerMessage.contains("connection reset")
383+
|| lowerMessage.contains("connection refused")
384+
|| lowerMessage.contains("temporary failure");
385+
}
386+
return false;
387+
}
388+
325389
/**
326390
* Maps SDKMAN vendor suffix back to vendor name.
327391
*/

maven-wrapper/src/test/java/org/apache/maven/wrapper/JdkResolverTest.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@
1919
package org.apache.maven.wrapper;
2020

2121
import java.io.IOException;
22+
import java.net.HttpURLConnection;
23+
import java.net.URL;
2224

2325
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.condition.EnabledIf;
2427

2528
import static org.junit.jupiter.api.Assertions.*;
2629

@@ -29,7 +32,32 @@
2932
*/
3033
class JdkResolverTest {
3134

35+
/**
36+
* Checks if SDKMAN API is available for network-dependent tests.
37+
* This prevents test failures in CI environments where the API might be unavailable.
38+
*/
39+
static boolean isSdkmanApiAvailable() {
40+
try {
41+
URL url = new URL("https://api.sdkman.io/2/candidates/java/linuxx64/versions/all");
42+
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
43+
connection.setRequestMethod("HEAD");
44+
connection.setConnectTimeout(5000); // 5 second timeout
45+
connection.setReadTimeout(5000);
46+
connection.setRequestProperty("User-Agent", "Maven-Wrapper-Test/3.3.0");
47+
48+
int responseCode = connection.getResponseCode();
49+
connection.disconnect();
50+
51+
// Consider API available if we get any response (200, 302, etc.) but not 503/502/504
52+
return responseCode != 503 && responseCode != 502 && responseCode != 504;
53+
} catch (Exception e) {
54+
// API is not available
55+
return false;
56+
}
57+
}
58+
3259
@Test
60+
@EnabledIf("isSdkmanApiAvailable")
3361
void testResolveTemurinJdk() throws IOException {
3462
JdkResolver resolver = new JdkResolver();
3563

@@ -43,6 +71,7 @@ void testResolveTemurinJdk() throws IOException {
4371
}
4472

4573
@Test
74+
@EnabledIf("isSdkmanApiAvailable")
4675
void testResolveJdkWithDefaultVendor() throws IOException {
4776
JdkResolver resolver = new JdkResolver();
4877

@@ -67,6 +96,7 @@ void testResolveJdkWithInvalidVersion() {
6796
}
6897

6998
@Test
99+
@EnabledIf("isSdkmanApiAvailable")
70100
void testResolveJdkWithUnsupportedVendor() {
71101
JdkResolver resolver = new JdkResolver();
72102

@@ -80,12 +110,13 @@ void testResolveJdkWithUnsupportedVendor() {
80110
}
81111

82112
@Test
113+
@EnabledIf("isSdkmanApiAvailable")
83114
void testResolveMajorVersionQueriesApi() throws IOException {
84115
JdkResolver resolver = new JdkResolver();
85116

86117
// Test that major version resolution actually queries SDKMAN API
87-
// This test will make a real API call - in a production test suite,
88-
// you might want to mock this
118+
// This test makes a real API call but is conditional on API availability
119+
// to prevent flaky failures in CI environments
89120
JdkResolver.JdkMetadata metadata = resolver.resolveJdk("17", "temurin");
90121

91122
assertNotNull(metadata);

0 commit comments

Comments
 (0)