Repository navigation
feat: custom API request headers and a dedicated streaming host - #256
Open
vazarkevych wants to merge 7 commits into
Open
vazarkevych wants to merge 7 commits into
vazarkevych wants to merge 7 commits into
Conversation
Add three Options: apiHostRequestHeaders (sent on every features fetch and remote-eval request), streamingHost (dedicated SSE host, falling back to apiHost) and streamingHostRequestHeaders (sent on the SSE request). This mirrors the TypeScript SDK and supports gateways/proxies that require auth headers and GrowthBook Cloud's dedicated streaming domain. GBFeaturesRepository gains a builder constructor with the three fields, applies the custom headers to the features GET, the SSE request and the remote-eval POST, and sanitizes them (dropping blank names, null values and the SDK-managed User-Agent/If-None-Match/Cache-Control headers). The SDK User-Agent is now set directly on each request so it holds even for a user-supplied OkHttpClient. RemoteEvalService gains matching customHeaders overloads. OptionsValidator rejects a malformed streamingHost and reserved/blank/null headers up front; header maps are @ToString.Exclude so secrets never reach logs. GrowthBookClient wires the options into its SWR, SSE-invalidation and remote-eval paths. Header values may contain secrets and are never logged. Ported from the internal feat/custom-request-headers branch; adapted to the public GBFeaturesRepository/Options structure and wired through GrowthBookClient directly, since the internal RepositoryFactory/RemoteEvalCoordinator indirection does not exist here. Single-context GrowthBook/GBContext is intentionally left out: it owns no feature-fetch or SSE path and no apiHost/clientKey headers.
Collaborator
Author
|
@greptile review |
|
OptionsValidator accepts a scheme-less streamingHost (it assumes https for the URL check only), and GBFeaturesRepository can be built directly via its builder with no validation at all. Both paths previously fed the raw value into the SSE endpoint, so "beacon.growthbook.io" produced a scheme-less URL OkHttp cannot build a request from, and a trailing slash produced a doubled "//sub/" path. Normalize the supplied streamingHost where the endpoint is built: prefix https:// when no scheme is present and strip trailing slashes. The apiHost fallback is unchanged.
Two start-up validation gaps let malformed custom headers reach OkHttp and throw an unchecked IllegalArgumentException on the first request (outside the fetch handler on the remote-eval path) instead of being reported up front: - OptionsValidator now rejects header names/values that are not valid HTTP syntax (e.g. a name like "X Gateway Key" with spaces), matching what OkHttp accepts when the request is built. Invalid-value messages reference the header name only, never the value. - Accept is now an SDK-managed (reserved) header. It carries the SSE content negotiation (text/event-stream) on the streaming request and was previously accepted in streamingHostRequestHeaders only to be silently overwritten; it is now rejected at startup and dropped by the repository's header sanitation. Docs (SDKConstants, Options, OptionsValidator, README) updated; tests cover the invalid-name, invalid-value (value not echoed) and reserved-Accept cases.
Collaborator
Author
|
@greptile review |
Reserving Accept in the shared reserved-header set blocked it on apiHostRequestHeaders too, even though the SDK only sets a managed Accept (text/event-stream) on the SSE streaming request. A caller whose API gateway requires a specific Accept on the features GET or remote-eval POST could not supply it: startup validation failed, or the repository dropped it. Split the reserved set per request path: RESERVED_REQUEST_HEADERS (User-Agent, If-None-Match, Cache-Control) for apiHostRequestHeaders, and RESERVED_STREAMING_REQUEST_HEADERS (User-Agent, Accept) for streamingHostRequestHeaders. OptionsValidator and the repository's header sanitation now take the applicable set per map. A custom Accept is accepted on apiHostRequestHeaders and rejected only on streamingHostRequestHeaders.
Collaborator
Author
|
@greptile review |
# Conflicts: # lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java
Collaborator
Author
|
@greptile review |
…te-eval POST header paths Replace the real com.sun HttpServer in RemoteEvalServiceCustomHeadersTest with WireMock, asserting the recorded request headers, per the no-real-network rule. Add request-level coverage in GBFeaturesRepositoryCustomHeadersTest: streamingHostRequestHeaders reach the built SSE request, and apiHostRequestHeaders (plus the SDK User-Agent) are sent on the remote-evaluation POST.
Collaborator
Author
|
@greptile review |
… type CI builds the PR merged with current main, where sseHttpClient (and sseRequest) are final AtomicReference fields from the thread-safe lifecycle change; reflectively assigning a plain OkHttpClient then throws IllegalArgumentException. Set and read the fields through helpers that mutate the AtomicReference when present and fall back to a direct field access otherwise, so the SSE header test passes both before and after the merge.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: custom API request headers and a dedicated streaming host
Custom request headers + separate SSE streaming host for
GrowthBookClientandGBFeaturesRepositoryTypeScript-SDK parity · additive · secrets never logged
Summary
Adds three configuration options so the SDK can talk to GrowthBook through an authenticating gateway/proxy and use a dedicated streaming domain — matching the GrowthBook TypeScript SDK options of the same name:
apiHostRequestHeadersstreamingHostapiHostwhen unsetstreamingHostRequestHeadersThe SDK still owns
User-Agent,If-None-MatchandCache-Control: those names are reserved, filtered out of user maps, and the SDK sets its own values. Header values may carry secrets, so both maps are@ToString.Excludeand are never written to logs or diagnostics.The change is additive and backward compatible — new options default to "no custom headers / use apiHost", so existing consumers are unaffected.
Why — TypeScript SDK parity
The GrowthBook TS SDK already exposes
streamingHost,apiHostRequestHeadersandstreamingHostRequestHeaders; in this repo they existed only as placeholder comments onOptions. Two real deployment shapes were unreachable from Java:Authorization(or similar) header on every request.https://beacon.growthbook.io), which is a different host from the features API.This PR closes both gaps and keeps the two SDK entry points consistent.
How it wires up
flowchart LR O["Options<br/>apiHostRequestHeaders<br/>streamingHost<br/>streamingHostRequestHeaders"] V{{OptionsValidator<br/>URL + reserved-header checks}} C[GrowthBookClient] R[GBFeaturesRepository] RE[RemoteEvalService] O --> V --> C C -->|SWR + SSE builder| R C -->|getRemoteEvalService| RE R -->|apiHostRequestHeaders + UA| GET[features GET] R -->|streamingHostRequestHeaders + UA| SSE[SSE @ streamingHost] R -->|apiHostRequestHeaders + UA| POST[remote-eval POST] RE -->|apiHostRequestHeaders + UA| POST2[remote-eval POST]Headers are sanitized once at construction (blank names, null values, and reserved SDK headers are dropped with a name-only warning) and applied before the SDK-managed headers, so SDK values always win.
What was done
1. New options on
Options(multiusermode/configurations/Options.java)Three
@Nullablefields added; the two header maps are@ToString.Exclude. A new backward-compatible constructor preserves the previous positional signature, and@Buildermoves to the new longest constructor soOptions.builder()gains the three setters.2. Start-up validation (
multiusermode/configurations/OptionsValidator.java)streamingHost, when set, must be a validhttp(s)URL (shared with the existingapiHostcheck via a newcheckHostUrl).InvalidOptionsException.3. Transport (
repository/GBFeaturesRepository.java)@Builderconstructor with the three fields (old constructors delegate withnull, so existing call sites and the public API are untouched).streamingHostwhen supplied, otherwiseapiHost.sanitizeCustomHeaders/applyCustomHeaders/applySdkUserAgentapply the headers to the features GET, the SSE request, and the remote-eval POST. The SDKUser-Agentis now set directly on each request, so it holds even when a consumer passes their ownOkHttpClientwithout the interceptor.4. Remote eval (
remoteeval/RemoteEvalService.java)New
customHeadersconstructor overloads; headers + SDKUser-Agentare applied to the eval POST.5. Wiring (
multiusermode/GrowthBookClient.java)Options flow into all three paths: the SWR repository, the remote-eval SSE-invalidation repository, and
RemoteEvalService.6. Shared constant (
constants/SDKConstants.java,repository/GBFeaturesRepositoryRequestInterceptor.java)RESERVED_REQUEST_HEADERSis defined once and reused by the validator and the repository; the interceptor'sUser-Agentname/value are exposed as constants so the direct-set path and the interceptor can't drift.Files
constants/SDKConstants.javaRESERVED_REQUEST_HEADERS(user-agent / if-none-match / cache-control)repository/GBFeaturesRepositoryRequestInterceptor.javaUSER_AGENT_HEADER/USER_AGENT_VALUEconstantsrepository/GBFeaturesRepository.javaremoteeval/RemoteEvalService.javacustomHeadersoverloads; headers + User-Agent on the eval POSTmultiusermode/configurations/Options.java@ToString.Excludeon secret mapsmultiusermode/configurations/OptionsValidator.javastreamingHostURL check + reserved/blank/null header checksmultiusermode/GrowthBookClient.javamultiusermode/GrowthBookClientTestFixtures.javaOptionsValidatorTest.javaremoteeval/RemoteEvalServiceCustomHeadersTest.javarepository/GBFeaturesRepositoryCustomHeadersTest.javaREADME.mdUsage
Java — behind an auth gateway with a dedicated streaming host
Public API surface
Options.builder().apiHostRequestHeaders(...)/.streamingHost(...)/.streamingHostRequestHeaders(...)GBFeaturesRepository.builder().apiHostRequestHeaders(...)/.streamingHost(...)/.streamingHostRequestHeaders(...)new RemoteEvalService(apiHost, clientKey, customHeaders)SDKConstants.RESERVED_REQUEST_HEADERSOptions/GBFeaturesRepositoryconstructorsnull)Tests
OptionsValidatorTest(+11)streamingHost; custom headers accepted; reserved headers rejected (case-insensitive); blank-name/null-value rejected; violation messages andOptions.toString()omit header valuesRemoteEvalServiceCustomHeadersTest(new)User-Agenton the eval POST; SDKUser-Agentoverrides a user-supplied oneGBFeaturesRepositoryCustomHeadersTest(new)apiHostRequestHeaders+User-Agenton the features GET; a reserved header is dropped and the SDK value wins; SSE endpoint built fromstreamingHost, falling back toapiHost./gradlew buildpasses on JDK 17; the full:libsuite is green.Compatibility & scope notes
@Buildermove and the new backward-compatOptionsconstructor preserve existing positional callers.@ToString.Excluded and never logged; sanitation warnings log header names only.GrowthBook/GBContextowns no feature-fetch or SSE path and noapiHost/clientKey-level header configuration, so it is intentionally untouched; the feature flows through the multi-userGrowthBookClientand the standaloneGBFeaturesRepository.