diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index a72c2c71..7c58b5e7 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -120,6 +120,21 @@ public final class Registry extends OCI { */ private @Nullable MeterRegistry meterRegistry; + /** + * Maximum number of attempts for retryable requests (1 = no retry) + */ + private int maxRetries = 3; + + /** + * Initial delay between retries in milliseconds + */ + private long retryDelayMs = 500L; + + /** + * Upper bound on retry delay in milliseconds + */ + private long maxRetryDelayMs = 30_000L; + /** * Constructor */ @@ -290,12 +305,28 @@ private void setMeterRegistry(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } + private void setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + + private void setRetryDelayMs(long retryDelayMs) { + this.retryDelayMs = retryDelayMs; + } + + private void setMaxRetryDelayMs(long maxRetryDelayMs) { + this.maxRetryDelayMs = maxRetryDelayMs; + } + /** * Build the provider * @return The provider */ private Registry build() { - HttpClient.Builder clientBuilder = HttpClient.Builder.builder().withSkipTlsVerify(skipTlsVerify); + HttpClient.Builder clientBuilder = HttpClient.Builder.builder() + .withSkipTlsVerify(skipTlsVerify) + .withMaxRetries(maxRetries) + .withRetryDelay(retryDelayMs) + .withMaxRetryDelay(maxRetryDelayMs); if (caFilePath != null) { clientBuilder = clientBuilder.withCaFile(caFilePath); } @@ -1565,6 +1596,9 @@ public Builder from(Registry registry) { this.registry.setSkipTlsVerify(registry.skipTlsVerify); this.registry.setExecutorService(registry.executorService); this.registry.setParallelism(registry.maxConcurrentDownloads); + this.registry.setMaxRetries(registry.maxRetries); + this.registry.setRetryDelayMs(registry.retryDelayMs); + this.registry.setMaxRetryDelayMs(registry.maxRetryDelayMs); if (registry.meterRegistry != null) { this.registry.setMeterRegistry(registry.meterRegistry); } @@ -1743,6 +1777,40 @@ public Builder withMeterRegistry(MeterRegistry meterRegistry) { return this; } + /** + * Set the maximum number of attempts for retryable requests (default: 3). + * A value of 1 disables retries entirely. + * Retryable conditions: HTTP 429, HTTP 5xx, network errors (IOException / timeout). + * Token-refresh requests are never retried regardless of this setting. + * @param maxRetries Maximum attempts (must be >= 1) + * @return The builder + */ + public Builder withMaxRetries(int maxRetries) { + registry.setMaxRetries(maxRetries); + return this; + } + + /** + * Set the initial delay before the first retry in milliseconds (default: 500). + * Subsequent delays are doubled up to the limit set by {@link #withMaxRetryDelay}. + * @param retryDelayMs Initial delay in milliseconds (must be >= 0) + * @return The builder + */ + public Builder withRetryDelay(long retryDelayMs) { + registry.setRetryDelayMs(retryDelayMs); + return this; + } + + /** + * Set the upper bound on retry delay in milliseconds (default: 30 000). + * @param maxRetryDelayMs Maximum delay cap in milliseconds (must be >= 0) + * @return The builder + */ + public Builder withMaxRetryDelay(long maxRetryDelayMs) { + registry.setMaxRetryDelayMs(maxRetryDelayMs); + return this; + } + /** * Return a new builder * @return The builder diff --git a/src/main/java/land/oras/auth/HttpClient.java b/src/main/java/land/oras/auth/HttpClient.java index d35a3416..3482499a 100644 --- a/src/main/java/land/oras/auth/HttpClient.java +++ b/src/main/java/land/oras/auth/HttpClient.java @@ -30,6 +30,7 @@ import java.net.*; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -113,6 +114,21 @@ public final class HttpClient { */ private Integer timeout; + /** + * Maximum number of attempts (1 = no retry, 2 = one retry, …) + */ + private int maxRetries = 3; + + /** + * Initial delay between retries in milliseconds (doubles on each attempt) + */ + private long retryDelayMs = 500L; + + /** + * Upper bound on retry delay in milliseconds + */ + private long maxRetryDelayMs = 30_000L; + /** * The meter registry for metrics */ @@ -280,7 +296,23 @@ public ResponseWrapper get(URI uri, Map headers, Scopes HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.noBody(), scopes, - authProvider); + authProvider, + true); + } + + private ResponseWrapper getForTokenRefresh( + URI uri, Map headers, Scopes scopes, AuthProvider authProvider) { + return executeRequest( + "GET", + uri, + true, + headers, + new byte[0], + HttpResponse.BodyHandlers.ofString(), + HttpRequest.BodyPublishers.noBody(), + scopes, + authProvider, + false); } /** @@ -303,7 +335,8 @@ public ResponseWrapper download( HttpResponse.BodyHandlers.ofFile(file), HttpRequest.BodyPublishers.noBody(), scopes, - authProvider); + authProvider, + true); } /** @@ -325,7 +358,8 @@ public ResponseWrapper download( HttpResponse.BodyHandlers.ofInputStream(), HttpRequest.BodyPublishers.noBody(), scopes, - authProvider); + authProvider, + true); } /** @@ -350,7 +384,8 @@ public ResponseWrapper upload( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.ofFile(file), scopes, - authProvider); + authProvider, + true); } catch (FileNotFoundException e) { throw new OrasException("Unable to upload file. File not found.", e); } @@ -382,7 +417,8 @@ public ResponseWrapper upload( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.fromPublisher(HttpRequest.BodyPublishers.ofInputStream(stream), size), scopes, - authProvider); + authProvider, + true); } /** @@ -404,7 +440,8 @@ public ResponseWrapper head( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.noBody(), scopes, - authProvider); + authProvider, + true); } /** @@ -426,7 +463,8 @@ public ResponseWrapper delete( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.noBody(), scopes, - authProvider); + authProvider, + true); } /** @@ -449,7 +487,8 @@ public ResponseWrapper post( HttpResponse.BodyHandlers.ofString(), body.length == 0 ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofByteArray(body), scopes, - authProvider); + authProvider, + true); } /** @@ -472,7 +511,8 @@ public ResponseWrapper patch( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.ofByteArray(body), scopes, - authProvider); + authProvider, + true); } /** @@ -501,7 +541,8 @@ public ResponseWrapper patch( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.fromPublisher(HttpRequest.BodyPublishers.ofInputStream(stream), chunkSize), scopes, - authProvider); + authProvider, + true); } /** @@ -524,7 +565,8 @@ public ResponseWrapper put( HttpResponse.BodyHandlers.ofString(), HttpRequest.BodyPublishers.ofByteArray(body), scopes, - authProvider); + authProvider, + true); } /** @@ -565,9 +607,9 @@ public TokenResponse refreshToken( URI uri = URI.create(realm + "?" + query); - // Perform the request to get the token + // Perform the request to get the token (no retry — a failed token request is a hard failure) Map headers = new HashMap<>(); - HttpClient.ResponseWrapper responseWrapper = get(uri, headers, scopes, authProvider); + HttpClient.ResponseWrapper responseWrapper = getForTokenRefresh(uri, headers, scopes, authProvider); // Log the response LOG.debug( @@ -614,14 +656,18 @@ static boolean shouldRedirect(HttpResponse response) { } /** - * Execute a request - * @param method The method + * Execute a request, with optional retry on transient failures (429, 5xx, network errors). + * Token-refresh requests must pass {@code retryEnabled=false}. + * @param method The HTTP method * @param uri The URI - * @param headers The headers - * @param body The body - * @param handler The response handler + * @param includeAuthHeader Whether to attach an Authorization header + * @param headers Extra headers + * @param body Raw body bytes (used only for logging) + * @param handler The response body handler * @param bodyPublisher The body publisher + * @param scopes The scopes * @param authProvider The authentication provider + * @param retryEnabled Whether transient failures should be retried * @return The response */ private ResponseWrapper executeRequest( @@ -633,79 +679,153 @@ private ResponseWrapper executeRequest( HttpResponse.BodyHandler handler, HttpRequest.BodyPublisher bodyPublisher, Scopes scopes, - AuthProvider authProvider) { - try { - HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).method(method, bodyPublisher); - - // Get scope based on method - ContainerRef containerRef = scopes.getContainerRef(); - LOG.debug("Scopes are adding registry scopes"); - Scopes newScopes = - switch (method) { - case "GET", "HEAD" -> scopes.withAddedRegistryScopes(Scope.PULL); - case "POST", "PUT", "PATCH" -> scopes.withAddedRegistryScopes(Scope.PUSH); - case "DELETE" -> scopes.withAddedRegistryScopes(Scope.DELETE); - default -> throw new OrasException("Unsupported HTTP method: " + method); - }; - - LOG.debug("Existing scopes: {}", scopes.getScopes()); - LOG.debug("New scopes: {}", newScopes.getScopes()); - - // Check if token is present and reuse auth instead of passing auth provider - TokenResponse cachedToken = TokenCache.get(newScopes); - if (cachedToken == null) { - LOG.trace("No token found in cache for scopes: {}", newScopes); - } else { - LOG.trace("Found token in cache for scopes: {}", newScopes.withService(cachedToken.service())); - } + AuthProvider authProvider, + boolean retryEnabled) { + + // Scopes are invariant across retries — compute once. + ContainerRef containerRef = scopes.getContainerRef(); + LOG.debug("Scopes are adding registry scopes"); + Scopes newScopes = + switch (method) { + case "GET", "HEAD" -> scopes.withAddedRegistryScopes(Scope.PULL); + case "POST", "PUT", "PATCH" -> scopes.withAddedRegistryScopes(Scope.PUSH); + case "DELETE" -> scopes.withAddedRegistryScopes(Scope.DELETE); + default -> throw new OrasException("Unsupported HTTP method: " + method); + }; + LOG.debug("Existing scopes: {}", scopes.getScopes()); + LOG.debug("New scopes: {}", newScopes.getScopes()); + + int maxAttempts = retryEnabled ? this.maxRetries : 1; + + for (int attempt = 0; attempt < maxAttempts; attempt++) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).method(method, bodyPublisher); + + // Check token cache — may be populated by a prior attempt's 401 handling. + TokenResponse cachedToken = TokenCache.get(newScopes); + if (cachedToken == null) { + LOG.trace("No token found in cache for scopes: {}", newScopes); + } else { + LOG.trace("Found token in cache for scopes: {}", newScopes.withService(cachedToken.service())); + } - // Add authentication header if any (from provider or cached token) - var authHeader = authProvider.getAuthHeader(containerRef); - if (cachedToken == null - && authHeader != null - && !authProvider.getAuthScheme().equals(AuthScheme.NONE) - && includeAuthHeader) { - builder = builder.header(Const.AUTHORIZATION_HEADER, authHeader); - } else if (cachedToken != null && includeAuthHeader) { - builder = builder.header(Const.AUTHORIZATION_HEADER, "Bearer " + cachedToken.getEffectiveToken()); - } - headers.forEach(builder::header); - - // Add user agent - builder = builder.header(Const.USER_AGENT_HEADER, Versions.USER_AGENT_VALUE); - - HttpRequest request = builder.build(); - logRequest(request, body); - HttpResponse response = executeAndRecordRequest(request, handler); - - // Follow redirect - if (shouldRedirect(response)) { - String location = getLocationHeader(response); - URI redirectUri = URI.create(location); - LOG.debug("Redirecting to {} from domain {} to domain {}", location, uri, redirectUri); - boolean includeAuthHeaderForRedirect = isSameOrigin(uri, redirectUri); - if (!includeAuthHeaderForRedirect) { - LOG.debug("Skipping auth header for redirect from {} to {}", uri, redirectUri); + // Add authentication header if any (from provider or cached token) + var authHeader = authProvider.getAuthHeader(containerRef); + if (cachedToken == null + && authHeader != null + && !authProvider.getAuthScheme().equals(AuthScheme.NONE) + && includeAuthHeader) { + builder = builder.header(Const.AUTHORIZATION_HEADER, authHeader); + } else if (cachedToken != null && includeAuthHeader) { + builder = builder.header(Const.AUTHORIZATION_HEADER, "Bearer " + cachedToken.getEffectiveToken()); + } + headers.forEach(builder::header); + + // Add user agent + builder = builder.header(Const.USER_AGENT_HEADER, Versions.USER_AGENT_VALUE); + + HttpRequest request = builder.build(); + logRequest(request, body); + HttpResponse response = executeAndRecordRequest(request, handler); + + // Follow redirect (retryEnabled propagates into the recursive call) + if (shouldRedirect(response)) { + String location = getLocationHeader(response); + URI redirectUri = URI.create(location); + LOG.debug("Redirecting to {} from domain {} to domain {}", location, uri, redirectUri); + boolean includeAuthHeaderForRedirect = isSameOrigin(uri, redirectUri); + if (!includeAuthHeaderForRedirect) { + LOG.debug("Skipping auth header for redirect from {} to {}", uri, redirectUri); + } + return executeRequest( + method, + redirectUri, + includeAuthHeaderForRedirect, + headers, + body, + handler, + bodyPublisher, + newScopes, + authProvider, + retryEnabled); + } + + // Retry on 429 / 5xx before delegating 401/403 to redoRequest. + if (retryEnabled && isRetryableStatus(response.statusCode()) && attempt < maxAttempts - 1) { + long delay = computeRetryDelay(response, attempt); + LOG.warn( + "Retrying request ({}/{}) after {}ms, status={}", + attempt + 1, + maxAttempts - 1, + delay, + response.statusCode()); + meterRegistry + .counter(Const.METRIC_HTTP_RETRIES, "reason", retryReason(response.statusCode())) + .increment(); + Thread.sleep(delay); + continue; + } + + return redoRequest(uri, response, builder, handler, newScopes, authProvider); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OrasException("Request interrupted during retry wait", e); + } catch (OrasException e) { + throw e; + } catch (Exception e) { + if (retryEnabled && attempt < maxAttempts - 1 && isRetryableException(e)) { + long delay = computeRetryDelay(null, attempt); + LOG.warn( + "Retrying request ({}/{}) after {}ms, error={}", + attempt + 1, + maxAttempts - 1, + delay, + e.getMessage()); + meterRegistry + .counter(Const.METRIC_HTTP_RETRIES, "reason", "network_error") + .increment(); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new OrasException("Request interrupted during retry wait", ie); + } + } else { + LOG.error("Failed to execute request", e); + throw new OrasException("Unable to create HTTP request", e); } - return executeRequest( - method, - redirectUri, - includeAuthHeaderForRedirect, - headers, - body, - handler, - bodyPublisher, - newScopes, - authProvider); } - return redoRequest(uri, response, builder, handler, newScopes, authProvider); - } catch (Exception e) { - if (e instanceof OrasException) { - throw (OrasException) e; + } + throw new OrasException("Max retries (" + (maxAttempts - 1) + ") exceeded"); + } + + private static boolean isRetryableStatus(int statusCode) { + return statusCode == 429 || (statusCode >= 500 && statusCode <= 599); + } + + private static boolean isRetryableException(Exception e) { + return e instanceof HttpTimeoutException || e instanceof java.io.IOException; + } + + private long computeRetryDelay(@Nullable HttpResponse response, int attempt) { + if (response != null && response.statusCode() == 429) { + String retryAfter = response.headers().firstValue("Retry-After").orElse(null); + if (retryAfter != null) { + try { + return Long.parseLong(retryAfter.trim()) * 1000L; + } catch (NumberFormatException ignored) { + } } - LOG.error("Failed to execute request", e); - throw new OrasException("Unable to create HTTP request", e); } + long delay = retryDelayMs * (1L << Math.min(attempt, 30)); + return Math.min(delay, maxRetryDelayMs); + } + + private static String retryReason(int statusCode) { + if (statusCode == 429) return "rate_limit"; + if (statusCode >= 500) return "server_error"; + return "unknown"; } private HttpResponse executeAndRecordRequest(HttpRequest request, HttpResponse.BodyHandler handler) @@ -933,6 +1053,41 @@ public Builder withMeterRegistry(MeterRegistry meterRegistry) { return this; } + /** + * Set the maximum number of attempts for retryable requests (default: 3). + * A value of 1 disables retries entirely. + * @param maxRetries Maximum attempts (must be >= 1) + * @return The builder + */ + public Builder withMaxRetries(int maxRetries) { + if (maxRetries < 1) throw new IllegalArgumentException("maxRetries must be >= 1"); + client.maxRetries = maxRetries; + return this; + } + + /** + * Set the initial delay before the first retry in milliseconds (default: 500). + * Subsequent delays are doubled up to {@link #withMaxRetryDelay}. + * @param retryDelayMs Initial delay in milliseconds (must be >= 0) + * @return The builder + */ + public Builder withRetryDelay(long retryDelayMs) { + if (retryDelayMs < 0) throw new IllegalArgumentException("retryDelayMs must be >= 0"); + client.retryDelayMs = retryDelayMs; + return this; + } + + /** + * Set the upper bound on retry delay in milliseconds (default: 30 000). + * @param maxRetryDelayMs Maximum delay cap in milliseconds (must be >= 0) + * @return The builder + */ + public Builder withMaxRetryDelay(long maxRetryDelayMs) { + if (maxRetryDelayMs < 0) throw new IllegalArgumentException("maxRetryDelayMs must be >= 0"); + client.maxRetryDelayMs = maxRetryDelayMs; + return this; + } + /** * Set the CA file for TLS verification * @param caFilePath The path to a PEM-encoded CA certificate or bundle diff --git a/src/main/java/land/oras/utils/Const.java b/src/main/java/land/oras/utils/Const.java index 6d09c714..58a10a52 100644 --- a/src/main/java/land/oras/utils/Const.java +++ b/src/main/java/land/oras/utils/Const.java @@ -461,6 +461,11 @@ public static String currentTimestamp() { */ public static final String METRIC_HTTP_REQUESTS = "land.oras.http.client.requests"; + /** + * Metric name for HTTP retries + */ + public static final String METRIC_HTTP_RETRIES = "land.oras.http.client.retries"; + /** * Metric name for token refresh duration */ diff --git a/src/test/java/land/oras/RegistryWireMockTest.java b/src/test/java/land/oras/RegistryWireMockTest.java index 0dbea057..53993bcd 100644 --- a/src/test/java/land/oras/RegistryWireMockTest.java +++ b/src/test/java/land/oras/RegistryWireMockTest.java @@ -61,7 +61,6 @@ import land.oras.utils.Const; import land.oras.utils.JsonUtils; import land.oras.utils.SupportedAlgorithm; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Execution; @@ -548,42 +547,40 @@ void shouldHandleTimeout(WireMockRuntimeInfo wmRuntimeInfo) { assertEquals(408, exception.getStatusCode()); } - // Note: Currently this test is @Disabled because the retry functionality isn't implemented. - // remove the @Disabled annotation and the test should pass. @Test - @Disabled("Automatic retry not yet implemented in SDK") void shouldRetryBlobUpload(WireMockRuntimeInfo wmRuntimeInfo) throws IOException { WireMock wireMock = wmRuntimeInfo.getWireMock(); String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); - String uploadUrl = "/v2/library/artifact-text/blobs/uploads/"; + String uploadPath = "/v2/library/artifact-text/blobs/uploads/"; - // Setting up WireMock to simulate a failed first attempt - wireMock.register(WireMock.post(WireMock.urlEqualTo(uploadUrl)) + // HEAD: blob does not exist yet + wireMock.register(WireMock.head(WireMock.urlPathMatching("/v2/library/artifact-text/blobs/.*")) + .willReturn(WireMock.status(404))); + + // POST: first attempt fails with 500; second succeeds with 202 + Location + wireMock.register(WireMock.post(WireMock.urlPathMatching(uploadPath + ".*")) .inScenario("upload retry scenario") .whenScenarioStateIs(Scenario.STARTED) .willReturn(WireMock.serverError()) .willSetStateTo("retry")); - // Setting up WireMock for successful retry - wireMock.register(WireMock.post(WireMock.urlEqualTo(uploadUrl)) + wireMock.register(WireMock.post(WireMock.urlPathMatching(uploadPath + ".*")) .inScenario("upload retry scenario") .whenScenarioStateIs("retry") - .willReturn(WireMock.aResponse().withStatus(202).withHeader("Location", uploadUrl + "12345"))); + .willReturn(WireMock.aResponse().withStatus(202).withHeader("Location", uploadPath + "12345"))); wireMock.register( - WireMock.put(WireMock.urlMatching(uploadUrl + "12345.*")).willReturn(WireMock.created())); + WireMock.put(WireMock.urlPathMatching(uploadPath + "12345.*")).willReturn(WireMock.created())); - Registry registry = Registry.Builder.builder().withInsecure(true).build(); + Registry registry = + Registry.Builder.builder().withInsecure(true).withRetryDelay(0).build(); ContainerRef ref = ContainerRef.parse("%s/library/artifact-text".formatted(registryUrl)); Path testFile = configDir.resolve("test-data.temp"); Files.writeString(testFile, "Test Content"); try (InputStream inputStream = Files.newInputStream(testFile)) { - // when retry is implemented we dont want to throw an exception here as it will retry Layer layer = registry.pushBlob(ref, inputStream); - - // assertions will verify that the upload succeeded after retry assertNotNull(layer); assertNotNull(layer.getDigest()); } @@ -1147,6 +1144,183 @@ void pullArtifactShouldRejectInvalidTitleAnnotation(WireMockRuntimeInfo wmRuntim "Blob must not be written outside the output directory"); } + @Test + void shouldRetryOn429WithRetryAfterHeader(WireMockRuntimeInfo wmRuntimeInfo) { + WireMock wireMock = wmRuntimeInfo.getWireMock(); + String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + + // First call: 429 with Retry-After: 0 (immediate retry in tests) + wireMock.register(get(urlEqualTo("/v2/library/rate-limited-retry/tags/list")) + .inScenario("rate-limit-retry") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(aResponse() + .withStatus(429) + .withHeader("Retry-After", "0") + .withBody("Rate limited")) + .willSetStateTo("retry")); + + // Second call: success + wireMock.register(get(urlEqualTo("/v2/library/rate-limited-retry/tags/list")) + .inScenario("rate-limit-retry") + .whenScenarioStateIs("retry") + .willReturn(okJson(JsonUtils.toJson(new Tags("rate-limited-retry", List.of("latest")))))); + + Registry registry = Registry.Builder.builder() + .withInsecure(true) + .withRetryDelay(0) + .withMaxRetries(2) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/rate-limited-retry".formatted(registryUrl)); + Tags tags = registry.getTags(ref); + assertEquals(List.of("latest"), tags.tags()); + + // Verify the server was called exactly twice (one failure + one retry) + WireMock.verify(2, getRequestedFor(urlEqualTo("/v2/library/rate-limited-retry/tags/list"))); + } + + @Test + void shouldRetryOn429WithExponentialBackoff(WireMockRuntimeInfo wmRuntimeInfo) { + WireMock wireMock = wmRuntimeInfo.getWireMock(); + String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + + // Two 429 responses without Retry-After, then success + wireMock.register(get(urlEqualTo("/v2/library/rate-limited-backoff/tags/list")) + .inScenario("backoff-retry") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(aResponse().withStatus(429).withBody("Rate limited")) + .willSetStateTo("retry-1")); + + wireMock.register(get(urlEqualTo("/v2/library/rate-limited-backoff/tags/list")) + .inScenario("backoff-retry") + .whenScenarioStateIs("retry-1") + .willReturn(aResponse().withStatus(429).withBody("Rate limited")) + .willSetStateTo("retry-2")); + + wireMock.register(get(urlEqualTo("/v2/library/rate-limited-backoff/tags/list")) + .inScenario("backoff-retry") + .whenScenarioStateIs("retry-2") + .willReturn(okJson(JsonUtils.toJson(new Tags("rate-limited-backoff", List.of("v1")))))); + + Registry registry = Registry.Builder.builder() + .withInsecure(true) + .withRetryDelay(0) + .withMaxRetries(3) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/rate-limited-backoff".formatted(registryUrl)); + Tags tags = registry.getTags(ref); + assertEquals(List.of("v1"), tags.tags()); + + WireMock.verify(3, getRequestedFor(urlEqualTo("/v2/library/rate-limited-backoff/tags/list"))); + } + + @Test + void shouldRetryOnNetworkError(WireMockRuntimeInfo wmRuntimeInfo) { + WireMock wireMock = wmRuntimeInfo.getWireMock(); + String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + + // First call: connection reset (IOException); second: success + wireMock.register(get(urlEqualTo("/v2/library/network-error-retry/tags/list")) + .inScenario("network-error-retry") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(aResponse().withFault(com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER)) + .willSetStateTo("retry")); + + wireMock.register(get(urlEqualTo("/v2/library/network-error-retry/tags/list")) + .inScenario("network-error-retry") + .whenScenarioStateIs("retry") + .willReturn(okJson(JsonUtils.toJson(new Tags("network-error-retry", List.of("latest")))))); + + Registry registry = Registry.Builder.builder() + .withInsecure(true) + .withRetryDelay(0) + .withMaxRetries(2) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/network-error-retry".formatted(registryUrl)); + Tags tags = registry.getTags(ref); + assertEquals(List.of("latest"), tags.tags()); + + WireMock.verify(2, getRequestedFor(urlEqualTo("/v2/library/network-error-retry/tags/list"))); + } + + @Test + void shouldNotRetryOnClientError(WireMockRuntimeInfo wmRuntimeInfo) { + WireMock wireMock = wmRuntimeInfo.getWireMock(); + String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + + wireMock.register(get(urlEqualTo("/v2/library/not-found/tags/list")) + .willReturn(aResponse().withStatus(404).withBody("Not found"))); + + Registry registry = Registry.Builder.builder() + .withInsecure(true) + .withRetryDelay(0) + .withMaxRetries(3) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/not-found".formatted(registryUrl)); + OrasException exception = assertThrows(OrasException.class, () -> registry.getTags(ref)); + assertEquals(404, exception.getStatusCode()); + + // Must be called exactly once — no retry on 4xx + WireMock.verify(1, getRequestedFor(urlEqualTo("/v2/library/not-found/tags/list"))); + } + + @Test + void shouldExhaustRetriesAndThrow(WireMockRuntimeInfo wmRuntimeInfo) { + WireMock wireMock = wmRuntimeInfo.getWireMock(); + String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + + // Always return 500 + wireMock.register(get(urlEqualTo("/v2/library/always-fails/tags/list")) + .willReturn(aResponse().withStatus(500).withBody("Server error"))); + + Registry registry = Registry.Builder.builder() + .withInsecure(true) + .withRetryDelay(0) + .withMaxRetries(3) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/always-fails".formatted(registryUrl)); + assertThrows(OrasException.class, () -> registry.getTags(ref)); + + // 3 attempts total (initial + 2 retries) + WireMock.verify(3, getRequestedFor(urlEqualTo("/v2/library/always-fails/tags/list"))); + } + + @Test + void shouldNotRetryTokenRefreshRequest(WireMockRuntimeInfo wmRuntimeInfo) { + WireMock wireMock = wmRuntimeInfo.getWireMock(); + String registryUrl = wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + String digest = SupportedAlgorithm.SHA256.digest("blob-data".getBytes()); + + // Registry returns 401 to trigger token refresh + wireMock.register(get(urlPathEqualTo("/v2/library/no-retry-token/blobs/%s".formatted(digest))) + .willReturn(forbidden() + .withHeader( + Const.WWW_AUTHENTICATE_HEADER, + "Bearer realm=\"http://localhost:%d/token\",service=\"localhost\",scope=\"repository:library/no-retry-token:pull\"" + .formatted(wmRuntimeInfo.getHttpPort())))); + + // Token endpoint always fails with 500 + wireMock.register(get(urlPathEqualTo("/token")) + .willReturn(aResponse().withStatus(500).withBody("Token service unavailable"))); + + Registry registry = Registry.Builder.builder() + .withInsecure(true) + .withRetryDelay(0) + .withMaxRetries(3) + .build(); + + ContainerRef ref = + ContainerRef.parse("localhost:%d/library/no-retry-token".formatted(wmRuntimeInfo.getHttpPort())); + assertThrows(OrasException.class, () -> registry.getBlob(ref.withDigest(digest))); + + // Token endpoint must be called exactly once — no retry on token refresh + WireMock.verify(1, getRequestedFor(urlPathEqualTo("/token"))); + } + @Test void shouldFailChunkedUploadWhenInitiationReturnsNon202(WireMockRuntimeInfo wmRuntimeInfo) throws IOException { WireMock wireMock = wmRuntimeInfo.getWireMock(); diff --git a/src/test/resources/oci/artifact.tar b/src/test/resources/oci/artifact.tar index dff3d448..0601a3b7 100644 Binary files a/src/test/resources/oci/artifact.tar and b/src/test/resources/oci/artifact.tar differ diff --git a/src/test/resources/oci/subject.tar b/src/test/resources/oci/subject.tar index 271e24e1..6e0d56b3 100644 Binary files a/src/test/resources/oci/subject.tar and b/src/test/resources/oci/subject.tar differ