diff --git a/samples/src/main/java/com/example/vault/TimeoutAndRetryConfigExample.java b/samples/src/main/java/com/example/vault/TimeoutAndRetryConfigExample.java new file mode 100644 index 00000000..20d2f711 --- /dev/null +++ b/samples/src/main/java/com/example/vault/TimeoutAndRetryConfigExample.java @@ -0,0 +1,74 @@ +package com.example.vault; + +import com.skyflow.Skyflow; +import com.skyflow.config.Credentials; +import com.skyflow.config.VaultConfig; +import com.skyflow.enums.Env; +import com.skyflow.enums.LogLevel; +import com.skyflow.errors.SkyflowException; + +/** + * This sample demonstrates how to configure HTTP timeout and retry behavior in the Skyflow Java SDK. + * + *

Configurable settings (all optional): + *

+ * + *

Two levels + precedence: set client-wide defaults on {@code Skyflow.builder()}, and/or + * per-vault overrides on {@code VaultConfig}. The most specific value wins, resolved per field: + * per-vault → client-wide → SDK default. + */ +public class TimeoutAndRetryConfigExample { + + public static void main(String[] args) { + try { + // Step 1: Initialize credentials with the path to your service account key file + String filePath = ""; + Credentials credentials = new Credentials(); + credentials.setPath(filePath); + + // Step 2: Configure the vault. Optionally override timeout/retry settings for THIS vault only. + VaultConfig vaultConfig = new VaultConfig(); + vaultConfig.setVaultId(""); + vaultConfig.setClusterId(""); + vaultConfig.setEnv(Env.PROD); + vaultConfig.setCredentials(credentials); + // Per-vault overrides (optional). Any field left unset inherits the client-wide default below, + // and then the SDK default. + vaultConfig.setTimeout(30); // seconds – tighter overall ceiling for this vault + vaultConfig.setMaxRetries(2); // fewer retries for this vault + vaultConfig.setInitialRetryDelayMillis(500L); + vaultConfig.setMaxRetryDelayMillis(1000L); + + // Step 3: Create the Skyflow client. Client-wide defaults apply to every vault + // unless that vault overrides them (as above). + Skyflow skyflowClient = Skyflow.builder() + .setLogLevel(LogLevel.ERROR) + .timeout(60) // seconds – client-wide overall call timeout + .maxRetries(3) // client-wide retry attempts + .initialRetryDelayMillis(500L) // client-wide base backoff (ms) + .maxRetryDelayMillis(2000L) // client-wide backoff cap (ms) + .addVaultConfig(vaultConfig) + .build(); + + // Step 4: Use the client as usual. Requests now fail fast at the configured timeout and + // retry transient 408/429/5xx responses with exponential backoff + jitter. + System.out.println("Skyflow client configured with custom timeout & retry settings: " + skyflowClient); + + // Example (uncomment and fill in a real request to try it): + // DetokenizeResponse response = skyflowClient.vault().detokenize(detokenizeRequest); + // System.out.println(response); + } catch (SkyflowException e) { + // Step 5: Handle any errors that occur during the process + System.err.println("Error in Skyflow operations: " + e.getMessage()); + } + } +} diff --git a/v3/src/main/java/com/skyflow/Skyflow.java b/v3/src/main/java/com/skyflow/Skyflow.java index db42fe85..df530215 100644 --- a/v3/src/main/java/com/skyflow/Skyflow.java +++ b/v3/src/main/java/com/skyflow/Skyflow.java @@ -55,6 +55,11 @@ public VaultController vault() throws SkyflowException { public static final class SkyflowClientBuilder extends BaseSkyflowClientBuilder { private final LinkedHashMap vaultConfigMap; private final LinkedHashMap vaultClientsMap; + // Client-wide HTTP config defaults (apply to all vaults unless a vault overrides). null => SDK default. + private Integer timeout; + private Integer maxRetries; + private Long initialRetryDelayMillis; + private Long maxRetryDelayMillis; public SkyflowClientBuilder() { this.vaultConfigMap = new LinkedHashMap<>(); @@ -78,7 +83,9 @@ public SkyflowClientBuilder addVaultConfig(VaultConfig vaultConfig) throws Skyfl ErrorMessage.VaultIdAlreadyInConfigList.getMessage()); } else { this.vaultConfigMap.put(vaultConfigCopy.getVaultId(), vaultConfigCopy); // add new config in map - this.vaultClientsMap.put(vaultConfigCopy.getVaultId(), new VaultController(vaultConfigCopy, this.skyflowCredentials)); // add new controller with new config + VaultController controller = new VaultController(vaultConfigCopy, this.skyflowCredentials); // new controller with new config + controller.setCommonHttpConfig(this.timeout, this.maxRetries, this.initialRetryDelayMillis, this.maxRetryDelayMillis); + this.vaultClientsMap.put(vaultConfigCopy.getVaultId(), controller); LogUtil.printInfoLog(Utils.parameterizedString( InfoLogs.VAULT_CONTROLLER_INITIALIZED.getLog(), vaultConfigCopy.getVaultId())); } @@ -100,6 +107,40 @@ public SkyflowClientBuilder addSkyflowCredentials(Credentials credentials) throw return this; } + /** Client-wide overall call timeout in seconds. Applies to all vaults unless a vault overrides it. */ + public SkyflowClientBuilder timeout(int timeout) { + this.timeout = timeout; + propagateHttpConfig(); + return this; + } + + /** Client-wide retry attempt count. Applies to all vaults unless a vault overrides it. */ + public SkyflowClientBuilder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + propagateHttpConfig(); + return this; + } + + /** Client-wide base retry backoff in milliseconds. Applies to all vaults unless a vault overrides it. */ + public SkyflowClientBuilder initialRetryDelayMillis(long initialRetryDelayMillis) { + this.initialRetryDelayMillis = initialRetryDelayMillis; + propagateHttpConfig(); + return this; + } + + /** Client-wide retry backoff cap in milliseconds. Applies to all vaults unless a vault overrides it. */ + public SkyflowClientBuilder maxRetryDelayMillis(long maxRetryDelayMillis) { + this.maxRetryDelayMillis = maxRetryDelayMillis; + propagateHttpConfig(); + return this; + } + + private void propagateHttpConfig() { + for (VaultController vault : this.vaultClientsMap.values()) { + vault.setCommonHttpConfig(this.timeout, this.maxRetries, this.initialRetryDelayMillis, this.maxRetryDelayMillis); + } + } + @Override public SkyflowClientBuilder setLogLevel(LogLevel logLevel) { super.setLogLevel(logLevel); diff --git a/v3/src/main/java/com/skyflow/VaultClient.java b/v3/src/main/java/com/skyflow/VaultClient.java index f7319d29..72d983b2 100644 --- a/v3/src/main/java/com/skyflow/VaultClient.java +++ b/v3/src/main/java/com/skyflow/VaultClient.java @@ -20,6 +20,7 @@ import com.skyflow.logs.InfoLogs; import com.skyflow.serviceaccount.util.Token; import com.skyflow.utils.Constants; +import com.skyflow.utils.SkyflowRetryInterceptor; import com.skyflow.utils.Utils; import com.skyflow.utils.logger.LogUtil; import com.skyflow.utils.validations.Validations; @@ -45,6 +46,16 @@ public class VaultClient { private String apiKey; private OkHttpClient sharedHttpClient = null; private String currentVaultURL = null; + // Client-wide (Skyflow builder) HTTP config defaults; null => fall back to the SDK defaults below. + private Integer commonTimeout; + private Integer commonMaxRetries; + private Long commonInitialRetryDelayMillis; + private Long commonMaxRetryDelayMillis; + // SDK defaults, used when neither the vault-level nor the client-wide value is set. + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final int DEFAULT_MAX_RETRIES = 3; + private static final long DEFAULT_INITIAL_RETRY_DELAY_MILLIS = 500L; + private static final long DEFAULT_MAX_RETRY_DELAY_MILLIS = 2000L; protected VaultClient(VaultConfig vaultConfig, Credentials credentials) throws SkyflowException { super(); @@ -68,6 +79,20 @@ protected void setCommonCredentials(Credentials commonCredentials) throws Skyflo prioritiseCredentials(); } + /** + * Client-wide HTTP timeout/retry defaults from the Skyflow builder. Nulls out the cached client + * so the next call rebuilds with the new values. + */ + protected void setCommonHttpConfig(Integer timeout, Integer maxRetries, + Long initialRetryDelayMillis, Long maxRetryDelayMillis) { + this.commonTimeout = timeout; + this.commonMaxRetries = maxRetries; + this.commonInitialRetryDelayMillis = initialRetryDelayMillis; + this.commonMaxRetryDelayMillis = maxRetryDelayMillis; + this.sharedHttpClient = null; + this.apiClient = null; + } + protected void setBearerToken() throws SkyflowException { prioritiseCredentials(); Validations.validateCredentials(this.finalCredentials); @@ -143,9 +168,18 @@ private void prioritiseCredentials() throws SkyflowException { protected void updateExecutorInHTTP() { if (sharedHttpClient == null) { + int timeoutSeconds = resolveInt(vaultConfig.getTimeout(), commonTimeout, DEFAULT_TIMEOUT_SECONDS); + int maxRetries = resolveInt(vaultConfig.getMaxRetries(), commonMaxRetries, DEFAULT_MAX_RETRIES); + long initialRetryDelayMillis = resolveLong( + vaultConfig.getInitialRetryDelayMillis(), commonInitialRetryDelayMillis, DEFAULT_INITIAL_RETRY_DELAY_MILLIS); + long maxRetryDelayMillis = resolveLong( + vaultConfig.getMaxRetryDelayMillis(), commonMaxRetryDelayMillis, DEFAULT_MAX_RETRY_DELAY_MILLIS); + sharedHttpClient = new OkHttpClient.Builder() .connectionPool(new ConnectionPool(10, 1, TimeUnit.MINUTES)) - .addInterceptor(chain -> { + .callTimeout(timeoutSeconds, TimeUnit.SECONDS) // overall ceiling; bounds the whole call incl. retries + .addInterceptor(new SkyflowRetryInterceptor(maxRetries, initialRetryDelayMillis, maxRetryDelayMillis)) // OUTER: retries + .addInterceptor(chain -> { // INNER: auth (reads this.token per request) Request requestWithAuth = chain.request().newBuilder() .header("Authorization", "Bearer " + this.token) .build(); @@ -156,6 +190,22 @@ protected void updateExecutorInHTTP() { } } + /** Resolve an int setting: vault-level override, else client-wide default, else SDK default. */ + private static int resolveInt(Integer vaultLevel, Integer clientLevel, int defaultValue) { + if (vaultLevel != null) { + return vaultLevel; + } + return clientLevel != null ? clientLevel : defaultValue; + } + + /** Resolve a long setting: vault-level override, else client-wide default, else SDK default. */ + private static long resolveLong(Long vaultLevel, Long clientLevel, long defaultValue) { + if (vaultLevel != null) { + return vaultLevel; + } + return clientLevel != null ? clientLevel : defaultValue; + } + protected V1InsertRequest getBulkInsertRequestBody(com.skyflow.vault.data.InsertRequest request, VaultConfig config) { ArrayList records = request.getRecords(); List insertRecordDataList = new ArrayList<>(); diff --git a/v3/src/main/java/com/skyflow/config/VaultConfig.java b/v3/src/main/java/com/skyflow/config/VaultConfig.java index 23ce7a51..29c7f02c 100644 --- a/v3/src/main/java/com/skyflow/config/VaultConfig.java +++ b/v3/src/main/java/com/skyflow/config/VaultConfig.java @@ -8,6 +8,11 @@ public class VaultConfig implements Cloneable { private String vaultURL; private Env env; private Credentials credentials; + // HTTP timeout & retry config (vault-level overrides). null => inherit client-wide default, then SDK default. + private Integer timeout; // overall call timeout, in seconds + private Integer maxRetries; // retry attempts after the first failure + private Long initialRetryDelayMillis; // base backoff before the first retry, in ms + private Long maxRetryDelayMillis; // cap on the (exponentially growing) backoff, in ms public VaultConfig() { this.vaultId = null; @@ -15,6 +20,10 @@ public VaultConfig() { this.vaultURL = null; this.env = Env.PROD; this.credentials = null; + this.timeout = null; + this.maxRetries = null; + this.initialRetryDelayMillis = null; + this.maxRetryDelayMillis = null; } public String getVaultId() { @@ -57,6 +66,42 @@ public void setVaultURL(String vaultURL) { this.vaultURL = vaultURL; } + public Integer getTimeout() { + return timeout; + } + + /** Overall call timeout in seconds for this vault. Overrides the client-wide default. */ + public void setTimeout(Integer timeout) { + this.timeout = timeout; + } + + public Integer getMaxRetries() { + return maxRetries; + } + + /** Number of retry attempts after the first failure for this vault. Overrides the client-wide default. */ + public void setMaxRetries(Integer maxRetries) { + this.maxRetries = maxRetries; + } + + public Long getInitialRetryDelayMillis() { + return initialRetryDelayMillis; + } + + /** Base retry backoff in milliseconds for this vault. Overrides the client-wide default. */ + public void setInitialRetryDelayMillis(Long initialRetryDelayMillis) { + this.initialRetryDelayMillis = initialRetryDelayMillis; + } + + public Long getMaxRetryDelayMillis() { + return maxRetryDelayMillis; + } + + /** Cap on retry backoff in milliseconds for this vault. Overrides the client-wide default. */ + public void setMaxRetryDelayMillis(Long maxRetryDelayMillis) { + this.maxRetryDelayMillis = maxRetryDelayMillis; + } + @Override public Object clone() throws CloneNotSupportedException { VaultConfig cloned = (VaultConfig) super.clone(); diff --git a/v3/src/main/java/com/skyflow/utils/SkyflowRetryInterceptor.java b/v3/src/main/java/com/skyflow/utils/SkyflowRetryInterceptor.java new file mode 100644 index 00000000..f93507c0 --- /dev/null +++ b/v3/src/main/java/com/skyflow/utils/SkyflowRetryInterceptor.java @@ -0,0 +1,85 @@ +package com.skyflow.utils; + +import java.io.IOException; +import java.util.Random; +import okhttp3.Interceptor; +import okhttp3.Response; + +/** + * Retry interceptor for the v3 SDK's shared {@link okhttp3.OkHttpClient}. + * + *

Replicates Fern's retry algorithm (exponential backoff + proportional jitter) so behavior + * stays familiar, while exposing {@code maxRetries}, {@code initialRetryDelayMillis} and + * {@code maxRetryDelayMillis} as configurable knobs. The jitter factor is fixed at 0.2 (±10%) and + * intentionally not exposed. Retries fire on HTTP 408 / 429 / 5xx responses only (same triggers as + * Fern); it does not retry connection failures / {@link IOException}. + * + *

Why this is hand-written instead of reusing Fern's generated {@code RetryInterceptor}: + * the SDK injects its own {@code OkHttpClient} (for the auth header + connection pool), so Fern's + * generated interceptor is never wired by {@code ClientOptions.build()} (it is only added on the + * no-custom-client branch); and the currently vendored Fern interceptor hardcodes its backoff + * (only {@code maxRetries} is configurable), so it cannot honor the delay knobs above. See + * {@code docs/superpowers/specs/2026-07-21-CUST-4311-v3-configurable-timeouts-retries-design.md}. + * + *

TODO(CUST-4311): replace this class with Fern's generated {@code RetryInterceptor} + * once the SDK is regenerated with a Fern version whose interceptor (a) exposes + * {@code initialRetryDelayMillis} / {@code maxRetryDelayMillis} and (b) is constructable + * standalone so it can be attached to our custom client. Target Fern/generator version: v_____ + * (fill in when known). + */ +public final class SkyflowRetryInterceptor implements Interceptor { + + /** Fixed jitter factor (±10%). Intentionally not exposed as a public knob (matches Fern's default). */ + private static final double JITTER_FACTOR = 0.2; + + private final int maxRetries; + private final long initialRetryDelayMillis; + private final long maxRetryDelayMillis; + private final Random random = new Random(); + + public SkyflowRetryInterceptor(int maxRetries, long initialRetryDelayMillis, long maxRetryDelayMillis) { + this.maxRetries = maxRetries; + this.initialRetryDelayMillis = initialRetryDelayMillis; + this.maxRetryDelayMillis = maxRetryDelayMillis; + } + + @Override + public Response intercept(Chain chain) throws IOException { + Response response = chain.proceed(chain.request()); + + int attempt = 0; + while (shouldRetry(response.code()) && attempt < maxRetries) { + attempt++; + sleep(backoffMillis(attempt)); + response.close(); // release the failed response/connection before retrying + response = chain.proceed(chain.request()); + } + return response; + } + + /** + * Exponential backoff, capped, then proportional jitter (same shape as Fern): + * {@code base(n) = min(initialRetryDelayMillis * 2^(n-1), maxRetryDelayMillis)}, + * {@code delay = base * (1 + (rand - 0.5) * JITTER_FACTOR)} — i.e. {@code base * [0.9, 1.1]}. + * Jitter is applied after the cap, so the returned delay can exceed {@code maxRetryDelayMillis} + * by up to the jitter factor. + */ + private long backoffMillis(int attempt) { + double base = Math.min(initialRetryDelayMillis * Math.pow(2, attempt - 1), maxRetryDelayMillis); + double jitter = 1.0 + ((random.nextDouble() - 0.5) * JITTER_FACTOR); + return (long) (base * jitter); + } + + private static boolean shouldRetry(int statusCode) { + return statusCode == 408 || statusCode == 429 || statusCode >= 500; + } + + private void sleep(long millis) throws IOException { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted during retry backoff", e); + } + } +} diff --git a/v3/src/test/java/com/skyflow/SkyflowClientBuilderHttpConfigTests.java b/v3/src/test/java/com/skyflow/SkyflowClientBuilderHttpConfigTests.java new file mode 100644 index 00000000..1d738266 --- /dev/null +++ b/v3/src/test/java/com/skyflow/SkyflowClientBuilderHttpConfigTests.java @@ -0,0 +1,72 @@ +package com.skyflow; + +import com.skyflow.config.VaultConfig; +import com.skyflow.enums.Env; +import com.skyflow.vault.controller.VaultController; +import java.lang.reflect.Field; +import org.junit.Assert; +import org.junit.Test; + +/** + * Verifies the client-wide HTTP config setters on {@code Skyflow.builder()} store their values and + * propagate to the vault controller(s) — both when set before {@code addVaultConfig} and after + * (which exercises the propagation loop over already-created controllers). + */ +public class SkyflowClientBuilderHttpConfigTests { + + private VaultConfig vaultConfig() { + VaultConfig cfg = new VaultConfig(); + cfg.setVaultId("test_vault_id"); + cfg.setClusterId("test_cluster_id"); + cfg.setEnv(Env.PROD); + return cfg; + } + + private Object commonField(VaultController controller, String name) throws Exception { + Field field = VaultClient.class.getDeclaredField(name); // fields declared on VaultClient superclass + field.setAccessible(true); + return field.get(controller); + } + + @Test + public void clientWideConfigSetBeforeAddVaultReachesController() throws Exception { + Skyflow client = Skyflow.builder() + .timeout(30) + .maxRetries(2) + .initialRetryDelayMillis(300L) + .maxRetryDelayMillis(1500L) + .addVaultConfig(vaultConfig()) + .build(); + + VaultController controller = client.vault(); + Assert.assertEquals(Integer.valueOf(30), commonField(controller, "commonTimeout")); + Assert.assertEquals(Integer.valueOf(2), commonField(controller, "commonMaxRetries")); + Assert.assertEquals(Long.valueOf(300L), commonField(controller, "commonInitialRetryDelayMillis")); + Assert.assertEquals(Long.valueOf(1500L), commonField(controller, "commonMaxRetryDelayMillis")); + } + + @Test + public void clientWideConfigSetAfterAddVaultPropagatesToExistingController() throws Exception { + Skyflow.SkyflowClientBuilder builder = Skyflow.builder().addVaultConfig(vaultConfig()); + // Set config AFTER the controller already exists -> exercises propagateHttpConfig's loop. + builder.timeout(45).maxRetries(4).initialRetryDelayMillis(700L).maxRetryDelayMillis(3000L); + Skyflow client = builder.build(); + + VaultController controller = client.vault(); + Assert.assertEquals(Integer.valueOf(45), commonField(controller, "commonTimeout")); + Assert.assertEquals(Integer.valueOf(4), commonField(controller, "commonMaxRetries")); + Assert.assertEquals(Long.valueOf(700L), commonField(controller, "commonInitialRetryDelayMillis")); + Assert.assertEquals(Long.valueOf(3000L), commonField(controller, "commonMaxRetryDelayMillis")); + } + + @Test + public void noClientWideConfigLeavesCommonFieldsNull() throws Exception { + Skyflow client = Skyflow.builder().addVaultConfig(vaultConfig()).build(); + + VaultController controller = client.vault(); + Assert.assertNull(commonField(controller, "commonTimeout")); + Assert.assertNull(commonField(controller, "commonMaxRetries")); + Assert.assertNull(commonField(controller, "commonInitialRetryDelayMillis")); + Assert.assertNull(commonField(controller, "commonMaxRetryDelayMillis")); + } +} diff --git a/v3/src/test/java/com/skyflow/VaultClientHttpConfigTests.java b/v3/src/test/java/com/skyflow/VaultClientHttpConfigTests.java new file mode 100644 index 00000000..dab77ad7 --- /dev/null +++ b/v3/src/test/java/com/skyflow/VaultClientHttpConfigTests.java @@ -0,0 +1,169 @@ +package com.skyflow; + +import com.skyflow.config.Credentials; +import com.skyflow.config.VaultConfig; +import com.skyflow.enums.Env; +import com.skyflow.errors.SkyflowException; +import com.skyflow.utils.SkyflowRetryInterceptor; +import java.lang.reflect.Field; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import org.junit.Assert; +import org.junit.Test; + +/** + * Verifies the HTTP timeout/retry config is wired onto the shared OkHttpClient and that the + * timeout precedence (vault-level > client-wide > SDK default) resolves correctly. Uses an API-key + * credential so {@code setBearerToken()} builds the client without a network call. + */ +public class VaultClientHttpConfigTests { + + private static final int DEFAULT_CALL_TIMEOUT_MS = 60000; // SDK default = 60s + + private VaultConfig apiKeyConfig() { + VaultConfig cfg = new VaultConfig(); + cfg.setVaultId("vault123"); + cfg.setClusterId("cluster123"); + cfg.setEnv(Env.PROD); + Credentials credentials = new Credentials(); + credentials.setApiKey("sky-ab123-abcd1234cdef1234abcd4321cdef4321"); + cfg.setCredentials(credentials); + return cfg; + } + + private OkHttpClient sharedClient(VaultClient client) throws Exception { + Field field = VaultClient.class.getDeclaredField("sharedHttpClient"); + field.setAccessible(true); + return (OkHttpClient) field.get(client); + } + + @Test + public void defaultTimeoutAppliedWhenNothingConfigured() throws Exception { + VaultConfig cfg = apiKeyConfig(); + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setBearerToken(); + + Assert.assertEquals(DEFAULT_CALL_TIMEOUT_MS, sharedClient(client).callTimeoutMillis()); + } + + @Test + public void vaultLevelTimeoutOverridesDefault() throws Exception { + VaultConfig cfg = apiKeyConfig(); + cfg.setTimeout(15); // seconds + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setBearerToken(); + + Assert.assertEquals(15000, sharedClient(client).callTimeoutMillis()); + } + + @Test + public void clientLevelTimeoutUsedWhenNoVaultOverride() throws Exception { + VaultConfig cfg = apiKeyConfig(); + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setCommonHttpConfig(45, null, null, null); // client-wide 45s, no vault override + client.setBearerToken(); + + Assert.assertEquals(45000, sharedClient(client).callTimeoutMillis()); + } + + @Test + public void vaultLevelTimeoutOverridesClientLevel() throws Exception { + VaultConfig cfg = apiKeyConfig(); + cfg.setTimeout(15); // vault-level + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setCommonHttpConfig(45, null, null, null); // client-wide 45s — should lose to vault's 15s + client.setBearerToken(); + + Assert.assertEquals(15000, sharedClient(client).callTimeoutMillis()); + } + + @Test + public void callTimeoutIsBoundedNotZero() throws Exception { + // Regression guard: the real bug was callTimeout == 0 (unbounded). It must never be 0 by default. + VaultConfig cfg = apiKeyConfig(); + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setBearerToken(); + + Assert.assertTrue(sharedClient(client).callTimeoutMillis() > 0); + } + + @Test + public void vaultConfigStoresHttpFieldsAndDefaultsToNull() throws SkyflowException { + VaultConfig cfg = new VaultConfig(); + Assert.assertNull(cfg.getTimeout()); + Assert.assertNull(cfg.getMaxRetries()); + Assert.assertNull(cfg.getInitialRetryDelayMillis()); + Assert.assertNull(cfg.getMaxRetryDelayMillis()); + + cfg.setTimeout(30); + cfg.setMaxRetries(2); + cfg.setInitialRetryDelayMillis(250L); + cfg.setMaxRetryDelayMillis(1500L); + + Assert.assertEquals(Integer.valueOf(30), cfg.getTimeout()); + Assert.assertEquals(Integer.valueOf(2), cfg.getMaxRetries()); + Assert.assertEquals(Long.valueOf(250L), cfg.getInitialRetryDelayMillis()); + Assert.assertEquals(Long.valueOf(1500L), cfg.getMaxRetryDelayMillis()); + } + + private SkyflowRetryInterceptor retryInterceptor(VaultClient client) throws Exception { + for (Interceptor interceptor : sharedClient(client).interceptors()) { + if (interceptor instanceof SkyflowRetryInterceptor) { + return (SkyflowRetryInterceptor) interceptor; + } + } + throw new AssertionError("SkyflowRetryInterceptor was not attached to the shared client"); + } + + private int intField(Object obj, String name) throws Exception { + Field field = obj.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.getInt(obj); + } + + private long longField(Object obj, String name) throws Exception { + Field field = obj.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.getLong(obj); + } + + @Test + public void defaultRetryConfigAppliedToInterceptor() throws Exception { + VaultConfig cfg = apiKeyConfig(); + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setBearerToken(); + + SkyflowRetryInterceptor ri = retryInterceptor(client); + Assert.assertEquals(3, intField(ri, "maxRetries")); // SDK default + Assert.assertEquals(500L, longField(ri, "initialRetryDelayMillis")); + Assert.assertEquals(2000L, longField(ri, "maxRetryDelayMillis")); + } + + @Test + public void vaultLevelRetryConfigOverridesDefault() throws Exception { + VaultConfig cfg = apiKeyConfig(); + cfg.setMaxRetries(1); + cfg.setInitialRetryDelayMillis(250L); + cfg.setMaxRetryDelayMillis(1200L); + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setBearerToken(); + + SkyflowRetryInterceptor ri = retryInterceptor(client); + Assert.assertEquals(1, intField(ri, "maxRetries")); + Assert.assertEquals(250L, longField(ri, "initialRetryDelayMillis")); + Assert.assertEquals(1200L, longField(ri, "maxRetryDelayMillis")); + } + + @Test + public void clientLevelRetryConfigUsedWhenNoVaultOverride() throws Exception { + VaultConfig cfg = apiKeyConfig(); + VaultClient client = new VaultClient(cfg, cfg.getCredentials()); + client.setCommonHttpConfig(null, 2, 300L, 1500L); // client-wide retry config, no vault override + client.setBearerToken(); + + SkyflowRetryInterceptor ri = retryInterceptor(client); + Assert.assertEquals(2, intField(ri, "maxRetries")); + Assert.assertEquals(300L, longField(ri, "initialRetryDelayMillis")); + Assert.assertEquals(1500L, longField(ri, "maxRetryDelayMillis")); + } +} diff --git a/v3/src/test/java/com/skyflow/utils/SkyflowRetryInterceptorTests.java b/v3/src/test/java/com/skyflow/utils/SkyflowRetryInterceptorTests.java new file mode 100644 index 00000000..0213acd1 --- /dev/null +++ b/v3/src/test/java/com/skyflow/utils/SkyflowRetryInterceptorTests.java @@ -0,0 +1,133 @@ +package com.skyflow.utils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class SkyflowRetryInterceptorTests { + + private Request request; + + @Before + public void setUp() { + request = new Request.Builder().url("https://example.com").build(); + } + + private Response response(int code) { + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("mock") + .body(ResponseBody.create("", (MediaType) null)) + .build(); + } + + // Tiny backoff so tests stay fast (1-2ms sleeps). + private SkyflowRetryInterceptor interceptor(int maxRetries) { + return new SkyflowRetryInterceptor(maxRetries, 1L, 2L); + } + + @Test + public void retriesUpToMaxRetriesThenReturnsLastResponse() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + // always 503 -> initial attempt + 2 retries = 3 proceeds + when(chain.proceed(any(Request.class))).thenReturn(response(503), response(503), response(503)); + + Response result = interceptor(2).intercept(chain); + + verify(chain, times(3)).proceed(any(Request.class)); + Assert.assertEquals(503, result.code()); + } + + @Test + public void stopsRetryingOnFirstSuccess() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + when(chain.proceed(any(Request.class))).thenReturn(response(503), response(200)); + + Response result = interceptor(2).intercept(chain); + + verify(chain, times(2)).proceed(any(Request.class)); // 1 retry, then success + Assert.assertEquals(200, result.code()); + } + + @Test + public void doesNotRetryNonRetryableStatus() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + when(chain.proceed(any(Request.class))).thenReturn(response(400)); + + Response result = interceptor(2).intercept(chain); + + verify(chain, times(1)).proceed(any(Request.class)); // no retry on 400 + Assert.assertEquals(400, result.code()); + } + + @Test + public void retriesOn429() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + when(chain.proceed(any(Request.class))).thenReturn(response(429), response(200)); + + Response result = interceptor(2).intercept(chain); + + verify(chain, times(2)).proceed(any(Request.class)); + Assert.assertEquals(200, result.code()); + } + + @Test + public void retriesOn408() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + when(chain.proceed(any(Request.class))).thenReturn(response(408), response(200)); + + Response result = interceptor(2).intercept(chain); + + verify(chain, times(2)).proceed(any(Request.class)); + Assert.assertEquals(200, result.code()); + } + + @Test + public void zeroMaxRetriesMeansSingleAttempt() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + when(chain.proceed(any(Request.class))).thenReturn(response(503)); + + Response result = interceptor(0).intercept(chain); + + verify(chain, times(1)).proceed(any(Request.class)); // no retries + Assert.assertEquals(503, result.code()); + } + + @Test + public void interruptedDuringBackoffThrowsIOExceptionAndPreservesInterruptFlag() throws IOException { + Interceptor.Chain chain = mock(Interceptor.Chain.class); + when(chain.request()).thenReturn(request); + when(chain.proceed(any(Request.class))).thenReturn(response(503), response(200)); + + // Pre-set the interrupt flag so the backoff Thread.sleep throws InterruptedException immediately. + Thread.currentThread().interrupt(); + try { + interceptor(2).intercept(chain); + Assert.fail("expected IOException when interrupted during backoff"); + } catch (IOException expected) { + // Interrupt status must be restored by the interceptor (Thread.interrupted() reads and clears it). + Assert.assertTrue("interrupt flag should be preserved", Thread.interrupted()); + } + } +}