Skip to content

feat!: support a separate streaming host and custom request headers - #175

Open
vazarkevych wants to merge 3 commits into
growthbook:mainfrom
vazarkevych:feature/api-streaming-host-headers
Open

vazarkevych wants to merge 3 commits into
growthbook:mainfrom
vazarkevych:feature/api-streaming-host-headers

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

Summary

The SDK talked to a single host with only the headers it sets itself, so it could not be pointed at a
deployment where streaming is served separately — features from a CDN, streaming through a GrowthBook
Proxy — nor used behind a gateway that expects an authentication header. Both are configurable in the
reference JS SDK; this brings the Flutter SDK to parity.

Option Applies to
streamingHost the streaming (SSE) endpoint only; falls back to hostURL
apiHostRequestHeaders features request + every remote-evaluation request
streamingHostRequestHeaders streaming connection, including its reconnects

Behaviour

Semantics follow getApiHosts() in sdk-js:
the streaming host falls back to the API host, trailing slashes are dropped, and a path on the host is
preserved. Features and remote evaluation always stay on hostURL.

The header maps are copied on the way in, so a later edit of the caller's map cannot change what
requests are already carrying. Streaming headers are re-applied on every SSE reconnect, not just on
the first connection.

Reserved headers are rejected, unlike in the JS SDK. JS merges caller headers over its own
({ "Content-Type": ..., ...headers }), which lets a supplied If-None-Match silently break ETag
revalidation. Here User-Agent, If-None-Match and Cache-Control throw an ArgumentError from
initialize() in any letter case, matching the C# SDK, and the client still applies its own headers
last as a second line of defence. Content-Type is deliberately not reserved — setting
text/event-stream on the streaming connection is a real nginx workaround.

A streamingHost that is not an absolute http/https URL is rejected the same way, so a
misconfiguration surfaces at startup rather than as a failed fetch later.

Refactor in the first commit

Endpoints were built two ways: GBContext assembled the features and remote-eval URLs itself, while
FeatureURLBuilder assembled the ones the data source actually requested, including streaming. Both
were correct but had to be kept in step by hand, and a second host would have had to be threaded
through both. FeatureURLBuilder is now an instance holding the hosts, GBContext delegates to it
and gained getStreamingURL(), and the data source asks the context. No behaviour change.

Breaking change

BaseClient gained an optional {Map<String, String>? headers} on its three methods — the only
channel through which headers can reach an injected client. This breaks anyone supplying a custom
client via client:; inside the repo it only affected the test doubles. Needs a CHANGELOG note.

Tests

+19 tests, 321 in the suite, all green. flutter analyze clean, dart format clean.

  • gb_sdk_request_headers_test.dart (14): API headers on the features GET and on the remote-eval
    POST but not on the stream; streaming headers on the stream but not on the API host; nothing added
    when unconfigured; a later edit of the caller's map does not leak through; SSE uses streamingHost
    when set and hostURL when not, while features and remote eval stay on the API host; invalid
    streaming hosts and reserved header names (any case, both maps) throw ArgumentError.
  • utility_classes_test.dart (+5, plus the two existing builder tests updated): features, streaming
    and remote-eval endpoints, host path preservation, null host or client key, and the streaming-host
    fallback.

Notes

  • Header values are never logged: no log statement receives the maps, and the existing redaction
    tests cover the failure path.
  • apiHost is not added as a separate option — the existing hostURL already plays that role and
    renaming it would be breaking for no gain.
  • One coupling with the open feature/attributes-merge-semantics branch: it adds three test clients
    that implement BaseClient. Whichever of the two merges second needs the new headers parameter
    added to those overrides — about five lines, and CI flags it immediately.

Endpoints were built two ways: GBContext assembled the features and remote-eval
URLs itself, while FeatureURLBuilder assembled the ones the data source actually
requested, including streaming. Both were correct but had to be kept in step by
hand, and a second host for streaming would have had to be threaded through both.

FeatureURLBuilder is now an instance holding the hosts, GBContext delegates to it
and gained getStreamingURL(), and the data source asks the context instead of
rebuilding URLs. Behaviour is unchanged: a path on the host is still preserved
and trailing slashes are still dropped.
The SDK talked to a single host with only the headers it sets itself, so it could
not be pointed at a deployment where streaming is served separately - a
GrowthBook Proxy in front of a CDN - nor used behind a gateway that expects an
authentication header. Both are configurable in the reference JS SDK.

Adds streamingHost, which the streaming endpoint uses and which falls back to
hostURL, plus apiHostRequestHeaders (features request and remote evaluation) and
streamingHostRequestHeaders (streaming connection and its reconnects). The maps
are copied on the way in so a later edit by the caller cannot change what
requests are already carrying.

Headers the SDK manages - User-Agent, If-None-Match, Cache-Control - are rejected
at configuration time with an ArgumentError, along with a streamingHost that is
not an absolute http/https URL, so a misconfiguration surfaces at startup instead
of as a failed fetch. This is stricter than the JS SDK, which merges caller
headers over its own and would let a supplied If-None-Match silently break ETag
revalidation.

BaseClient gains an optional headers argument on its three methods, which is a
breaking change for anyone supplying a custom client.
@andywhite37
andywhite37 self-requested a review October 8, 2026 21:30
@andywhite37 andywhite37 added effort:medium Focused review with meaningful behavior and test coverage to assess. needs-human-input A specific decision or manual task blocks progress; see the queue entry. priority:normal Regular queue priority; no evidence of urgency or low value. risk:high Could substantially affect correctness, isolation, compatibility, or lifecycle behavior. labels Oct 19, 2026
@andywhite37

Copy link
Copy Markdown
Contributor

Please confirm the streaming-host/header API, reserved-header policy, and release target for the breaking BaseClient signatures. Existing custom clients must update their overrides, and #176 adds clients needing the same adjustment. Record the compatibility decision so review can proceed.

@vazarkevych vazarkevych mentioned this pull request Oct 20, 2026
@vazarkevych vazarkevych changed the title feat: support a separate streaming host and custom request headers feat!: support a separate streaming host and custom request headers Oct 20, 2026
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

Decisions:

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

Labels

effort:medium Focused review with meaningful behavior and test coverage to assess. needs-human-input A specific decision or manual task blocks progress; see the queue entry. priority:normal Regular queue priority; no evidence of urgency or low value. risk:high Could substantially affect correctness, isolation, compatibility, or lifecycle behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants