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 = " 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());
+ }
+ }
+}