params) {
+ delegate.serverURL(serverUrl, params);
+ return this;
+ }
+
+ /**
+ * Overrides the default server by index.
+ *
+ * @param serverIdx The server to use for all requests.
+ * @return This builder instance.
+ */
+ public GleanBuilder serverIndex(int serverIdx) {
+ delegate.serverIndex(serverIdx);
+ return this;
+ }
+
+ /**
+ * Sets the instance variable for URL substitution.
+ *
+ * @param instance The instance name to set.
+ * @return This builder instance.
+ */
+ public GleanBuilder instance(String instance) {
+ delegate.instance(instance);
+ return this;
+ }
+
+ /**
+ * Overrides the default configuration for retries.
+ *
+ * @param retryConfig The retry configuration to use for all requests.
+ * @return This builder instance.
+ */
+ public GleanBuilder retryConfig(RetryConfig retryConfig) {
+ delegate.retryConfig(retryConfig);
+ return this;
+ }
+
+ /**
+ * Enables debug logging for HTTP requests and responses, including JSON body content.
+ *
+ * @param enabled Whether to enable debug logging.
+ * @return This builder instance.
+ */
+ public GleanBuilder enableHTTPDebugLogging(boolean enabled) {
+ delegate.enableHTTPDebugLogging(enabled);
+ return this;
+ }
+
+ /**
+ * Exclude API endpoints that will be deprecated after this date.
+ * Use this to test your integration against upcoming deprecations.
+ *
+ * More information: Deprecations Overview
+ *
+ * @param excludeDeprecatedAfter date string in YYYY-MM-DD format (e.g., '2026-10-15')
+ * @return This builder instance.
+ */
+ public GleanBuilder excludeDeprecatedAfter(String excludeDeprecatedAfter) {
+ this.excludeDeprecatedAfter = Optional.ofNullable(excludeDeprecatedAfter);
+ return this;
+ }
+
+ /**
+ * Enable experimental API features that are not yet generally available.
+ * Use this to preview and test new functionality.
+ *
+ *
Warning: Experimental features may change or be removed without notice.
+ * Do not rely on experimental features in production environments.
+ *
+ * @param includeExperimental whether to include experimental features
+ * @return This builder instance.
+ */
+ public GleanBuilder includeExperimental(boolean includeExperimental) {
+ this.includeExperimental = Optional.of(includeExperimental);
+ return this;
+ }
+
+ /**
+ * Builds a new instance of the Glean SDK.
+ *
+ * @return The configured Glean instance.
+ */
+ public Glean build() {
+ Glean sdk = delegate.build();
+ SDKConfiguration sdkConfiguration = extractSdkConfiguration(sdk);
+ if (sdkConfiguration != null) {
+ GleanCustomConfigRegistry.put(
+ sdkConfiguration,
+ new GleanCustomConfig(excludeDeprecatedAfter, includeExperimental)
+ );
+ }
+ return sdk;
+ }
+
+ private static SDKConfiguration extractSdkConfiguration(Glean sdk) {
+ if (sdk == null) {
+ return null;
+ }
+
+ try {
+ // Preferred: reflectively access Glean.client -> Client.sdkConfiguration
+ Object client = readFieldValue(sdk, "client");
+ if (client != null) {
+ Object cfg = readFieldValue(client, "sdkConfiguration");
+ if (cfg instanceof SDKConfiguration) {
+ return (SDKConfiguration) cfg;
+ }
+ }
+ } catch (RuntimeException e) {
+ // Best-effort: fall through to generic field scan
+ }
+
+ // Fallback: scan first-level fields for an SDKConfiguration
+ for (Field f : sdk.getClass().getDeclaredFields()) {
+ if (!SDKConfiguration.class.isAssignableFrom(f.getType())) {
+ continue;
+ }
+ try {
+ f.setAccessible(true);
+ Object cfg = f.get(sdk);
+ if (cfg instanceof SDKConfiguration) {
+ return (SDKConfiguration) cfg;
+ }
+ } catch (IllegalAccessException e) {
+ // ignore
+ }
+ }
+
+ return null;
+ }
+
+ private static Object readFieldValue(Object target, String fieldName) {
+ Class> c = target.getClass();
+ while (c != null) {
+ try {
+ Field f = c.getDeclaredField(fieldName);
+ f.setAccessible(true);
+ return f.get(target);
+ } catch (NoSuchFieldException e) {
+ c = c.getSuperclass();
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java
new file mode 100644
index 00000000..b8e217ec
--- /dev/null
+++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfig.java
@@ -0,0 +1,41 @@
+package com.glean.api_client.glean_api_client.hooks;
+
+import java.util.Optional;
+
+/**
+ * Immutable custom Glean configuration values.
+ *
+ *
This class holds configuration that is not part of the auto-generated SDK,
+ * providing a way to configure custom headers and features without modifying
+ * generated code.
+ *
+ *
Values are associated with a specific SDK instance via {@link GleanCustomConfigRegistry}.
+ */
+public final class GleanCustomConfig {
+
+ private final Optional excludeDeprecatedAfter;
+ private final Optional includeExperimental;
+
+ public GleanCustomConfig(Optional excludeDeprecatedAfter, Optional includeExperimental) {
+ this.excludeDeprecatedAfter = excludeDeprecatedAfter != null ? excludeDeprecatedAfter : Optional.empty();
+ this.includeExperimental = includeExperimental != null ? includeExperimental : Optional.empty();
+ }
+
+ /**
+ * Gets the date after which deprecated API endpoints should be excluded.
+ *
+ * @return Optional containing the date string (YYYY-MM-DD format) if set
+ */
+ public Optional excludeDeprecatedAfter() {
+ return excludeDeprecatedAfter;
+ }
+
+ /**
+ * Gets whether experimental API features should be enabled.
+ *
+ * @return Optional containing the boolean value if set
+ */
+ public Optional includeExperimental() {
+ return includeExperimental;
+ }
+}
diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java
new file mode 100644
index 00000000..afa0fb28
--- /dev/null
+++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/GleanCustomConfigRegistry.java
@@ -0,0 +1,47 @@
+package com.glean.api_client.glean_api_client.hooks;
+
+import com.glean.api_client.glean_api_client.SDKConfiguration;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.WeakHashMap;
+
+/**
+ * Registry mapping a generated {@link SDKConfiguration} instance to preserved custom configuration.
+ *
+ * Speakeasy regenerations overwrite generated classes, so custom configuration is stored outside
+ * generated code and associated to a specific SDK instance at runtime.
+ */
+final class GleanCustomConfigRegistry {
+
+ private static final Map REGISTRY =
+ Collections.synchronizedMap(new WeakHashMap<>());
+
+ private GleanCustomConfigRegistry() {
+ // prevent instantiation
+ }
+
+ static void put(SDKConfiguration sdkConfiguration, GleanCustomConfig customConfig) {
+ if (sdkConfiguration == null) {
+ return;
+ }
+
+ if (customConfig == null) {
+ return;
+ }
+
+ REGISTRY.put(sdkConfiguration, customConfig);
+ }
+
+ static Optional get(SDKConfiguration sdkConfiguration) {
+ if (sdkConfiguration == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(REGISTRY.get(sdkConfiguration));
+ }
+
+ static void clearForTests() {
+ REGISTRY.clear();
+ }
+}
diff --git a/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java
index 5147f1b5..255ac37d 100644
--- a/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java
+++ b/src/main/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHook.java
@@ -5,6 +5,7 @@
import com.glean.api_client.glean_api_client.utils.Helpers;
import com.glean.api_client.glean_api_client.utils.Hook;
+import java.lang.reflect.Method;
import java.net.http.HttpRequest;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -85,12 +86,16 @@ static AsyncHook.BeforeRequest createAsyncHook(Function envProvi
};
}
- private static void addHeaders(HttpRequest.Builder builder, SDKConfiguration config,
+ private static void addHeaders(HttpRequest.Builder builder,
+ SDKConfiguration sdkConfiguration,
Function envProvider) {
+ Optional customConfig = GleanCustomConfigRegistry.get(sdkConfiguration);
+
// Get deprecated after value - environment variable takes precedence
Optional deprecatedAfterValue = getFirstNonEmpty(
getEnv(ENV_EXCLUDE_DEPRECATED_AFTER, envProvider),
- config.excludeDeprecatedAfter()
+ customConfig.flatMap(GleanCustomConfig::excludeDeprecatedAfter),
+ getSdkConfigurationExcludeDeprecatedAfter(sdkConfiguration)
);
deprecatedAfterValue.ifPresent(value ->
@@ -100,7 +105,8 @@ private static void addHeaders(HttpRequest.Builder builder, SDKConfiguration con
// Get experimental value - environment variable takes precedence
Optional experimentalValue = getFirstNonEmpty(
getEnvAsBoolean(ENV_INCLUDE_EXPERIMENTAL, envProvider),
- config.includeExperimental().filter(b -> b).map(b -> "true")
+ customConfig.flatMap(GleanCustomConfig::includeExperimental).filter(b -> b).map(b -> "true"),
+ getSdkConfigurationIncludeExperimental(sdkConfiguration).filter(b -> b).map(b -> "true")
);
experimentalValue.ifPresent(value ->
@@ -123,17 +129,71 @@ private static Optional getFirstNonEmpty(Optional... optionals)
private static Optional getEnv(String name, Function envProvider) {
String value = envProvider.apply(name);
- if (value != null && !value.isEmpty()) {
- return Optional.of(value);
+ if (value != null) {
+ value = value.trim();
+ if (!value.isEmpty()) {
+ return Optional.of(value);
+ }
}
return Optional.empty();
}
private static Optional getEnvAsBoolean(String name, Function envProvider) {
String value = envProvider.apply(name);
- if ("true".equalsIgnoreCase(value)) {
+ if (value != null && "true".equalsIgnoreCase(value.trim())) {
return Optional.of("true");
}
return Optional.empty();
}
+
+ private static Optional getSdkConfigurationExcludeDeprecatedAfter(SDKConfiguration sdkConfiguration) {
+ if (sdkConfiguration == null) {
+ return Optional.empty();
+ }
+
+ // Use reflection to avoid a compile-time dependency on generated methods.
+ try {
+ Method m = sdkConfiguration.getClass().getMethod("excludeDeprecatedAfter");
+ Object result = m.invoke(sdkConfiguration);
+ if (result instanceof Optional) {
+ Optional> opt = (Optional>) result;
+ Object v = opt.orElse(null);
+ if (v instanceof String) {
+ String s = ((String) v).trim();
+ return s.isEmpty() ? Optional.empty() : Optional.of(s);
+ }
+ }
+ } catch (NoSuchMethodException e) {
+ return Optional.empty();
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+
+ return Optional.empty();
+ }
+
+ private static Optional getSdkConfigurationIncludeExperimental(SDKConfiguration sdkConfiguration) {
+ if (sdkConfiguration == null) {
+ return Optional.empty();
+ }
+
+ // Use reflection to avoid a compile-time dependency on generated methods.
+ try {
+ Method m = sdkConfiguration.getClass().getMethod("includeExperimental");
+ Object result = m.invoke(sdkConfiguration);
+ if (result instanceof Optional) {
+ Optional> opt = (Optional>) result;
+ Object v = opt.orElse(null);
+ if (v instanceof Boolean) {
+ return Optional.of((Boolean) v);
+ }
+ }
+ } catch (NoSuchMethodException e) {
+ return Optional.empty();
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+
+ return Optional.empty();
+ }
}
diff --git a/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java
index e3182b99..c03303bf 100644
--- a/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java
+++ b/src/test/java/com/glean/api_client/glean_api_client/hooks/XGleanHeadersHookTest.java
@@ -4,14 +4,17 @@
import com.glean.api_client.glean_api_client.SecuritySource;
import com.glean.api_client.glean_api_client.utils.Hook;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpRequest;
+import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.*;
@@ -21,6 +24,11 @@ class XGleanHeadersHookTest {
private static final String HEADER_EXCLUDE_DEPRECATED_AFTER = XGleanHeadersHook.HEADER_EXCLUDE_DEPRECATED_AFTER;
private static final String HEADER_EXPERIMENTAL = XGleanHeadersHook.HEADER_EXPERIMENTAL;
+ @BeforeEach
+ void setUp() {
+ GleanCustomConfigRegistry.clearForTests();
+ }
+
private HttpRequest createMockRequest() {
return HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/test"))
@@ -40,11 +48,11 @@ private Hook.BeforeRequestContext createMockContext(SDKConfiguration config) {
private SDKConfiguration createConfig(String excludeDeprecatedAfter, Boolean includeExperimental) {
SDKConfiguration config = new SDKConfiguration();
- if (excludeDeprecatedAfter != null) {
- config.setExcludeDeprecatedAfter(Optional.of(excludeDeprecatedAfter));
- }
- if (includeExperimental != null) {
- config.setIncludeExperimental(Optional.of(includeExperimental));
+ if (excludeDeprecatedAfter != null || includeExperimental != null) {
+ GleanCustomConfigRegistry.put(
+ config,
+ new GleanCustomConfig(Optional.ofNullable(excludeDeprecatedAfter), Optional.ofNullable(includeExperimental))
+ );
}
return config;
}
@@ -249,4 +257,83 @@ void shouldUseEnvironmentVariablesForBothHeadersWhenAllAreSet() throws Exception
assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null));
}
}
+
+ @Nested
+ class WhenMultipleSDKInstancesExist {
+
+ @Test
+ void shouldNotLeakConfigBetweenSDKConfigurations() throws Exception {
+ Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider());
+ HttpRequest request = createMockRequest();
+
+ SDKConfiguration configA = createConfig("2026-10-15", true);
+ SDKConfiguration configB = createConfig("2027-01-01", false);
+
+ HttpRequest resA = hook.beforeRequest(createMockContext(configA), request);
+ HttpRequest resB = hook.beforeRequest(createMockContext(configB), request);
+
+ assertEquals("2026-10-15", resA.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null));
+ assertEquals("true", resA.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null));
+
+ assertEquals("2027-01-01", resB.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null));
+ assertFalse(resB.headers().firstValue(HEADER_EXPERIMENTAL).isPresent());
+ }
+ }
+
+ @Nested
+ class AsyncHookParity {
+
+ @Test
+ void shouldApplySameHeadersInAsyncHook() {
+ var hook = XGleanHeadersHook.createAsyncHook(emptyEnvProvider());
+ HttpRequest request = createMockRequest();
+ SDKConfiguration config = createConfig("2026-10-15", true);
+
+ CompletableFuture fut = hook.beforeRequest(createMockContext(config), request);
+ HttpRequest result = fut.join();
+
+ assertEquals("2026-10-15", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null));
+ assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null));
+ }
+ }
+
+ @Nested
+ class BackwardCompatibilityWithGeneratedSDKConfiguration {
+
+ @Test
+ void shouldFallBackToSDKConfigurationWhenRegistryIsEmpty() throws Exception {
+ Hook.BeforeRequest hook = XGleanHeadersHook.createSyncHook(emptyEnvProvider());
+ HttpRequest request = createMockRequest();
+ SDKConfiguration config = new SDKConfiguration();
+
+ boolean setDeprecatedAfter = tryInvokeOptionalSetter(config, "setExcludeDeprecatedAfter", Optional.of("2029-12-31"));
+ boolean setExperimental = tryInvokeOptionalSetter(config, "setIncludeExperimental", Optional.of(true));
+
+ HttpRequest result = hook.beforeRequest(createMockContext(config), request);
+
+ if (setDeprecatedAfter) {
+ assertEquals("2029-12-31", result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).orElse(null));
+ } else {
+ assertFalse(result.headers().firstValue(HEADER_EXCLUDE_DEPRECATED_AFTER).isPresent());
+ }
+
+ if (setExperimental) {
+ assertEquals("true", result.headers().firstValue(HEADER_EXPERIMENTAL).orElse(null));
+ } else {
+ assertFalse(result.headers().firstValue(HEADER_EXPERIMENTAL).isPresent());
+ }
+ }
+ }
+
+ private static boolean tryInvokeOptionalSetter(Object target, String methodName, Optional> value) {
+ try {
+ Method m = target.getClass().getMethod(methodName, Optional.class);
+ m.invoke(target, value);
+ return true;
+ } catch (NoSuchMethodException e) {
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ }
}