diff --git a/docs/documentation/server_admin/topics/authentication/x509.adoc b/docs/documentation/server_admin/topics/authentication/x509.adoc index f2a4ae0da14b..9d0ef68a3993 100644 --- a/docs/documentation/server_admin/topics/authentication/x509.adoc +++ b/docs/documentation/server_admin/topics/authentication/x509.adoc @@ -143,6 +143,8 @@ Checks the certificate revocation status by using Online Certificate Status Prot *OCSP Fail-Open Behavior*:: By default the OCSP check must return a positive response in order to continue with a successful authentication. Sometimes however this check can be inconclusive: for example, the OCSP server could be unreachable, overloaded, or the client certificate may not contain an OCSP responder URI. When this setting is turned ON, authentication will be denied only if an explicit negative response is received by the OCSP responder and the certificate is definitely revoked. If a valid OCSP response is not available the authentication attempt will be accepted. +NOTE: OCSP retry behavior is configured server-wide through the HTTP client provider. See <@links.server id="outgoinghttp"/> for details on configuring retry settings for all outgoing HTTP requests, including OCSP validation. + *OCSP Responder URI*:: Override the value of the OCSP responder URI in the certificate. diff --git a/docs/guides/server/outgoinghttp.adoc b/docs/guides/server/outgoinghttp.adoc index f7642c0ce4c3..8088c82ba01a 100644 --- a/docs/guides/server/outgoinghttp.adoc +++ b/docs/guides/server/outgoinghttp.adoc @@ -57,6 +57,44 @@ Specify proxy configurations for outgoing HTTP requests. For more details, see < *disable-trust-manager*:: If an outgoing request requires HTTPS and this configuration option is set to true, you do not have to specify a truststore. This setting should be used only during development and *never in production* because it will disable verification of SSL certificates. Default: false. +== Configuring retry behavior for outgoing HTTP requests +IMPORTANT: Do not let outgoing retry duration exceed the caller’s timeout. Otherwise, the caller may time out and see an error while {project_name} continues retrying in the background. + +{project_name} can automatically retry failed outgoing HTTP requests. This is useful for handling transient network errors or temporary service unavailability. Retry behavior is disabled by default and must be explicitly enabled. + +The following are the retry configuration options: + +*max-retries*:: +Maximum number of retry attempts for failed HTTP requests. Set to 0 to disable retries. Default: 0. + +*retry-on-error*:: +Whether to retry HTTP requests when errors occur. Default: true. + +*initial-backoff-millis*:: +Initial backoff time in milliseconds before the first retry attempt. Default: 1000. + +*backoff-multiplier*:: +Multiplier for exponential backoff between retry attempts. For example, with an initial backoff of 1000ms and a multiplier of 2.0, the retry delays would be: 1000ms, 2000ms, 4000ms, etc. Default: 2.0. + +*use-jitter*:: +Whether to apply jitter to backoff times to prevent synchronized retry storms when multiple clients are retrying at the same time. Default: true. + +*jitter-factor*:: +Jitter factor to apply to backoff times. A value of 0.5 means the actual backoff time will be between 50% and 150% of the calculated exponential backoff time. Default: 0.5. + +.Example of enabling retry behavior +[source,bash] +---- +bin/kc.[sh|bat] start --spi-connections-http-client-default-max-retries=3 \ + --spi-connections-http-client-default-retry-on-error=true \ + --spi-connections-http-client-default-initial-backoff-millis=1000 \ + --spi-connections-http-client-default-backoff-multiplier=2.0 +---- + +In this example, {project_name} will retry failed HTTP requests up to 3 times with exponential backoff starting at 1000ms and doubling with each retry attempt. + +NOTE: Retry behavior applies to all outgoing HTTP requests made by {project_name}, including OCSP validation, identity provider communication, and other external service calls. + == Proxy mappings for outgoing HTTP requests To configure outgoing requests to use a proxy, you can use the following standard proxy environment variables to configure the proxy mappings: `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. diff --git a/server-spi-private/src/main/java/org/keycloak/connections/httpclient/RetryConfig.java b/server-spi-private/src/main/java/org/keycloak/connections/httpclient/RetryConfig.java new file mode 100644 index 000000000000..f13732490904 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/connections/httpclient/RetryConfig.java @@ -0,0 +1,377 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.connections.httpclient; + +/** + * Configuration for HTTP client retry behavior. + *

+ * This class provides configuration options for HTTP client retry behavior when + * making requests. It allows customization of the maximum number of retry + * attempts, whether to retry on IO exceptions, exponential backoff settings, + * jitter for backoff times, and connection/socket timeouts. + *

+ * The default configuration is 0 retry attempts (no retries) with retries + * enabled for IO exceptions if configured, with exponential backoff starting + * at 1000ms and multiplying by 2.0 for each retry. Jitter is enabled by + * default with a factor of 0.5 to prevent synchronized retry storms. + *

+ * This configuration is used internally by {@link HttpClientProvider#getHttpClient()} + * to create HTTP clients with server-wide retry capabilities. The configuration + * is set globally through the HTTP client provider SPI configuration. + *

+ * Server-wide SPI properties for configuring retry behavior: + *

+ *

+ * Example configuration: + * + *

+ * spi-connections-http-client-default-max-retries=3
+ * spi-connections-http-client-default-initial-backoff-millis=1000
+ * spi-connections-http-client-default-backoff-multiplier=2.0
+ * 
+ * + * This configuration applies to all outgoing HTTP requests from Keycloak, + * including OCSP validation, identity provider communication, and other + * external HTTP calls. + */ +public class RetryConfig { + private final int maxRetries; + private final boolean retryOnIOException; + private final long initialBackoffMillis; + private final double backoffMultiplier; + private final boolean useJitter; + private final double jitterFactor; + private final int connectionTimeoutMillis; + private final int socketTimeoutMillis; + + private RetryConfig(Builder builder) { + this.maxRetries = builder.maxRetries; + this.retryOnIOException = builder.retryOnIOException; + this.initialBackoffMillis = builder.initialBackoffMillis; + this.backoffMultiplier = builder.backoffMultiplier; + this.useJitter = builder.useJitter; + this.jitterFactor = builder.jitterFactor; + this.connectionTimeoutMillis = builder.connectionTimeoutMillis; + this.socketTimeoutMillis = builder.socketTimeoutMillis; + } + + /** + * Gets the maximum number of retry attempts. + * + * @return The maximum number of retry attempts + */ + public int getMaxRetries() { + return maxRetries; + } + + /** + * Determines whether to retry on IO exceptions. + * + * @return {@code true} if retries should be attempted on IO exceptions, + * {@code false} otherwise + */ + public boolean isRetryOnIOException() { + return retryOnIOException; + } + + /** + * Gets the initial backoff time in milliseconds before the first retry attempt. + * + * @return The initial backoff time in milliseconds + */ + public long getInitialBackoffMillis() { + return initialBackoffMillis; + } + + /** + * Gets the multiplier used for exponential backoff between retry attempts. + * + * @return The backoff multiplier + */ + public double getBackoffMultiplier() { + return backoffMultiplier; + } + + /** + * Gets the connection timeout in milliseconds. + * + * @return The connection timeout in milliseconds + */ + public int getConnectionTimeoutMillis() { + return connectionTimeoutMillis; + } + + /** + * Gets the socket timeout in milliseconds. + * + * @return The socket timeout in milliseconds + */ + public int getSocketTimeoutMillis() { + return socketTimeoutMillis; + } + + /** + * Determines whether to apply jitter to backoff times. + *

+ * Jitter adds randomness to backoff times to prevent synchronized retry storms + * when multiple clients are retrying at the same time. + * + * @return {@code true} if jitter should be applied, {@code false} otherwise + */ + public boolean isUseJitter() { + return useJitter; + } + + /** + * Gets the jitter factor to apply to backoff times. + *

+ * The jitter factor determines how much randomness to apply to the backoff + * time. + * A value of 0.5 means the actual backoff time will be between 50% and 150% of + * the calculated exponential backoff time. + * + * @return The jitter factor + */ + public double getJitterFactor() { + return jitterFactor; + } + + /** + * Compares this RetryConfig with another object for equality. + *

+ * Two RetryConfig objects are considered equal if all their configuration + * parameters match exactly. + * + * @param obj The object to compare with + * @return {@code true} if the objects are equal, {@code false} otherwise + */ + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + RetryConfig that = (RetryConfig) obj; + + if (maxRetries != that.maxRetries) return false; + if (retryOnIOException != that.retryOnIOException) return false; + if (initialBackoffMillis != that.initialBackoffMillis) return false; + if (Double.compare(backoffMultiplier, that.backoffMultiplier) != 0) return false; + if (useJitter != that.useJitter) return false; + if (Double.compare(jitterFactor, that.jitterFactor) != 0) return false; + if (connectionTimeoutMillis != that.connectionTimeoutMillis) return false; + return socketTimeoutMillis == that.socketTimeoutMillis; + } + + /** + * Returns a hash code value for this RetryConfig. + *

+ * This method is implemented to be consistent with {@link #equals(Object)}, + * ensuring that equal RetryConfig objects have the same hash code. + * + * @return A hash code value for this object + */ + @Override + public int hashCode() { + int result; + long temp; + result = maxRetries; + result = 31 * result + (retryOnIOException ? 1 : 0); + result = 31 * result + (int) (initialBackoffMillis ^ (initialBackoffMillis >>> 32)); + temp = Double.doubleToLongBits(backoffMultiplier); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + result = 31 * result + (useJitter ? 1 : 0); + temp = Double.doubleToLongBits(jitterFactor); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + result = 31 * result + connectionTimeoutMillis; + result = 31 * result + socketTimeoutMillis; + return result; + } + + /** + * Builder for creating {@link RetryConfig} instances. + *

+ * This builder uses the following defaults: + *

+ */ + public static class Builder { + private int maxRetries = 0; + private boolean retryOnIOException = true; + private long initialBackoffMillis = 1000; + private double backoffMultiplier = 2.0; + private boolean useJitter = true; + private double jitterFactor = 0.5; + private int connectionTimeoutMillis = 10000; + private int socketTimeoutMillis = 10000; + + /** + * Sets the maximum number of retry attempts. + *

+ * The default value is 3. A value of 0 means no retries will be attempted. + * Negative values are allowed but not recommended as they don't make practical + * sense. + * + * @param maxRetries The maximum number of retry attempts + * @return This builder instance for method chaining + */ + public Builder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + /** + * Sets whether to retry on IO exceptions. + *

+ * The default value is {@code true}. When set to {@code false}, the client will + * not + * retry requests that fail with IO exceptions. + * + * @param retryOnIOException {@code true} to retry on IO exceptions, + * {@code false} otherwise + * @return This builder instance for method chaining + */ + public Builder retryOnIOException(boolean retryOnIOException) { + this.retryOnIOException = retryOnIOException; + return this; + } + + /** + * Sets the initial backoff time in milliseconds before the first retry attempt. + *

+ * The default value is 1000 (1 second). This is the amount of time to wait + * before + * the first retry attempt. Subsequent retry attempts will use exponential + * backoff + * based on this value and the backoff multiplier. + * + * @param initialBackoffMillis The initial backoff time in milliseconds + * @return This builder instance for method chaining + */ + public Builder initialBackoffMillis(long initialBackoffMillis) { + this.initialBackoffMillis = initialBackoffMillis; + return this; + } + + /** + * Sets the multiplier used for exponential backoff between retry attempts. + *

+ * The default value is 2.0. This means that each retry will wait twice as long + * as + * the previous retry. For example, with an initial backoff of 1000ms and a + * multiplier + * of 2.0, the retry delays would be: 1000ms, 2000ms, 4000ms, etc. + * + * @param backoffMultiplier The backoff multiplier + * @return This builder instance for method chaining + */ + public Builder backoffMultiplier(double backoffMultiplier) { + this.backoffMultiplier = backoffMultiplier; + return this; + } + + /** + * Sets the connection timeout in milliseconds. + *

+ * The default value is 10000 (10 seconds). This is the timeout for establishing + * a connection with the remote server. + * + * @param connectionTimeoutMillis The connection timeout in milliseconds + * @return This builder instance for method chaining + */ + public Builder connectionTimeoutMillis(int connectionTimeoutMillis) { + this.connectionTimeoutMillis = connectionTimeoutMillis; + return this; + } + + /** + * Sets the socket timeout in milliseconds. + *

+ * The default value is 10000 (10 seconds). This is the timeout for waiting for + * data + * from an established connection. + * + * @param socketTimeoutMillis The socket timeout in milliseconds + * @return This builder instance for method chaining + */ + public Builder socketTimeoutMillis(int socketTimeoutMillis) { + this.socketTimeoutMillis = socketTimeoutMillis; + return this; + } + + /** + * Sets whether to apply jitter to backoff times. + *

+ * The default value is {@code true}. When set to {@code true}, the system will + * add + * randomness to backoff times to prevent synchronized retry storms when + * multiple + * clients are retrying at the same time. + * + * @param useJitter {@code true} to apply jitter, {@code false} otherwise + * @return This builder instance for method chaining + */ + public Builder useJitter(boolean useJitter) { + this.useJitter = useJitter; + return this; + } + + /** + * Sets the jitter factor to apply to backoff times. + *

+ * The default value is 0.5. This means the actual backoff time will be between + * 50% and 150% of the calculated exponential backoff time. For example, if the + * calculated backoff time is 1000ms, the actual backoff time will be between + * 500ms and 1500ms. + * + * @param jitterFactor The jitter factor + * @return This builder instance for method chaining + */ + public Builder jitterFactor(double jitterFactor) { + this.jitterFactor = jitterFactor; + return this; + } + + /** + * Builds a new {@link RetryConfig} instance with the current builder settings. + * + * @return A new {@link RetryConfig} instance + */ + public RetryConfig build() { + return new RetryConfig(this); + } + } +} \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/utils/OCSPProvider.java b/server-spi-private/src/main/java/org/keycloak/utils/OCSPProvider.java index 09a0a7588c64..577dca612758 100644 --- a/server-spi-private/src/main/java/org/keycloak/utils/OCSPProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/utils/OCSPProvider.java @@ -30,7 +30,6 @@ import java.util.List; import org.apache.http.HttpHeaders; -import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ByteArrayEntity; @@ -52,7 +51,6 @@ public abstract class OCSPProvider { private final static Logger logger = Logger.getLogger(OCSPProvider.class); - protected static int OCSP_CONNECT_TIMEOUT = 10000; // 10 sec protected static final int TIME_SKEW = 900000; public enum RevocationStatus { @@ -126,13 +124,6 @@ protected byte[] getEncodedOCSPResponse(KeycloakSession session, byte[] encodedO CloseableHttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); HttpPost post = new HttpPost(responderUri); post.setHeader(HttpHeaders.CONTENT_TYPE, "application/ocsp-request"); - - final RequestConfig params = RequestConfig.custom() - .setConnectTimeout(OCSP_CONNECT_TIMEOUT) - .setSocketTimeout(OCSP_CONNECT_TIMEOUT) - .build(); - post.setConfig(params); - post.setEntity(new ByteArrayEntity(encodedOCSPReq)); //Get Response diff --git a/server-spi-private/src/test/java/org/keycloak/connections/httpclient/RetryConfigTest.java b/server-spi-private/src/test/java/org/keycloak/connections/httpclient/RetryConfigTest.java new file mode 100644 index 000000000000..254fbe056596 --- /dev/null +++ b/server-spi-private/src/test/java/org/keycloak/connections/httpclient/RetryConfigTest.java @@ -0,0 +1,214 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.connections.httpclient; + +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * Comprehensive tests for RetryConfig class. + */ +public class RetryConfigTest { + + @Test + public void testDefaultValues() { + RetryConfig config = new RetryConfig.Builder().build(); + assertEquals(0, config.getMaxRetries()); + assertTrue(config.isRetryOnIOException()); + assertEquals(1000, config.getInitialBackoffMillis()); + assertEquals(2.0, config.getBackoffMultiplier(), 0.001); + assertEquals(10000, config.getConnectionTimeoutMillis()); + assertEquals(10000, config.getSocketTimeoutMillis()); + } + + @Test + public void testCustomValues() { + RetryConfig config = new RetryConfig.Builder() + .maxRetries(5) + .retryOnIOException(false) + .initialBackoffMillis(2000) + .backoffMultiplier(3.0) + .connectionTimeoutMillis(15000) + .socketTimeoutMillis(20000) + .build(); + + assertEquals(5, config.getMaxRetries()); + assertFalse(config.isRetryOnIOException()); + assertEquals(2000, config.getInitialBackoffMillis()); + assertEquals(3.0, config.getBackoffMultiplier(), 0.001); + assertEquals(15000, config.getConnectionTimeoutMillis()); + assertEquals(20000, config.getSocketTimeoutMillis()); + } + + @Test + public void testZeroRetries() { + RetryConfig config = new RetryConfig.Builder() + .maxRetries(0) + .build(); + + assertEquals(0, config.getMaxRetries()); + assertTrue(config.isRetryOnIOException()); + } + + @Test + public void testNegativeRetries() { + // Negative values should be allowed (though they don't make practical sense) + // This tests that the builder doesn't enforce any validation + RetryConfig config = new RetryConfig.Builder() + .maxRetries(-1) + .build(); + + assertEquals(-1, config.getMaxRetries()); + } + + @Test + public void testLargeNumberOfRetries() { + // Test with a large number of retries + RetryConfig config = new RetryConfig.Builder() + .maxRetries(Integer.MAX_VALUE) + .build(); + + assertEquals(Integer.MAX_VALUE, config.getMaxRetries()); + } + + @Test + public void testBuilderChaining() { + // Test that builder methods can be chained + RetryConfig config = new RetryConfig.Builder() + .maxRetries(10) + .retryOnIOException(false) + .build(); + + assertEquals(10, config.getMaxRetries()); + assertFalse(config.isRetryOnIOException()); + } + + @Test + public void testBuilderOverriding() { + // Test that later builder calls override earlier ones + RetryConfig config = new RetryConfig.Builder() + .maxRetries(5) + .maxRetries(10) + .retryOnIOException(true) + .retryOnIOException(false) + .initialBackoffMillis(500) + .initialBackoffMillis(1500) + .backoffMultiplier(1.5) + .backoffMultiplier(2.5) + .connectionTimeoutMillis(5000) + .connectionTimeoutMillis(8000) + .socketTimeoutMillis(12000) + .socketTimeoutMillis(15000) + .build(); + + assertEquals(10, config.getMaxRetries()); + assertFalse(config.isRetryOnIOException()); + assertEquals(1500, config.getInitialBackoffMillis()); + assertEquals(2.5, config.getBackoffMultiplier(), 0.001); + assertEquals(8000, config.getConnectionTimeoutMillis()); + assertEquals(15000, config.getSocketTimeoutMillis()); + } + + @Test + public void testExponentialBackoffSettings() { + // Test specific exponential backoff settings + RetryConfig config = new RetryConfig.Builder() + .initialBackoffMillis(100) // Very short initial backoff + .backoffMultiplier(4.0) // Aggressive multiplier + .build(); + + assertEquals(100, config.getInitialBackoffMillis()); + assertEquals(4.0, config.getBackoffMultiplier(), 0.001); + } + + @Test + public void testTimeoutSettings() { + // Test specific timeout settings + RetryConfig config = new RetryConfig.Builder() + .connectionTimeoutMillis(30000) // 30 seconds connection timeout + .socketTimeoutMillis(60000) // 60 seconds socket timeout + .build(); + + assertEquals(30000, config.getConnectionTimeoutMillis()); + assertEquals(60000, config.getSocketTimeoutMillis()); + } + + @Test + public void testJitterSettings() { + // Test jitter settings + RetryConfig config = new RetryConfig.Builder() + .useJitter(true) + .jitterFactor(0.3) + .build(); + + assertTrue(config.isUseJitter()); + assertEquals(0.3, config.getJitterFactor(), 0.001); + } + + @Test + public void testDisableJitter() { + // Test disabling jitter + RetryConfig config = new RetryConfig.Builder() + .useJitter(false) + .build(); + + assertFalse(config.isUseJitter()); + assertEquals(0.5, config.getJitterFactor(), 0.001); // Default value should still be set + } + + @Test + public void testEqualsWithSameValues() { + RetryConfig config1 = new RetryConfig.Builder() + .maxRetries(5) + .retryOnIOException(false) + .initialBackoffMillis(2000) + .backoffMultiplier(3.0) + .useJitter(true) + .jitterFactor(0.5) + .connectionTimeoutMillis(15000) + .socketTimeoutMillis(20000) + .build(); + + RetryConfig config2 = new RetryConfig.Builder() + .maxRetries(5) + .retryOnIOException(false) + .initialBackoffMillis(2000) + .backoffMultiplier(3.0) + .useJitter(true) + .jitterFactor(0.5) + .connectionTimeoutMillis(15000) + .socketTimeoutMillis(20000) + .build(); + + assertEquals(config1, config2); + assertEquals(config2, config1); + assertEquals(config1.hashCode(), config2.hashCode()); + } + + @Test + public void testEqualsWithNull() { + RetryConfig config = new RetryConfig.Builder().build(); + assertNotEquals(config, null); + } + + @Test + public void testEqualsSameInstance() { + RetryConfig config = new RetryConfig.Builder().build(); + assertEquals(config, config); + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java b/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java index 6c87b731bac7..b91e748efff5 100755 --- a/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java +++ b/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java @@ -243,12 +243,62 @@ private void lazyInit(KeycloakSession session) { throw new RuntimeException("Failed to load keystore", e); } } + + // Configure retry behavior + configureRetries(builder); + httpClient = builder.build(); } } } } + /** + * Configures retry behavior for the HTTP client builder. + * Applies server-wide retry configuration if enabled. + * + * @param builder The HTTP client builder to configure + */ + private void configureRetries(HttpClientBuilder builder) { + int maxRetries = config.getInt("max-retries", 0); + if (maxRetries <= 0) { + return; // Retries disabled + } + + boolean retryOnError = config.getBoolean("retry-on-error", true); + long initialBackoffMillis = config.getLong("initial-backoff-millis", 1000L); + String backoffMultiplierStr = config.get("backoff-multiplier", "2.0"); + double backoffMultiplier = Double.parseDouble(backoffMultiplierStr); + boolean useJitter = config.getBoolean("use-jitter", true); + String jitterFactorStr = config.get("jitter-factor", "0.5"); + double jitterFactor = Double.parseDouble(jitterFactorStr); + + builder.getApacheHttpClientBuilder().setRetryHandler( + new org.apache.http.impl.client.DefaultHttpRequestRetryHandler(maxRetries, retryOnError) { + @Override + public boolean retryRequest(IOException exception, int executionCount, + org.apache.http.protocol.HttpContext context) { + boolean shouldRetry = super.retryRequest(exception, executionCount, context); + if (shouldRetry) { + try { + long baseDelay = initialBackoffMillis * + (long)Math.pow(backoffMultiplier, executionCount - 1); + long delay = baseDelay; + if (useJitter) { + double jitter = 1.0 - jitterFactor + + (Math.random() * jitterFactor * 2.0); + delay = (long)(baseDelay * jitter); + } + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return shouldRetry; + } + }); + } + protected HttpClientBuilder newHttpClientBuilder() { return new HttpClientBuilder(); } @@ -342,6 +392,45 @@ public List getConfigMetadata() { .helpText("Maximum size of a response consumed by the client (to prevent denial of service)") .defaultValue(HttpClientProvider.DEFAULT_MAX_CONSUMED_RESPONSE_SIZE) .add() + .property() + .name("max-retries") + .type("int") + .helpText("Maximum number of retry attempts for all outgoing HTTP requests. Set to 0 to disable retries (default).") + .defaultValue(0) + .add() + .property() + .name("retry-on-error") + .type("boolean") + .helpText("Whether to retry HTTP requests on errors.") + .defaultValue(true) + .add() + .property() + .name("initial-backoff-millis") + .type("long") + .helpText("Initial backoff time in milliseconds before the first retry attempt.") + .defaultValue(1000L) + .add() + .property() + .name("backoff-multiplier") + .type("string") + .helpText( + "Multiplier for exponential backoff between retry attempts. For example, with an initial backoff of 1000ms and a multiplier of 2.0, the retry delays would be: 1000ms, 2000ms, 4000ms, etc.") + .defaultValue("2.0") + .add() + .property() + .name("use-jitter") + .type("boolean") + .helpText( + "Whether to apply jitter to backoff times to prevent synchronized retry storms when multiple clients are retrying at the same time.") + .defaultValue(true) + .add() + .property() + .name("jitter-factor") + .type("string") + .helpText( + "Jitter factor to apply to backoff times. A value of 0.5 means the actual backoff time will be between 50% and 150% of the calculated exponential backoff time.") + .defaultValue("0.5") + .add() .build(); } diff --git a/services/src/test/java/org/keycloak/connections/httpclient/DefaultHttpClientFactoryTest.java b/services/src/test/java/org/keycloak/connections/httpclient/DefaultHttpClientFactoryTest.java index 168de62e3016..086fadc3a6cb 100644 --- a/services/src/test/java/org/keycloak/connections/httpclient/DefaultHttpClientFactoryTest.java +++ b/services/src/test/java/org/keycloak/connections/httpclient/DefaultHttpClientFactoryTest.java @@ -42,29 +42,53 @@ public class DefaultHttpClientFactoryTest { private static final String DISABLE_TRUST_MANAGER_PROPERTY = "disable-trust-manager"; private static final String TEST_DOMAIN = "keycloak.org"; + private static final String MAX_RETRIES_PROPERTY = "max-retries"; + private static final String RETRY_ON_ERROR_PROPERTY = "retry-on-error"; + + // Common objects for tests + private DefaultHttpClientFactory factory; + private KeycloakSession session; + + /** + * Helper method to create and initialize factory with default settings + */ + private HttpClientProvider createDefaultProvider() { + factory = new DefaultHttpClientFactory(); + factory.init(ScopeUtil.createScope(new HashMap<>())); + session = new ResteasyKeycloakSession(new ResteasyKeycloakSessionFactory()); + return factory.create(session); + } + + /** + * Helper method to create and initialize factory with custom settings + */ + private HttpClientProvider createProviderWithProperties(Map values) { + factory = new DefaultHttpClientFactory(); + factory.init(ScopeUtil.createScope(values)); + session = new ResteasyKeycloakSession(new ResteasyKeycloakSessionFactory()); + return factory.create(session); + } @Test - public void createHttpClientProviderWithDisableTrustManager() throws IOException{ + public void createHttpClientProviderWithDisableTrustManager() throws IOException { + // Create provider with trust manager disabled Map values = new HashMap<>(); values.put(DISABLE_TRUST_MANAGER_PROPERTY, "true"); - DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - factory.init(ScopeUtil.createScope(values)); - KeycloakSession session = new ResteasyKeycloakSession(new ResteasyKeycloakSessionFactory()); - HttpClientProvider provider = factory.create(session); - Optional testURL = getTestURL(); - Assume.assumeTrue( "Could not get test url for domain", testURL.isPresent() ); + HttpClientProvider provider = createProviderWithProperties(values); + + Optional testURL = getTestURL(); + Assume.assumeTrue("Could not get test url for domain", testURL.isPresent()); try (CloseableHttpClient httpClient = provider.getHttpClient(); - CloseableHttpResponse response = httpClient.execute(new HttpGet(testURL.get()))) { - assertEquals(HttpStatus.SC_NOT_FOUND,response.getStatusLine().getStatusCode()); + CloseableHttpResponse response = httpClient.execute(new HttpGet(testURL.get()))) { + assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatusLine().getStatusCode()); } } @Test(expected = SSLPeerUnverifiedException.class) public void createHttpClientProviderWithUnvailableURL() throws IOException { - DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - factory.init(ScopeUtil.createScope(new HashMap<>())); - KeycloakSession session = new ResteasyKeycloakSession(new ResteasyKeycloakSessionFactory()); - HttpClientProvider provider = factory.create(session); + // Create provider with default settings + HttpClientProvider provider = createDefaultProvider(); + try (CloseableHttpClient httpClient = provider.getHttpClient()) { Optional testURL = getTestURL(); Assume.assumeTrue("Could not get test url for domain", testURL.isPresent()); @@ -72,6 +96,37 @@ public void createHttpClientProviderWithUnvailableURL() throws IOException { } } + @Test + public void testGetHttpClientWithRetries() throws IOException { + // Create provider with retry config + Map values = new HashMap<>(); + values.put(MAX_RETRIES_PROPERTY, "3"); + HttpClientProvider provider = createProviderWithProperties(values); + + // Get HTTP client (now has retry built-in) + CloseableHttpClient client = provider.getHttpClient(); + + // Verify client is not null + org.junit.Assert.assertNotNull("HTTP client should not be null", client); + } + + @Test + public void testFactoryInitWithRetryProperties() { + // Create factory with custom retry properties + Map values = new HashMap<>(); + values.put(MAX_RETRIES_PROPERTY, "5"); + values.put(RETRY_ON_ERROR_PROPERTY, "false"); + + // Create provider with custom properties + HttpClientProvider provider = createProviderWithProperties(values); + + // Get HTTP client + CloseableHttpClient client = provider.getHttpClient(); + + // Verify client is not null + org.junit.Assert.assertNotNull("HTTP client should not be null", client); + } + private Optional getTestURL() { try { // Convert domain name to ip to make request by ip