Skip to content

feat: custom API request headers and a dedicated streaming host - #256

Open
vazarkevych wants to merge 7 commits into
mainfrom
feat/custom-request-headers
Open

vazarkevych wants to merge 7 commits into
mainfrom
feat/custom-request-headers

Conversation

@vazarkevych

@vazarkevych vazarkevych commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

feat: custom API request headers and a dedicated streaming host

Custom request headers + separate SSE streaming host for GrowthBookClient and GBFeaturesRepository

TypeScript-SDK parity · additive · secrets never logged

build
language level
new tests

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:

Option Applied to Falls back to
apiHostRequestHeaders every features GET and remote-eval POST —
streamingHost the SSE streaming connection only apiHost when unset
streamingHostRequestHeaders the SSE streaming request —

The SDK still owns User-Agent, If-None-Match and Cache-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.Exclude and 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, apiHostRequestHeaders and streamingHostRequestHeaders; in this repo they existed only as placeholder comments on Options. Two real deployment shapes were unreachable from Java:

  • Auth gateway / proxy in front of the GrowthBook API that requires an Authorization (or similar) header on every request.
  • GrowthBook Cloud's dedicated streaming domain (e.g. 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]
Loading

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 @Nullable fields added; the two header maps are @ToString.Exclude. A new backward-compatible constructor preserves the previous positional signature, and @Builder moves to the new longest constructor so Options.builder() gains the three setters.

2. Start-up validation (multiusermode/configurations/OptionsValidator.java)

  • streamingHost, when set, must be a valid http(s) URL (shared with the existing apiHost check via a new checkHostUrl).
  • Both header maps are rejected for blank names, null values, and reserved headers — all violations surface together via InvalidOptionsException.

3. Transport (repository/GBFeaturesRepository.java)

  • New @Builder constructor with the three fields (old constructors delegate with null, so existing call sites and the public API are untouched).
  • The SSE endpoint is built from streamingHost when supplied, otherwise apiHost.
  • sanitizeCustomHeaders / applyCustomHeaders / applySdkUserAgent apply the headers to the features GET, the SSE request, and the remote-eval POST. The SDK User-Agent is now set directly on each request, so it holds even when a consumer passes their own OkHttpClient without the interceptor.

4. Remote eval (remoteeval/RemoteEvalService.java)

New customHeaders constructor overloads; headers + SDK User-Agent are 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_HEADERS is defined once and reused by the validator and the repository; the interceptor's User-Agent name/value are exposed as constants so the direct-set path and the interceptor can't drift.


Files

File Change
constants/SDKConstants.java + RESERVED_REQUEST_HEADERS (user-agent / if-none-match / cache-control)
repository/GBFeaturesRepositoryRequestInterceptor.java extract USER_AGENT_HEADER / USER_AGENT_VALUE constants
repository/GBFeaturesRepository.java builder constructor + 3 fields; streaming-host endpoint; sanitize/apply/User-Agent on GET, SSE, remote-eval
remoteeval/RemoteEvalService.java customHeaders overloads; headers + User-Agent on the eval POST
multiusermode/configurations/Options.java 3 options + backward-compat constructor; @ToString.Exclude on secret maps
multiusermode/configurations/OptionsValidator.java streamingHost URL check + reserved/blank/null header checks
multiusermode/GrowthBookClient.java wire options into SWR, SSE-invalidation, and remote-eval paths
multiusermode/GrowthBookClientTestFixtures.java builder stubs for the 3 new setters
OptionsValidatorTest.java +11 streaming-host / header-validation / secret-omission cases
remoteeval/RemoteEvalServiceCustomHeadersTest.java new — headers + User-Agent on the wire (2 cases)
repository/GBFeaturesRepositoryCustomHeadersTest.java new — GET headers, reserved-drop, streaming endpoint (4 cases)
README.md "Custom Request Headers & Streaming Host" section

Usage

Java — behind an auth gateway with a dedicated streaming host
Options options = Options.builder()
    .apiHost("https://growthbook-api.internal.example.com")
    .clientKey("${GROWTHBOOK_CLIENT_KEY}")
    // sent on every features fetch and remote-eval request
    .apiHostRequestHeaders(Collections.singletonMap("Authorization", "Bearer " + System.getenv("GB_PROXY_TOKEN")))
    // dedicated SSE host; falls back to apiHost when unset
    .streamingHost("https://beacon.growthbook.io")
    // sent on the SSE streaming request
    .streamingHostRequestHeaders(Collections.singletonMap("Authorization", "Bearer " + System.getenv("GB_STREAM_TOKEN")))
    .build();

GrowthBookClient client = new GrowthBookClient(options);
client.initialize();

Public API surface

Consumer touches Kind Stability
Options.builder().apiHostRequestHeaders(...) / .streamingHost(...) / .streamingHostRequestHeaders(...) new builder setters additive
GBFeaturesRepository.builder().apiHostRequestHeaders(...) / .streamingHost(...) / .streamingHostRequestHeaders(...) new builder setters additive
new RemoteEvalService(apiHost, clientKey, customHeaders) new constructor overload additive
SDKConstants.RESERVED_REQUEST_HEADERS new constant additive
existing Options / GBFeaturesRepository constructors unchanged (delegate with null) no break

Tests

Suite Covers
OptionsValidatorTest (+11) valid/blank/non-http/malformed streamingHost; custom headers accepted; reserved headers rejected (case-insensitive); blank-name/null-value rejected; violation messages and Options.toString() omit header values
RemoteEvalServiceCustomHeadersTest (new) custom headers + SDK User-Agent on the eval POST; SDK User-Agent overrides a user-supplied one
GBFeaturesRepositoryCustomHeadersTest (new) custom apiHostRequestHeaders + User-Agent on the features GET; a reserved header is dropped and the SDK value wins; SSE endpoint built from streamingHost, falling back to apiHost

./gradlew build passes on JDK 17; the full :lib suite is green.


Compatibility & scope notes

  • Additive / backward compatible. No public signatures removed; the @Builder move and the new backward-compat Options constructor preserve existing positional callers.
  • Secrets. Header values are @ToString.Excluded and never logged; sanitation warnings log header names only.
  • Single-context out of scope. GrowthBook / GBContext owns no feature-fetch or SSE path and no apiHost/clientKey-level header configuration, so it is intentionally untouched; the feature flows through the multi-user GrowthBookClient and the standalone GBFeaturesRepository.
  • Build: requires JDK 17 (the default JDK 26 crashes the Lombok version used here), matching CI.

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.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge; no blocking issue remains.

Reviews (5) · Last reviewed commit: "test: use WireMock for remote-eval heade..." · Reviewed by Greptile

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.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread lib/src/main/java/growthbook/sdk/java/constants/SDKConstants.java Outdated
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.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

# Conflicts:
#	lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java
@vazarkevych

Copy link
Copy Markdown
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.
@vazarkevych

Copy link
Copy Markdown
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant