")
.upsert(upsertColumns)
- .upsertType(UpsertType.UPDATE)
+ // upsertType is optional; when omitted the vault defaults to UpsertType.UPDATE.
+ // Set .upsertType(UpsertType.REPLACE) to replace the matched row instead.
.build();
// Step 5: Prepare second record for insertion
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..eb4bf1e9
--- /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):
+ *
+ * - {@code timeout} – overall call timeout in seconds (bounds the whole
+ * request including retries and backoff). Default: 60.
+ * - {@code maxRetries} – retry attempts after the first failure (retries on HTTP
+ * 408 / 429 / 5xx). Default: 0 — retries are OFF unless you set this (avoids auto-retrying non-idempotent writes).
+ * - {@code initialRetryDelayMillis} – base backoff before the first retry, in milliseconds.
+ * Default: 500.
+ * - {@code maxRetryDelayMillis} – cap on the (exponentially growing) backoff, in
+ * milliseconds. Default: 2000.
+ *
+ *
+ * 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/pom.xml b/v3/pom.xml
index 8d0f10de..b0a3be70 100644
--- a/v3/pom.xml
+++ b/v3/pom.xml
@@ -11,7 +11,7 @@
skyflow-java
- 3.0.0-beta.11
+ 2.1.1-dev.3196451
jar
${project.groupId}:${project.artifactId}
Skyflow V3 SDK for the Java programming language
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..b06f2bf6 100644
--- a/v3/src/main/java/com/skyflow/VaultClient.java
+++ b/v3/src/main/java/com/skyflow/VaultClient.java
@@ -2,6 +2,7 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
import com.skyflow.config.Credentials;
@@ -19,6 +20,7 @@
import com.skyflow.generated.rest.types.V1Upsert;
import com.skyflow.logs.InfoLogs;
import com.skyflow.serviceaccount.util.Token;
+import com.skyflow.generated.rest.core.RetryInterceptor;
import com.skyflow.utils.Constants;
import com.skyflow.utils.Utils;
import com.skyflow.utils.logger.LogUtil;
@@ -45,6 +47,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 = 0; // retries OFF by default (opt-in) so non-idempotent writes aren't auto-retried
+ 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 +80,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 +169,19 @@ 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 RetryInterceptor( // OUTER: retries (Fern generated; jitter default 0.2)
+ maxRetries, Optional.of(initialRetryDelayMillis), Optional.of(maxRetryDelayMillis), Optional.empty()))
+ .addInterceptor(chain -> { // INNER: auth (reads this.token per request)
Request requestWithAuth = chain.request().newBuilder()
.header("Authorization", "Bearer " + this.token)
.build();
@@ -156,6 +192,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/generated/rest/ApiClientBuilder.java b/v3/src/main/java/com/skyflow/generated/rest/ApiClientBuilder.java
index fa3d6e9d..b2c312d7 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/ApiClientBuilder.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/ApiClientBuilder.java
@@ -5,13 +5,31 @@
import com.skyflow.generated.rest.core.ClientOptions;
import com.skyflow.generated.rest.core.Environment;
+import com.skyflow.generated.rest.core.LogConfig;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
import okhttp3.OkHttpClient;
-public final class ApiClientBuilder {
- private ClientOptions.Builder clientOptionsBuilder = ClientOptions.builder();
+public class ApiClientBuilder {
+ private Optional timeout = Optional.empty();
+
+ private Optional maxRetries = Optional.empty();
+
+ private Optional initialRetryDelayMillis = Optional.empty();
+
+ private Optional maxRetryDelayMillis = Optional.empty();
+
+ private Optional retryJitterFactor = Optional.empty();
+
+ private final Map customHeaders = new HashMap<>();
private Environment environment;
+ private OkHttpClient httpClient;
+
+ private Optional logging = Optional.empty();
+
public ApiClientBuilder url(String url) {
this.environment = Environment.custom(url);
return this;
@@ -21,7 +39,7 @@ public ApiClientBuilder url(String url) {
* Sets the timeout (in seconds) for the client. Defaults to 60 seconds.
*/
public ApiClientBuilder timeout(int timeout) {
- this.clientOptionsBuilder.timeout(timeout);
+ this.timeout = Optional.of(timeout);
return this;
}
@@ -29,7 +47,31 @@ public ApiClientBuilder timeout(int timeout) {
* Sets the maximum number of retries for the client. Defaults to 2 retries.
*/
public ApiClientBuilder maxRetries(int maxRetries) {
- this.clientOptionsBuilder.maxRetries(maxRetries);
+ this.maxRetries = Optional.of(maxRetries);
+ return this;
+ }
+
+ /**
+ * Sets the initial delay (in milliseconds) used for exponential backoff between retries. Defaults to 1000 milliseconds.
+ */
+ public ApiClientBuilder initialRetryDelayMillis(long initialRetryDelayMillis) {
+ this.initialRetryDelayMillis = Optional.of(initialRetryDelayMillis);
+ return this;
+ }
+
+ /**
+ * Sets the maximum delay (in milliseconds) between retries. Defaults to 60000 milliseconds.
+ */
+ public ApiClientBuilder maxRetryDelayMillis(long maxRetryDelayMillis) {
+ this.maxRetryDelayMillis = Optional.of(maxRetryDelayMillis);
+ return this;
+ }
+
+ /**
+ * Sets the jitter factor (between 0 and 1) applied to retry delays. Defaults to 0.2.
+ */
+ public ApiClientBuilder retryJitterFactor(double retryJitterFactor) {
+ this.retryJitterFactor = Optional.of(retryJitterFactor);
return this;
}
@@ -37,12 +79,150 @@ public ApiClientBuilder maxRetries(int maxRetries) {
* Sets the underlying OkHttp client
*/
public ApiClientBuilder httpClient(OkHttpClient httpClient) {
- this.clientOptionsBuilder.httpClient(httpClient);
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ /**
+ * Configure logging for the SDK. Silent by default — no log output unless explicitly configured.
+ */
+ public ApiClientBuilder logging(LogConfig logging) {
+ this.logging = Optional.of(logging);
+ return this;
+ }
+
+ /**
+ * Add a custom header to be sent with all requests.
+ * For headers that need to be computed dynamically or conditionally, use the setAdditional() method override instead.
+ *
+ * @param name The header name
+ * @param value The header value
+ * @return This builder for method chaining
+ */
+ public ApiClientBuilder addHeader(String name, String value) {
+ this.customHeaders.put(name, value);
return this;
}
+ protected ClientOptions buildClientOptions() {
+ ClientOptions.Builder builder = ClientOptions.builder();
+ setEnvironment(builder);
+ setHttpClient(builder);
+ setTimeouts(builder);
+ setRetries(builder);
+ setLogging(builder);
+ for (Map.Entry header : this.customHeaders.entrySet()) {
+ builder.addHeader(header.getKey(), header.getValue());
+ }
+ setAdditional(builder);
+ return builder.build();
+ }
+
+ /**
+ * Sets the environment configuration for the client.
+ * Override this method to modify URLs or add environment-specific logic.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setEnvironment(ClientOptions.Builder builder) {
+ builder.environment(this.environment);
+ }
+
+ /**
+ * Sets the request timeout configuration.
+ * Override this method to customize timeout behavior.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setTimeouts(ClientOptions.Builder builder) {
+ if (this.timeout.isPresent()) {
+ builder.timeout(this.timeout.get());
+ }
+ }
+
+ /**
+ * Sets the retry configuration for failed requests.
+ * Override this method to implement custom retry strategies.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setRetries(ClientOptions.Builder builder) {
+ if (this.maxRetries.isPresent()) {
+ builder.maxRetries(this.maxRetries.get());
+ }
+ if (this.initialRetryDelayMillis.isPresent()) {
+ builder.initialRetryDelayMillis(this.initialRetryDelayMillis.get());
+ }
+ if (this.maxRetryDelayMillis.isPresent()) {
+ builder.maxRetryDelayMillis(this.maxRetryDelayMillis.get());
+ }
+ if (this.retryJitterFactor.isPresent()) {
+ builder.retryJitterFactor(this.retryJitterFactor.get());
+ }
+ }
+
+ /**
+ * Sets the OkHttp client configuration.
+ * Override this method to customize HTTP client behavior (interceptors, connection pools, etc).
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setHttpClient(ClientOptions.Builder builder) {
+ if (this.httpClient != null) {
+ builder.httpClient(this.httpClient);
+ }
+ }
+
+ /**
+ * Sets the logging configuration for the SDK.
+ * Override this method to customize logging behavior.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setLogging(ClientOptions.Builder builder) {
+ if (this.logging.isPresent()) {
+ builder.logging(this.logging.get());
+ }
+ }
+
+ /**
+ * Override this method to add any additional configuration to the client.
+ * This method is called at the end of the configuration chain, allowing you to add
+ * custom headers, modify settings, or perform any other client customization.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ *
+ * Example:
+ * {@code
+ * @Override
+ * protected void setAdditional(ClientOptions.Builder builder) {
+ * builder.addHeader("X-Request-ID", () -> UUID.randomUUID().toString());
+ * builder.addHeader("X-Client-Version", "1.0.0");
+ * }
+ * }
+ */
+ protected void setAdditional(ClientOptions.Builder builder) {}
+
+ /**
+ * Override this method to add custom validation logic before the client is built.
+ * This method is called at the beginning of the build() method to ensure the configuration is valid.
+ * Throw an exception to prevent client creation if validation fails.
+ *
+ * Example:
+ * {@code
+ * @Override
+ * protected void validateConfiguration() {
+ * super.validateConfiguration(); // Run parent validations
+ * if (tenantId == null || tenantId.isEmpty()) {
+ * throw new IllegalStateException("tenantId is required");
+ * }
+ * }
+ * }
+ */
+ protected void validateConfiguration() {}
+
public ApiClient build() {
- clientOptionsBuilder.environment(this.environment);
- return new ApiClient(clientOptionsBuilder.build());
+ validateConfiguration();
+ return new ApiClient(buildClientOptions());
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/AsyncApiClientBuilder.java b/v3/src/main/java/com/skyflow/generated/rest/AsyncApiClientBuilder.java
index 10c08d51..30c3dfac 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/AsyncApiClientBuilder.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/AsyncApiClientBuilder.java
@@ -5,13 +5,31 @@
import com.skyflow.generated.rest.core.ClientOptions;
import com.skyflow.generated.rest.core.Environment;
+import com.skyflow.generated.rest.core.LogConfig;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
import okhttp3.OkHttpClient;
-public final class AsyncApiClientBuilder {
- private ClientOptions.Builder clientOptionsBuilder = ClientOptions.builder();
+public class AsyncApiClientBuilder {
+ private Optional timeout = Optional.empty();
+
+ private Optional maxRetries = Optional.empty();
+
+ private Optional initialRetryDelayMillis = Optional.empty();
+
+ private Optional maxRetryDelayMillis = Optional.empty();
+
+ private Optional retryJitterFactor = Optional.empty();
+
+ private final Map customHeaders = new HashMap<>();
private Environment environment;
+ private OkHttpClient httpClient;
+
+ private Optional logging = Optional.empty();
+
public AsyncApiClientBuilder url(String url) {
this.environment = Environment.custom(url);
return this;
@@ -21,7 +39,7 @@ public AsyncApiClientBuilder url(String url) {
* Sets the timeout (in seconds) for the client. Defaults to 60 seconds.
*/
public AsyncApiClientBuilder timeout(int timeout) {
- this.clientOptionsBuilder.timeout(timeout);
+ this.timeout = Optional.of(timeout);
return this;
}
@@ -29,7 +47,31 @@ public AsyncApiClientBuilder timeout(int timeout) {
* Sets the maximum number of retries for the client. Defaults to 2 retries.
*/
public AsyncApiClientBuilder maxRetries(int maxRetries) {
- this.clientOptionsBuilder.maxRetries(maxRetries);
+ this.maxRetries = Optional.of(maxRetries);
+ return this;
+ }
+
+ /**
+ * Sets the initial delay (in milliseconds) used for exponential backoff between retries. Defaults to 1000 milliseconds.
+ */
+ public AsyncApiClientBuilder initialRetryDelayMillis(long initialRetryDelayMillis) {
+ this.initialRetryDelayMillis = Optional.of(initialRetryDelayMillis);
+ return this;
+ }
+
+ /**
+ * Sets the maximum delay (in milliseconds) between retries. Defaults to 60000 milliseconds.
+ */
+ public AsyncApiClientBuilder maxRetryDelayMillis(long maxRetryDelayMillis) {
+ this.maxRetryDelayMillis = Optional.of(maxRetryDelayMillis);
+ return this;
+ }
+
+ /**
+ * Sets the jitter factor (between 0 and 1) applied to retry delays. Defaults to 0.2.
+ */
+ public AsyncApiClientBuilder retryJitterFactor(double retryJitterFactor) {
+ this.retryJitterFactor = Optional.of(retryJitterFactor);
return this;
}
@@ -37,12 +79,150 @@ public AsyncApiClientBuilder maxRetries(int maxRetries) {
* Sets the underlying OkHttp client
*/
public AsyncApiClientBuilder httpClient(OkHttpClient httpClient) {
- this.clientOptionsBuilder.httpClient(httpClient);
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ /**
+ * Configure logging for the SDK. Silent by default — no log output unless explicitly configured.
+ */
+ public AsyncApiClientBuilder logging(LogConfig logging) {
+ this.logging = Optional.of(logging);
+ return this;
+ }
+
+ /**
+ * Add a custom header to be sent with all requests.
+ * For headers that need to be computed dynamically or conditionally, use the setAdditional() method override instead.
+ *
+ * @param name The header name
+ * @param value The header value
+ * @return This builder for method chaining
+ */
+ public AsyncApiClientBuilder addHeader(String name, String value) {
+ this.customHeaders.put(name, value);
return this;
}
+ protected ClientOptions buildClientOptions() {
+ ClientOptions.Builder builder = ClientOptions.builder();
+ setEnvironment(builder);
+ setHttpClient(builder);
+ setTimeouts(builder);
+ setRetries(builder);
+ setLogging(builder);
+ for (Map.Entry header : this.customHeaders.entrySet()) {
+ builder.addHeader(header.getKey(), header.getValue());
+ }
+ setAdditional(builder);
+ return builder.build();
+ }
+
+ /**
+ * Sets the environment configuration for the client.
+ * Override this method to modify URLs or add environment-specific logic.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setEnvironment(ClientOptions.Builder builder) {
+ builder.environment(this.environment);
+ }
+
+ /**
+ * Sets the request timeout configuration.
+ * Override this method to customize timeout behavior.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setTimeouts(ClientOptions.Builder builder) {
+ if (this.timeout.isPresent()) {
+ builder.timeout(this.timeout.get());
+ }
+ }
+
+ /**
+ * Sets the retry configuration for failed requests.
+ * Override this method to implement custom retry strategies.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setRetries(ClientOptions.Builder builder) {
+ if (this.maxRetries.isPresent()) {
+ builder.maxRetries(this.maxRetries.get());
+ }
+ if (this.initialRetryDelayMillis.isPresent()) {
+ builder.initialRetryDelayMillis(this.initialRetryDelayMillis.get());
+ }
+ if (this.maxRetryDelayMillis.isPresent()) {
+ builder.maxRetryDelayMillis(this.maxRetryDelayMillis.get());
+ }
+ if (this.retryJitterFactor.isPresent()) {
+ builder.retryJitterFactor(this.retryJitterFactor.get());
+ }
+ }
+
+ /**
+ * Sets the OkHttp client configuration.
+ * Override this method to customize HTTP client behavior (interceptors, connection pools, etc).
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setHttpClient(ClientOptions.Builder builder) {
+ if (this.httpClient != null) {
+ builder.httpClient(this.httpClient);
+ }
+ }
+
+ /**
+ * Sets the logging configuration for the SDK.
+ * Override this method to customize logging behavior.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ */
+ protected void setLogging(ClientOptions.Builder builder) {
+ if (this.logging.isPresent()) {
+ builder.logging(this.logging.get());
+ }
+ }
+
+ /**
+ * Override this method to add any additional configuration to the client.
+ * This method is called at the end of the configuration chain, allowing you to add
+ * custom headers, modify settings, or perform any other client customization.
+ *
+ * @param builder The ClientOptions.Builder to configure
+ *
+ * Example:
+ * {@code
+ * @Override
+ * protected void setAdditional(ClientOptions.Builder builder) {
+ * builder.addHeader("X-Request-ID", () -> UUID.randomUUID().toString());
+ * builder.addHeader("X-Client-Version", "1.0.0");
+ * }
+ * }
+ */
+ protected void setAdditional(ClientOptions.Builder builder) {}
+
+ /**
+ * Override this method to add custom validation logic before the client is built.
+ * This method is called at the beginning of the build() method to ensure the configuration is valid.
+ * Throw an exception to prevent client creation if validation fails.
+ *
+ * Example:
+ * {@code
+ * @Override
+ * protected void validateConfiguration() {
+ * super.validateConfiguration(); // Run parent validations
+ * if (tenantId == null || tenantId.isEmpty()) {
+ * throw new IllegalStateException("tenantId is required");
+ * }
+ * }
+ * }
+ */
+ protected void validateConfiguration() {}
+
public AsyncApiClient build() {
- clientOptionsBuilder.environment(this.environment);
- return new AsyncApiClient(clientOptionsBuilder.build());
+ validateConfiguration();
+ return new AsyncApiClient(buildClientOptions());
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/ApiClientApiException.java b/v3/src/main/java/com/skyflow/generated/rest/core/ApiClientApiException.java
index a4487b1e..c016a435 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/ApiClientApiException.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/ApiClientApiException.java
@@ -65,9 +65,9 @@ public Map> headers() {
return this.headers;
}
- @java.lang.Override
+ @Override
public String toString() {
- return "ApiClientApiException{" + "message: " + getMessage() + ", statusCode: " + statusCode + ", body: " + body
- + "}";
+ return "ApiClientApiException{" + "message: " + getMessage() + ", statusCode: " + statusCode + ", body: "
+ + ObjectMappers.stringify(body) + "}";
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/ClientOptions.java b/v3/src/main/java/com/skyflow/generated/rest/core/ClientOptions.java
index a5d27b01..7d668945 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/ClientOptions.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/ClientOptions.java
@@ -21,12 +21,27 @@ public final class ClientOptions {
private final int timeout;
+ private final int maxRetries;
+
+ private final Optional initialRetryDelayMillis;
+
+ private final Optional maxRetryDelayMillis;
+
+ private final Optional retryJitterFactor;
+
+ private final Optional logging;
+
private ClientOptions(
Environment environment,
Map headers,
Map> headerSuppliers,
OkHttpClient httpClient,
- int timeout) {
+ int timeout,
+ int maxRetries,
+ Optional initialRetryDelayMillis,
+ Optional maxRetryDelayMillis,
+ Optional retryJitterFactor,
+ Optional logging) {
this.environment = environment;
this.headers = new HashMap<>();
this.headers.putAll(headers);
@@ -40,6 +55,11 @@ private ClientOptions(
this.headerSuppliers = headerSuppliers;
this.httpClient = httpClient;
this.timeout = timeout;
+ this.maxRetries = maxRetries;
+ this.initialRetryDelayMillis = initialRetryDelayMillis;
+ this.maxRetryDelayMillis = maxRetryDelayMillis;
+ this.retryJitterFactor = retryJitterFactor;
+ this.logging = logging;
}
public Environment environment() {
@@ -81,11 +101,31 @@ public OkHttpClient httpClientWithTimeout(RequestOptions requestOptions) {
.build();
}
+ public int maxRetries() {
+ return this.maxRetries;
+ }
+
+ public Optional initialRetryDelayMillis() {
+ return this.initialRetryDelayMillis;
+ }
+
+ public Optional maxRetryDelayMillis() {
+ return this.maxRetryDelayMillis;
+ }
+
+ public Optional retryJitterFactor() {
+ return this.retryJitterFactor;
+ }
+
+ public Optional logging() {
+ return this.logging;
+ }
+
public static Builder builder() {
return new Builder();
}
- public static final class Builder {
+ public static class Builder {
private Environment environment;
private final Map headers = new HashMap<>();
@@ -94,17 +134,27 @@ public static final class Builder {
private int maxRetries = 2;
+ private Optional initialRetryDelayMillis = Optional.empty();
+
+ private Optional maxRetryDelayMillis = Optional.empty();
+
+ private Optional retryJitterFactor = Optional.empty();
+
private Optional timeout = Optional.empty();
private OkHttpClient httpClient = null;
+ private Optional logging = Optional.empty();
+
public Builder environment(Environment environment) {
this.environment = environment;
return this;
}
public Builder addHeader(String key, String value) {
- this.headers.put(key, value);
+ if (value != null) {
+ this.headers.put(key, value);
+ }
return this;
}
@@ -137,11 +187,43 @@ public Builder maxRetries(int maxRetries) {
return this;
}
+ /**
+ * Override the initial delay (in milliseconds) used for exponential backoff between retries. Defaults to 1000 milliseconds.
+ */
+ public Builder initialRetryDelayMillis(long initialRetryDelayMillis) {
+ this.initialRetryDelayMillis = Optional.of(initialRetryDelayMillis);
+ return this;
+ }
+
+ /**
+ * Override the maximum delay (in milliseconds) between retries. Defaults to 60000 milliseconds.
+ */
+ public Builder maxRetryDelayMillis(long maxRetryDelayMillis) {
+ this.maxRetryDelayMillis = Optional.of(maxRetryDelayMillis);
+ return this;
+ }
+
+ /**
+ * Override the jitter factor (between 0 and 1) applied to retry delays. Defaults to 0.2.
+ */
+ public Builder retryJitterFactor(double retryJitterFactor) {
+ this.retryJitterFactor = Optional.of(retryJitterFactor);
+ return this;
+ }
+
public Builder httpClient(OkHttpClient httpClient) {
this.httpClient = httpClient;
return this;
}
+ /**
+ * Configure logging for the SDK. Silent by default — no log output unless explicitly configured.
+ */
+ public Builder logging(LogConfig logging) {
+ this.logging = Optional.of(logging);
+ return this;
+ }
+
public ClientOptions build() {
OkHttpClient.Builder httpClientBuilder =
this.httpClient != null ? this.httpClient.newBuilder() : new OkHttpClient.Builder();
@@ -158,13 +240,49 @@ public ClientOptions build() {
.connectTimeout(0, TimeUnit.SECONDS)
.writeTimeout(0, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
- .addInterceptor(new RetryInterceptor(this.maxRetries));
+ .addInterceptor(new RetryInterceptor(
+ this.maxRetries,
+ this.initialRetryDelayMillis,
+ this.maxRetryDelayMillis,
+ this.retryJitterFactor));
}
+ Logger logger = Logger.from(this.logging);
+ httpClientBuilder.addInterceptor(new LoggingInterceptor(logger));
+ httpClientBuilder.addInterceptor(new ResponseDecompressionInterceptor());
+
this.httpClient = httpClientBuilder.build();
this.timeout = Optional.of(httpClient.callTimeoutMillis() / 1000);
- return new ClientOptions(environment, headers, headerSuppliers, httpClient, this.timeout.get());
+ return new ClientOptions(
+ environment,
+ headers,
+ headerSuppliers,
+ httpClient,
+ this.timeout.get(),
+ this.maxRetries,
+ this.initialRetryDelayMillis,
+ this.maxRetryDelayMillis,
+ this.retryJitterFactor,
+ this.logging);
+ }
+
+ /**
+ * Create a new Builder initialized with values from an existing ClientOptions
+ */
+ public static Builder from(ClientOptions clientOptions) {
+ Builder builder = new Builder();
+ builder.environment = clientOptions.environment();
+ builder.timeout = Optional.of(clientOptions.timeout(null));
+ builder.httpClient = clientOptions.httpClient();
+ builder.headers.putAll(clientOptions.headers);
+ builder.headerSuppliers.putAll(clientOptions.headerSuppliers);
+ builder.maxRetries = clientOptions.maxRetries();
+ builder.initialRetryDelayMillis = clientOptions.initialRetryDelayMillis();
+ builder.maxRetryDelayMillis = clientOptions.maxRetryDelayMillis();
+ builder.retryJitterFactor = clientOptions.retryJitterFactor();
+ builder.logging = clientOptions.logging();
+ return builder;
}
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/ConsoleLogger.java b/v3/src/main/java/com/skyflow/generated/rest/core/ConsoleLogger.java
new file mode 100644
index 00000000..5935ff4c
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/ConsoleLogger.java
@@ -0,0 +1,51 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import java.util.logging.Level;
+
+/**
+ * Default logger implementation that writes to the console using {@link java.util.logging.Logger}.
+ *
+ * Uses the "fern" logger name with a simple format of "LEVEL - message".
+ */
+public final class ConsoleLogger implements ILogger {
+
+ private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger("fern");
+
+ static {
+ if (logger.getHandlers().length == 0) {
+ java.util.logging.ConsoleHandler handler = new java.util.logging.ConsoleHandler();
+ handler.setFormatter(new java.util.logging.SimpleFormatter() {
+ @Override
+ public String format(java.util.logging.LogRecord record) {
+ return record.getLevel() + " - " + record.getMessage() + System.lineSeparator();
+ }
+ });
+ logger.addHandler(handler);
+ logger.setUseParentHandlers(false);
+ logger.setLevel(Level.ALL);
+ }
+ }
+
+ @Override
+ public void debug(String message) {
+ logger.log(Level.FINE, message);
+ }
+
+ @Override
+ public void info(String message) {
+ logger.log(Level.INFO, message);
+ }
+
+ @Override
+ public void warn(String message) {
+ logger.log(Level.WARNING, message);
+ }
+
+ @Override
+ public void error(String message) {
+ logger.log(Level.SEVERE, message);
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/DateTimeDeserializer.java b/v3/src/main/java/com/skyflow/generated/rest/core/DateTimeDeserializer.java
index 6be10979..0f422bc2 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/DateTimeDeserializer.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/DateTimeDeserializer.java
@@ -14,11 +14,13 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalQueries;
/**
- * Custom deserializer that handles converting ISO8601 dates into {@link OffsetDateTime} objects.
+ * Custom deserializer that handles converting date-time strings into {@link OffsetDateTime} objects.
+ * Supports ISO 8601 format, space-separated variants, and RFC 1123 (RFC 2822) format.
*/
class DateTimeDeserializer extends JsonDeserializer {
private static final SimpleModule MODULE;
@@ -42,8 +44,21 @@ public OffsetDateTime deserialize(JsonParser parser, DeserializationContext cont
if (token == JsonToken.VALUE_NUMBER_INT) {
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(parser.getValueAsLong()), ZoneOffset.UTC);
} else {
- TemporalAccessor temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest(
- parser.getValueAsString(), OffsetDateTime::from, LocalDateTime::from);
+ String value = parser.getValueAsString();
+ TemporalAccessor temporal;
+ try {
+ temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest(value, OffsetDateTime::from, LocalDateTime::from);
+ } catch (DateTimeParseException e) {
+ try {
+ // Fall back to space-separated format (e.g. "2025-02-15 10:30:00+00:00").
+ temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest(
+ value.replace(' ', 'T'), OffsetDateTime::from, LocalDateTime::from);
+ } catch (DateTimeParseException e2) {
+ // Fall back to RFC 1123 format (e.g. "Thu, 07 May 2026 14:23:38 +0000").
+ temporal = DateTimeFormatter.RFC_1123_DATE_TIME.parseBest(
+ value, OffsetDateTime::from, LocalDateTime::from);
+ }
+ }
if (temporal.query(TemporalQueries.offset()) == null) {
return LocalDateTime.from(temporal).atOffset(ZoneOffset.UTC);
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/DoubleSerializer.java b/v3/src/main/java/com/skyflow/generated/rest/core/DoubleSerializer.java
new file mode 100644
index 00000000..131d0a9a
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/DoubleSerializer.java
@@ -0,0 +1,43 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+
+/**
+ * Custom serializer that writes integer-valued doubles without a decimal point.
+ * For example, {@code 24000.0} is serialized as {@code 24000} instead of {@code 24000.0}.
+ * Non-integer values like {@code 3.14} are serialized normally.
+ */
+class DoubleSerializer extends JsonSerializer {
+ private static final SimpleModule MODULE;
+
+ static {
+ MODULE = new SimpleModule()
+ .addSerializer(Double.class, new DoubleSerializer())
+ .addSerializer(double.class, new DoubleSerializer());
+ }
+
+ /**
+ * Gets a module wrapping this serializer as an adapter for the Jackson ObjectMapper.
+ *
+ * @return A {@link SimpleModule} to be plugged onto Jackson ObjectMapper.
+ */
+ public static SimpleModule getModule() {
+ return MODULE;
+ }
+
+ @Override
+ public void serialize(Double value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ if (value != null && value == Math.floor(value) && !Double.isInfinite(value) && !Double.isNaN(value)) {
+ gen.writeNumber(value.longValue());
+ } else {
+ gen.writeNumber(value);
+ }
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/ILogger.java b/v3/src/main/java/com/skyflow/generated/rest/core/ILogger.java
new file mode 100644
index 00000000..a70ec8e6
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/ILogger.java
@@ -0,0 +1,38 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+/**
+ * Interface for custom logger implementations.
+ *
+ * Implement this interface to provide a custom logging backend for the SDK.
+ * The SDK will call the appropriate method based on the log level.
+ *
+ *
Example:
+ *
{@code
+ * public class MyCustomLogger implements ILogger {
+ * public void debug(String message) {
+ * System.out.println("[DBG] " + message);
+ * }
+ * public void info(String message) {
+ * System.out.println("[INF] " + message);
+ * }
+ * public void warn(String message) {
+ * System.out.println("[WRN] " + message);
+ * }
+ * public void error(String message) {
+ * System.out.println("[ERR] " + message);
+ * }
+ * }
+ * }
+ */
+public interface ILogger {
+ void debug(String message);
+
+ void info(String message);
+
+ void warn(String message);
+
+ void error(String message);
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/InputStreamRequestBody.java b/v3/src/main/java/com/skyflow/generated/rest/core/InputStreamRequestBody.java
index 545f6088..5351a5f8 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/InputStreamRequestBody.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/InputStreamRequestBody.java
@@ -8,7 +8,6 @@
import java.util.Objects;
import okhttp3.MediaType;
import okhttp3.RequestBody;
-import okhttp3.internal.Util;
import okio.BufferedSink;
import okio.Okio;
import okio.Source;
@@ -68,12 +67,8 @@ public long contentLength() throws IOException {
*/
@Override
public void writeTo(BufferedSink sink) throws IOException {
- Source source = null;
- try {
- source = Okio.source(inputStream);
+ try (Source source = Okio.source(inputStream)) {
sink.writeAll(source);
- } finally {
- Util.closeQuietly(Objects.requireNonNull(source));
}
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/LogConfig.java b/v3/src/main/java/com/skyflow/generated/rest/core/LogConfig.java
new file mode 100644
index 00000000..7928e097
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/LogConfig.java
@@ -0,0 +1,98 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+/**
+ * Configuration for SDK logging.
+ *
+ * Use the builder to configure logging behavior:
+ *
{@code
+ * LogConfig config = LogConfig.builder()
+ * .level(LogLevel.DEBUG)
+ * .silent(false)
+ * .build();
+ * }
+ *
+ * Or with a custom logger:
+ *
{@code
+ * LogConfig config = LogConfig.builder()
+ * .level(LogLevel.DEBUG)
+ * .logger(new MyCustomLogger())
+ * .silent(false)
+ * .build();
+ * }
+ *
+ * Defaults:
+ *
+ * - {@code level} — {@link LogLevel#INFO}
+ * - {@code logger} — {@link ConsoleLogger} (writes to stderr via java.util.logging)
+ * - {@code silent} — {@code true} (no output unless explicitly enabled)
+ *
+ */
+public final class LogConfig {
+
+ private final LogLevel level;
+ private final ILogger logger;
+ private final boolean silent;
+
+ private LogConfig(LogLevel level, ILogger logger, boolean silent) {
+ this.level = level;
+ this.logger = logger;
+ this.silent = silent;
+ }
+
+ public LogLevel level() {
+ return level;
+ }
+
+ public ILogger logger() {
+ return logger;
+ }
+
+ public boolean silent() {
+ return silent;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private LogLevel level = LogLevel.INFO;
+ private ILogger logger = new ConsoleLogger();
+ private boolean silent = true;
+
+ private Builder() {}
+
+ /**
+ * Set the minimum log level. Only messages at this level or above will be logged.
+ * Defaults to {@link LogLevel#INFO}.
+ */
+ public Builder level(LogLevel level) {
+ this.level = level;
+ return this;
+ }
+
+ /**
+ * Set a custom logger implementation. Defaults to {@link ConsoleLogger}.
+ */
+ public Builder logger(ILogger logger) {
+ this.logger = logger;
+ return this;
+ }
+
+ /**
+ * Set whether logging is silent (disabled). Defaults to {@code true}.
+ * Set to {@code false} to enable log output.
+ */
+ public Builder silent(boolean silent) {
+ this.silent = silent;
+ return this;
+ }
+
+ public LogConfig build() {
+ return new LogConfig(level, logger, silent);
+ }
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/LogLevel.java b/v3/src/main/java/com/skyflow/generated/rest/core/LogLevel.java
new file mode 100644
index 00000000..db72cd0a
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/LogLevel.java
@@ -0,0 +1,36 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+/**
+ * Log levels for SDK logging configuration.
+ * Silent by default — no log output unless explicitly configured.
+ */
+public enum LogLevel {
+ DEBUG(1),
+ INFO(2),
+ WARN(3),
+ ERROR(4);
+
+ private final int value;
+
+ LogLevel(int value) {
+ this.value = value;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ /**
+ * Parse a log level from a string (case-insensitive).
+ *
+ * @param level the level string (debug, info, warn, error)
+ * @return the corresponding LogLevel
+ * @throws IllegalArgumentException if the string does not match any level
+ */
+ public static LogLevel fromString(String level) {
+ return LogLevel.valueOf(level.toUpperCase());
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/Logger.java b/v3/src/main/java/com/skyflow/generated/rest/core/Logger.java
new file mode 100644
index 00000000..a5603955
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/Logger.java
@@ -0,0 +1,97 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+/**
+ * SDK logger that filters messages based on level and silent mode.
+ *
+ * Silent by default — no log output unless explicitly configured.
+ * Create via {@link LogConfig} or directly:
+ *
{@code
+ * Logger logger = new Logger(LogLevel.DEBUG, new ConsoleLogger(), false);
+ * logger.debug("request sent");
+ * }
+ */
+public final class Logger {
+
+ private static final Logger DEFAULT = new Logger(LogLevel.INFO, new ConsoleLogger(), true);
+
+ private final LogLevel level;
+ private final ILogger logger;
+ private final boolean silent;
+
+ public Logger(LogLevel level, ILogger logger, boolean silent) {
+ this.level = level;
+ this.logger = logger;
+ this.silent = silent;
+ }
+
+ /**
+ * Returns a default silent logger (no output).
+ */
+ public static Logger getDefault() {
+ return DEFAULT;
+ }
+
+ /**
+ * Creates a Logger from a {@link LogConfig}. If config is {@code null}, returns the default silent logger.
+ */
+ public static Logger from(LogConfig config) {
+ if (config == null) {
+ return DEFAULT;
+ }
+ return new Logger(config.level(), config.logger(), config.silent());
+ }
+
+ /**
+ * Creates a Logger from an {@code Optional}. If empty, returns the default silent logger.
+ */
+ public static Logger from(java.util.Optional config) {
+ return config.map(Logger::from).orElse(DEFAULT);
+ }
+
+ private boolean shouldLog(LogLevel messageLevel) {
+ return !silent && level.getValue() <= messageLevel.getValue();
+ }
+
+ public boolean isDebug() {
+ return shouldLog(LogLevel.DEBUG);
+ }
+
+ public boolean isInfo() {
+ return shouldLog(LogLevel.INFO);
+ }
+
+ public boolean isWarn() {
+ return shouldLog(LogLevel.WARN);
+ }
+
+ public boolean isError() {
+ return shouldLog(LogLevel.ERROR);
+ }
+
+ public void debug(String message) {
+ if (isDebug()) {
+ logger.debug(message);
+ }
+ }
+
+ public void info(String message) {
+ if (isInfo()) {
+ logger.info(message);
+ }
+ }
+
+ public void warn(String message) {
+ if (isWarn()) {
+ logger.warn(message);
+ }
+ }
+
+ public void error(String message) {
+ if (isError()) {
+ logger.error(message);
+ }
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/LoggingInterceptor.java b/v3/src/main/java/com/skyflow/generated/rest/core/LoggingInterceptor.java
new file mode 100644
index 00000000..407b7937
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/LoggingInterceptor.java
@@ -0,0 +1,104 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import okhttp3.Interceptor;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * OkHttp interceptor that logs HTTP requests and responses.
+ *
+ * Logs request method, URL, and headers (with sensitive values redacted) at debug level.
+ * Logs response status at debug level, and 4xx/5xx responses at error level.
+ * Does nothing if the logger is silent.
+ */
+public final class LoggingInterceptor implements Interceptor {
+
+ private static final Set SENSITIVE_HEADERS = new HashSet<>(Arrays.asList(
+ "authorization",
+ "www-authenticate",
+ "x-api-key",
+ "api-key",
+ "apikey",
+ "x-api-token",
+ "x-auth-token",
+ "auth-token",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "cookie",
+ "set-cookie",
+ "x-csrf-token",
+ "x-xsrf-token",
+ "x-session-token",
+ "x-access-token"));
+
+ private final Logger logger;
+
+ public LoggingInterceptor(Logger logger) {
+ this.logger = logger;
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Request request = chain.request();
+
+ if (logger.isDebug()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("HTTP Request: ").append(request.method()).append(" ").append(request.url());
+ sb.append(" headers={");
+ boolean first = true;
+ for (String name : request.headers().names()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(name).append("=");
+ if (SENSITIVE_HEADERS.contains(name.toLowerCase())) {
+ sb.append("[REDACTED]");
+ } else {
+ sb.append(request.header(name));
+ }
+ first = false;
+ }
+ sb.append("}");
+ sb.append(" has_body=").append(request.body() != null);
+ logger.debug(sb.toString());
+ }
+
+ Response response = chain.proceed(request);
+
+ if (logger.isDebug()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("HTTP Response: status=").append(response.code());
+ sb.append(" url=").append(response.request().url());
+ sb.append(" headers={");
+ boolean first = true;
+ for (String name : response.headers().names()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(name).append("=");
+ if (SENSITIVE_HEADERS.contains(name.toLowerCase())) {
+ sb.append("[REDACTED]");
+ } else {
+ sb.append(response.header(name));
+ }
+ first = false;
+ }
+ sb.append("}");
+ logger.debug(sb.toString());
+ }
+
+ if (response.code() >= 400 && logger.isError()) {
+ logger.error("HTTP Error: status=" + response.code() + " url="
+ + response.request().url());
+ }
+
+ return response;
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/NullableNonemptyFilter.java b/v3/src/main/java/com/skyflow/generated/rest/core/NullableNonemptyFilter.java
index 98c33be4..a6cffd4d 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/NullableNonemptyFilter.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/NullableNonemptyFilter.java
@@ -14,6 +14,9 @@ public boolean equals(Object o) {
}
private boolean isOptionalEmpty(Object o) {
- return o instanceof Optional && !((Optional>) o).isPresent();
+ if (o instanceof Optional) {
+ return !((Optional>) o).isPresent();
+ }
+ return false;
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/ObjectMappers.java b/v3/src/main/java/com/skyflow/generated/rest/core/ObjectMappers.java
index 3b7894e0..4f69f150 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/ObjectMappers.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/ObjectMappers.java
@@ -4,6 +4,7 @@
package com.skyflow.generated.rest.core;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -17,6 +18,7 @@ public final class ObjectMappers {
.addModule(new Jdk8Module())
.addModule(new JavaTimeModule())
.addModule(DateTimeDeserializer.getModule())
+ .addModule(DoubleSerializer.getModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
@@ -33,4 +35,12 @@ public static String stringify(Object o) {
return o.getClass().getName() + "@" + Integer.toHexString(o.hashCode());
}
}
+
+ public static Object parseErrorBody(String responseBodyString) {
+ try {
+ return JSON_MAPPER.readValue(responseBodyString, Object.class);
+ } catch (JsonProcessingException ignored) {
+ return responseBodyString;
+ }
+ }
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/RequestOptions.java b/v3/src/main/java/com/skyflow/generated/rest/core/RequestOptions.java
index b8a8d14b..c890c7de 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/RequestOptions.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/RequestOptions.java
@@ -14,19 +14,31 @@ public final class RequestOptions {
private final TimeUnit timeoutTimeUnit;
+ private final Optional maxRetries;
+
private final Map headers;
private final Map> headerSuppliers;
+ private final Map queryParameters;
+
+ private final Map> queryParameterSuppliers;
+
private RequestOptions(
Optional timeout,
TimeUnit timeoutTimeUnit,
+ Optional maxRetries,
Map headers,
- Map> headerSuppliers) {
+ Map> headerSuppliers,
+ Map queryParameters,
+ Map> queryParameterSuppliers) {
this.timeout = timeout;
this.timeoutTimeUnit = timeoutTimeUnit;
+ this.maxRetries = maxRetries;
this.headers = headers;
this.headerSuppliers = headerSuppliers;
+ this.queryParameters = queryParameters;
+ this.queryParameterSuppliers = queryParameterSuppliers;
}
public Optional getTimeout() {
@@ -37,6 +49,10 @@ public TimeUnit getTimeoutTimeUnit() {
return timeoutTimeUnit;
}
+ public Optional getMaxRetries() {
+ return maxRetries;
+ }
+
public Map getHeaders() {
Map headers = new HashMap<>();
headers.putAll(this.headers);
@@ -46,19 +62,33 @@ public Map getHeaders() {
return headers;
}
+ public Map getQueryParameters() {
+ Map queryParameters = new HashMap<>(this.queryParameters);
+ this.queryParameterSuppliers.forEach((key, supplier) -> {
+ queryParameters.put(key, supplier.get());
+ });
+ return queryParameters;
+ }
+
public static Builder builder() {
return new Builder();
}
- public static final class Builder {
+ public static class Builder {
private Optional timeout = Optional.empty();
private TimeUnit timeoutTimeUnit = TimeUnit.SECONDS;
+ private Optional maxRetries = Optional.empty();
+
private final Map headers = new HashMap<>();
private final Map> headerSuppliers = new HashMap<>();
+ private final Map queryParameters = new HashMap<>();
+
+ private final Map> queryParameterSuppliers = new HashMap<>();
+
public Builder timeout(Integer timeout) {
this.timeout = Optional.of(timeout);
return this;
@@ -70,6 +100,11 @@ public Builder timeout(Integer timeout, TimeUnit timeoutTimeUnit) {
return this;
}
+ public Builder maxRetries(Integer maxRetries) {
+ this.maxRetries = Optional.of(maxRetries);
+ return this;
+ }
+
public Builder addHeader(String key, String value) {
this.headers.put(key, value);
return this;
@@ -80,8 +115,25 @@ public Builder addHeader(String key, Supplier value) {
return this;
}
+ public Builder addQueryParameter(String key, String value) {
+ this.queryParameters.put(key, value);
+ return this;
+ }
+
+ public Builder addQueryParameter(String key, Supplier value) {
+ this.queryParameterSuppliers.put(key, value);
+ return this;
+ }
+
public RequestOptions build() {
- return new RequestOptions(timeout, timeoutTimeUnit, headers, headerSuppliers);
+ return new RequestOptions(
+ timeout,
+ timeoutTimeUnit,
+ maxRetries,
+ headers,
+ headerSuppliers,
+ queryParameters,
+ queryParameterSuppliers);
}
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/ResponseDecompressionInterceptor.java b/v3/src/main/java/com/skyflow/generated/rest/core/ResponseDecompressionInterceptor.java
new file mode 100644
index 00000000..d6938347
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/ResponseDecompressionInterceptor.java
@@ -0,0 +1,56 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import java.io.IOException;
+import java.util.zip.Inflater;
+import okhttp3.Interceptor;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import okio.GzipSource;
+import okio.InflaterSource;
+import okio.Okio;
+import okio.Source;
+
+/**
+ * Decompresses gzip and deflate encoded response bodies. OkHttp only performs
+ * transparent decompression when it adds the Accept-Encoding header itself, so
+ * responses to requests with an explicitly set Accept-Encoding header would
+ * otherwise be returned as raw compressed bytes.
+ */
+public class ResponseDecompressionInterceptor implements Interceptor {
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ return decompress(chain.proceed(chain.request()));
+ }
+
+ private Response decompress(Response response) {
+ ResponseBody body = response.body();
+ if (body == null) {
+ return response;
+ }
+ String encoding = response.header("Content-Encoding");
+ if (encoding == null) {
+ return response;
+ }
+ Source decompressedSource;
+ if (encoding.equalsIgnoreCase("gzip") || encoding.equalsIgnoreCase("x-gzip")) {
+ decompressedSource = new GzipSource(body.source());
+ } else if (encoding.equalsIgnoreCase("deflate")) {
+ decompressedSource = new InflaterSource(body.source(), new Inflater());
+ } else {
+ return response;
+ }
+ ResponseBody decompressedBody = ResponseBody.create(body.contentType(), -1L, Okio.buffer(decompressedSource));
+ return response.newBuilder()
+ .headers(response.headers()
+ .newBuilder()
+ .removeAll("Content-Encoding")
+ .removeAll("Content-Length")
+ .build())
+ .body(decompressedBody)
+ .build();
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/RetryInterceptor.java b/v3/src/main/java/com/skyflow/generated/rest/core/RetryInterceptor.java
index eda7d265..7d89751d 100644
--- a/v3/src/main/java/com/skyflow/generated/rest/core/RetryInterceptor.java
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/RetryInterceptor.java
@@ -5,34 +5,78 @@
import java.io.IOException;
import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.Optional;
import java.util.Random;
import okhttp3.Interceptor;
+import okhttp3.Request;
import okhttp3.Response;
public class RetryInterceptor implements Interceptor {
- private static final Duration ONE_SECOND = Duration.ofSeconds(1);
- private final ExponentialBackoff backoff;
+ private static final Duration DEFAULT_INITIAL_RETRY_DELAY = Duration.ofMillis(1000);
+ private static final Duration DEFAULT_MAX_RETRY_DELAY = Duration.ofMillis(60000);
+ private static final double DEFAULT_JITTER_FACTOR = 0.2;
+
+ private final int maxRetries;
+ private final Duration initialRetryDelay;
+ private final Duration maxRetryDelay;
+ private final double jitterFactor;
private final Random random = new Random();
public RetryInterceptor(int maxRetries) {
- this.backoff = new ExponentialBackoff(maxRetries);
+ this(maxRetries, Optional.empty(), Optional.empty(), Optional.empty());
+ }
+
+ public RetryInterceptor(
+ int maxRetries,
+ Optional initialRetryDelayMillis,
+ Optional maxRetryDelayMillis,
+ Optional jitterFactor) {
+ initialRetryDelayMillis.ifPresent(delay -> {
+ if (delay < 0) {
+ throw new IllegalArgumentException("initialRetryDelayMillis must be non-negative");
+ }
+ });
+ maxRetryDelayMillis.ifPresent(delay -> {
+ if (delay < 0) {
+ throw new IllegalArgumentException("maxRetryDelayMillis must be non-negative");
+ }
+ });
+ jitterFactor.ifPresent(factor -> {
+ if (factor < 0 || factor > 1) {
+ throw new IllegalArgumentException("jitterFactor must be between 0 and 1");
+ }
+ });
+ this.maxRetries = maxRetries;
+ this.initialRetryDelay = initialRetryDelayMillis.map(Duration::ofMillis).orElse(DEFAULT_INITIAL_RETRY_DELAY);
+ this.maxRetryDelay = maxRetryDelayMillis.map(Duration::ofMillis).orElse(DEFAULT_MAX_RETRY_DELAY);
+ this.jitterFactor = jitterFactor.orElse(DEFAULT_JITTER_FACTOR);
}
@Override
public Response intercept(Chain chain) throws IOException {
- Response response = chain.proceed(chain.request());
+ Request request = chain.request();
+ int effectiveMaxRetries = resolveMaxRetries(request);
+ Response response = chain.proceed(request);
if (shouldRetry(response.code())) {
- return retryChain(response, chain);
+ return retryChain(response, chain, effectiveMaxRetries);
}
return response;
}
- private Response retryChain(Response response, Chain chain) throws IOException {
- Optional nextBackoff = this.backoff.nextBackoff();
+ private int resolveMaxRetries(Request request) {
+ MaxRetriesOverride override = request.tag(MaxRetriesOverride.class);
+ return override != null ? override.getValue() : this.maxRetries;
+ }
+
+ private Response retryChain(Response response, Chain chain, int maxRetries) throws IOException {
+ ExponentialBackoff backoff = new ExponentialBackoff(maxRetries);
+ Optional nextBackoff = backoff.nextBackoff(response);
while (nextBackoff.isPresent()) {
try {
Thread.sleep(nextBackoff.get().toMillis());
@@ -42,7 +86,7 @@ private Response retryChain(Response response, Chain chain) throws IOException {
response.close();
response = chain.proceed(chain.request());
if (shouldRetry(response.code())) {
- nextBackoff = this.backoff.nextBackoff();
+ nextBackoff = backoff.nextBackoff(response);
} else {
return response;
}
@@ -51,10 +95,130 @@ private Response retryChain(Response response, Chain chain) throws IOException {
return response;
}
+ /**
+ * Calculates the retry delay from response headers, with fallback to exponential backoff.
+ * Priority: Retry-After > X-RateLimit-Reset > Exponential Backoff
+ */
+ private Duration getRetryDelayFromHeaders(Response response, int retryAttempt) {
+ // Check for Retry-After header first (RFC 7231), with no jitter
+ String retryAfter = response.header("Retry-After");
+ if (retryAfter != null) {
+ // Parse as number of seconds...
+ Optional secondsDelay = tryParseLong(retryAfter)
+ .map(seconds -> seconds * 1000)
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, maxRetryDelay.toMillis()))
+ .map(Duration::ofMillis);
+ if (secondsDelay.isPresent()) {
+ return secondsDelay.get();
+ }
+
+ // ...or as an HTTP date; both are valid
+ Optional dateDelay = tryParseHttpDate(retryAfter)
+ .map(resetTime -> resetTime.toInstant().toEpochMilli() - System.currentTimeMillis())
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, maxRetryDelay.toMillis()))
+ .map(Duration::ofMillis);
+ if (dateDelay.isPresent()) {
+ return dateDelay.get();
+ }
+ }
+
+ // Then check for industry-standard X-RateLimit-Reset header, with positive jitter
+ String rateLimitReset = response.header("X-RateLimit-Reset");
+ if (rateLimitReset != null) {
+ // Assume Unix timestamp in epoch seconds
+ Optional rateLimitDelay = tryParseLong(rateLimitReset)
+ .map(resetTimeSeconds -> (resetTimeSeconds * 1000) - System.currentTimeMillis())
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, maxRetryDelay.toMillis()))
+ .map(this::addPositiveJitter)
+ .map(Duration::ofMillis);
+ if (rateLimitDelay.isPresent()) {
+ return rateLimitDelay.get();
+ }
+ }
+
+ // Fall back to exponential backoff, with symmetric jitter
+ long initialDelayMillis = initialRetryDelay.toMillis();
+ long maxDelayMillis = maxRetryDelay.toMillis();
+ long cappedDelay;
+ if (retryAttempt >= Long.SIZE - 1 || initialDelayMillis > (maxDelayMillis >> retryAttempt)) {
+ // initialDelayMillis * 2^retryAttempt would exceed maxDelayMillis (or overflow)
+ cappedDelay = maxDelayMillis;
+ } else {
+ cappedDelay = Math.min(initialDelayMillis << retryAttempt, maxDelayMillis); // 2^retryAttempt
+ }
+ return Duration.ofMillis(addSymmetricJitter(cappedDelay));
+ }
+
+ /**
+ * Attempts to parse a string as a long, returning empty Optional on failure.
+ */
+ private Optional tryParseLong(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(Long.parseLong(value));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Attempts to parse a string as an HTTP date (RFC 1123), returning empty Optional on failure.
+ */
+ private Optional tryParseHttpDate(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME));
+ } catch (DateTimeParseException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Adds positive jitter (100-120% of original value) to prevent thundering herd.
+ * Used for X-RateLimit-Reset header delays.
+ */
+ private long addPositiveJitter(long delayMs) {
+ double jitterMultiplier = 1.0 + (random.nextDouble() * jitterFactor);
+ return (long) (delayMs * jitterMultiplier);
+ }
+
+ /**
+ * Adds symmetric jitter (90-110% of original value) to prevent thundering herd.
+ * Used for exponential backoff delays.
+ */
+ private long addSymmetricJitter(long delayMs) {
+ double jitterMultiplier = 1.0 + ((random.nextDouble() - 0.5) * jitterFactor);
+ return (long) (delayMs * jitterMultiplier);
+ }
+
private static boolean shouldRetry(int statusCode) {
return statusCode == 408 || statusCode == 429 || statusCode >= 500;
}
+ /**
+ * Per-request override carried on the OkHttp {@link Request} as a tag.
+ * When present, the interceptor uses this value instead of the client-wide
+ * {@code maxRetries} configured at construction time.
+ */
+ public static final class MaxRetriesOverride {
+ private final int value;
+
+ public MaxRetriesOverride(int value) {
+ this.value = value;
+ }
+
+ public int getValue() {
+ return value;
+ }
+ }
+
private final class ExponentialBackoff {
private final int maxNumRetries;
@@ -65,14 +229,14 @@ private final class ExponentialBackoff {
this.maxNumRetries = maxNumRetries;
}
- public Optional nextBackoff() {
- retryNumber += 1;
- if (retryNumber > maxNumRetries) {
+ public Optional nextBackoff(Response response) {
+ if (retryNumber >= maxNumRetries) {
return Optional.empty();
}
- int upperBound = (int) Math.pow(2, retryNumber);
- return Optional.of(ONE_SECOND.multipliedBy(random.nextInt(upperBound)));
+ Duration delay = getRetryDelayFromHeaders(response, retryNumber);
+ retryNumber += 1;
+ return Optional.of(delay);
}
}
}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/Rfc2822DateTimeDeserializer.java b/v3/src/main/java/com/skyflow/generated/rest/core/Rfc2822DateTimeDeserializer.java
new file mode 100644
index 00000000..3a826210
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/Rfc2822DateTimeDeserializer.java
@@ -0,0 +1,25 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import java.io.IOException;
+import java.time.OffsetDateTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Custom deserializer that handles converting RFC 2822 (RFC 1123) dates into {@link OffsetDateTime} objects.
+ * This is used for fields with format "date-time-rfc-2822", such as Twilio's dateCreated, dateSent, dateUpdated.
+ */
+public class Rfc2822DateTimeDeserializer extends JsonDeserializer {
+
+ @Override
+ public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+ String raw = parser.getValueAsString();
+ return ZonedDateTime.parse(raw, DateTimeFormatter.RFC_1123_DATE_TIME).toOffsetDateTime();
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/SseEvent.java b/v3/src/main/java/com/skyflow/generated/rest/core/SseEvent.java
new file mode 100644
index 00000000..9ab823d5
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/SseEvent.java
@@ -0,0 +1,114 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents a Server-Sent Event with all standard fields.
+ * Used for event-level discrimination where the discriminator is at the SSE envelope level.
+ *
+ * @param The type of the data field
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class SseEvent {
+ private final String event;
+ private final T data;
+ private final String id;
+ private final Long retry;
+
+ private SseEvent(String event, T data, String id, Long retry) {
+ this.event = event;
+ this.data = data;
+ this.id = id;
+ this.retry = retry;
+ }
+
+ @JsonProperty("event")
+ public Optional getEvent() {
+ return Optional.ofNullable(event);
+ }
+
+ @JsonProperty("data")
+ public T getData() {
+ return data;
+ }
+
+ @JsonProperty("id")
+ public Optional getId() {
+ return Optional.ofNullable(id);
+ }
+
+ @JsonProperty("retry")
+ public Optional getRetry() {
+ return Optional.ofNullable(retry);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SseEvent> sseEvent = (SseEvent>) o;
+ return Objects.equals(event, sseEvent.event)
+ && Objects.equals(data, sseEvent.data)
+ && Objects.equals(id, sseEvent.id)
+ && Objects.equals(retry, sseEvent.retry);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(event, data, id, retry);
+ }
+
+ @Override
+ public String toString() {
+ return "SseEvent{" + "event='"
+ + event + '\'' + ", data="
+ + data + ", id='"
+ + id + '\'' + ", retry="
+ + retry + '}';
+ }
+
+ public static Builder builder() {
+ return new Builder<>();
+ }
+
+ public static final class Builder {
+ private String event;
+ private T data;
+ private String id;
+ private Long retry;
+
+ private Builder() {}
+
+ public Builder event(String event) {
+ this.event = event;
+ return this;
+ }
+
+ public Builder data(T data) {
+ this.data = data;
+ return this;
+ }
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder retry(Long retry) {
+ this.retry = retry;
+ return this;
+ }
+
+ public SseEvent build() {
+ return new SseEvent<>(event, data, id, retry);
+ }
+ }
+}
diff --git a/v3/src/main/java/com/skyflow/generated/rest/core/SseEventParser.java b/v3/src/main/java/com/skyflow/generated/rest/core/SseEventParser.java
new file mode 100644
index 00000000..ce50f86b
--- /dev/null
+++ b/v3/src/main/java/com/skyflow/generated/rest/core/SseEventParser.java
@@ -0,0 +1,228 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.skyflow.generated.rest.core;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.core.type.TypeReference;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Utility class for parsing Server-Sent Events with support for discriminated unions.
+ *
+ * Handles two discrimination patterns:
+ *
+ * - Data-level discrimination: The discriminator (e.g., 'type') is inside the JSON data payload.
+ * Jackson's polymorphic deserialization handles this automatically.
+ * - Event-level discrimination: The discriminator (e.g., 'event') is at the SSE envelope level.
+ * This requires constructing the full SSE envelope for Jackson to process.
+ *
+ */
+public final class SseEventParser {
+
+ private static final Set SSE_ENVELOPE_FIELDS = new HashSet<>(Arrays.asList("event", "data", "id", "retry"));
+
+ private SseEventParser() {
+ // Utility class
+ }
+
+ /**
+ * Parse an SSE event using event-level discrimination.
+ *
+ * Constructs the full SSE envelope object with event, data, id, and retry fields,
+ * then deserializes it to the target union type.
+ *
+ * @param eventType The SSE event type (from event: field)
+ * @param data The SSE data content (from data: field)
+ * @param id The SSE event ID (from id: field), may be null
+ * @param retry The SSE retry value (from retry: field), may be null
+ * @param unionClass The target union class
+ * @param discriminatorProperty The property name used for discrimination (e.g., "event")
+ * @param The target type
+ * @return The deserialized object
+ */
+ public static T parseEventLevelUnion(
+ String eventType, String data, String id, Long retry, Class unionClass, String discriminatorProperty) {
+ try {
+ // Determine if data should be parsed as JSON based on the variant's expected type
+ Object parsedData = parseDataForVariant(eventType, data, unionClass, discriminatorProperty);
+
+ // Construct the SSE envelope object
+ Map envelope = new HashMap<>();
+ envelope.put(discriminatorProperty, eventType);
+ envelope.put("data", parsedData);
+ if (id != null) {
+ envelope.put("id", id);
+ }
+ if (retry != null) {
+ envelope.put("retry", retry);
+ }
+
+ // Serialize to JSON and deserialize to target type
+ String envelopeJson = ObjectMappers.JSON_MAPPER.writeValueAsString(envelope);
+ return ObjectMappers.JSON_MAPPER.readValue(envelopeJson, unionClass);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to parse SSE event with event-level discrimination", e);
+ }
+ }
+
+ /**
+ * Parse an SSE event using data-level discrimination.
+ *
+ * Simply parses the data field as JSON and deserializes it to the target type.
+ * Jackson's polymorphic deserialization handles the discrimination automatically.
+ *
+ * @param data The SSE data content (from data: field)
+ * @param valueType The target type
+ * @param The target type
+ * @return The deserialized object
+ */
+ public static T parseDataLevelUnion(String data, Class valueType) {
+ try {
+ return ObjectMappers.JSON_MAPPER.readValue(data, valueType);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to parse SSE data with data-level discrimination", e);
+ }
+ }
+
+ /**
+ * Determines if the given discriminator property indicates event-level discrimination.
+ * Event-level discrimination occurs when the discriminator is an SSE envelope field.
+ *
+ * @param discriminatorProperty The discriminator property name
+ * @return true if event-level discrimination, false otherwise
+ */
+ public static boolean isEventLevelDiscrimination(String discriminatorProperty) {
+ return SSE_ENVELOPE_FIELDS.contains(discriminatorProperty);
+ }
+
+ /**
+ * Attempts to find the discriminator property from the union class's Jackson annotations.
+ *
+ * @param unionClass The union class to inspect
+ * @return The discriminator property name, or empty if not found
+ */
+ public static Optional findDiscriminatorProperty(Class> unionClass) {
+ try {
+ // Look for JsonTypeInfo on the class itself
+ JsonTypeInfo typeInfo = unionClass.getAnnotation(JsonTypeInfo.class);
+ if (typeInfo != null && !typeInfo.property().isEmpty()) {
+ return Optional.of(typeInfo.property());
+ }
+
+ // Look for inner Value interface with JsonTypeInfo
+ for (Class> innerClass : unionClass.getDeclaredClasses()) {
+ typeInfo = innerClass.getAnnotation(JsonTypeInfo.class);
+ if (typeInfo != null && !typeInfo.property().isEmpty()) {
+ return Optional.of(typeInfo.property());
+ }
+ }
+ } catch (Exception e) {
+ // Ignore reflection errors
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Parse the data field based on what the matching variant expects.
+ * If the variant expects a String for its data field, returns the raw string.
+ * Otherwise, parses the data as JSON.
+ */
+ private static Object parseDataForVariant(
+ String eventType, String data, Class> unionClass, String discriminatorProperty) {
+ if (data == null || data.isEmpty()) {
+ return data;
+ }
+
+ try {
+ // Try to find the variant class that matches this event type
+ Class> variantClass = findVariantClass(unionClass, eventType, discriminatorProperty);
+ if (variantClass != null) {
+ // Check if the variant expects a String for the data field
+ Field dataField = findField(variantClass, "data");
+ if (dataField != null && String.class.equals(dataField.getType())) {
+ // Variant expects String - return raw data
+ return data;
+ }
+ }
+
+ // Try to parse as JSON
+ return ObjectMappers.JSON_MAPPER.readValue(data, new TypeReference