Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions framework/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ type ErrorEvent struct {
type Heartbeat struct{}

type Goodbye struct {
Reason string `json:"reason"`
Silent bool `json:"silent"`
Catastrophe bool `json:"catastrophe"`
//nolint:godox
// TODO: Might later include some advice or backoff information
Reason string `json:"reason"`
Silent bool `json:"silent"`
Catastrophe bool `json:"catastrophe"`
ProtocolFallbackTTL *int `json:"protocolFallbackTTL,omitempty"`
}
11 changes: 11 additions & 0 deletions mockld/streaming_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,17 @@ func (s *StreamingService) PushGoodbye(reason string, silent, catastrophe bool)
})
}

// PushGoodbyeWithFallback sends a goodbye event carrying a protocolFallbackTTL
// field, which signals the SDK to fall back from FDv2 to FDv1 (see FDV2PL spec
// §3.7 and CSFDV2 spec §8.3.4). ttlSeconds is the duration in seconds the SDK
// should remain on FDv1 before attempting recovery; 0 means indefinite.
func (s *StreamingService) PushGoodbyeWithFallback(reason string, ttlSeconds int) {
s.PushEvent("goodbye", framework.Goodbye{
Reason: reason,
ProtocolFallbackTTL: &ttlSeconds,
})
}

func (s *StreamingService) PushPayloadTransferred(state string, version int) {
eventData := framework.PayloadTransferred{
State: state,
Expand Down
100 changes: 99 additions & 1 deletion sdktests/common_tests_stream_fdv2.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
// Tests use this to stand up a realistic FDv1 Fallback Synchronizer endpoint regardless
// of SDK kind.
func fdv1FallbackBody(
t *ldtest.T, c CommonStreamingTests, flagKey string,
t *ldtest.T, c CommonStreamingTests, flagKey string, //nolint:unparam // kept as a parameter for API consistency
version int,
value ldvalue.Value,
) []byte {
Expand Down Expand Up @@ -500,6 +500,8 @@ func (c CommonStreamingTests) FDv1FallbackDirective(t *ldtest.T) {
c.DirectiveWithoutFDv1ConfiguredHaltsDataSystem)
t.Run("directed fallback is terminal and does not revisit FDv2 sources",
c.DirectedFallbackIsTerminal)
t.Run("goodbye event with protocolFallbackTTL engages FDv1 fallback",
c.DirectiveViaGoodbyeEngagesFDv1)
}

// DirectiveOnStreamingErrorEngagesFDv1 verifies Requirement 1.6.1 and 1.6.3 for the
Expand Down Expand Up @@ -951,6 +953,102 @@ func (c CommonStreamingTests) DirectedFallbackIsTerminal(t *ldtest.T) {
"flag-key should still be served from the FDv1 fallback after the recovery window elapses")
}

// DirectiveViaGoodbyeEngagesFDv1 verifies the in-band goodbye-event form of the FDv1
// Fallback Directive (FDV2PL spec §3.7, CSFDV2 spec §8.3.4): a `goodbye` SSE event
// whose JSON body contains a `protocolFallbackTTL` field MUST cause the SDK to engage
// the FDv1 Fallback Synchronizer, just as the `X-LD-FD-Fallback` response header does.
//
// This transport is essential for browser SDKs that use the native EventSource API,
// which does not expose HTTP response headers on the streaming connection. The goodbye
// message provides the same fallback information in-band.
//
// The test delivers a full FDv2 basis via streaming so the SDK initializes normally,
// then sends the goodbye event with protocolFallbackTTL. The FDv1 endpoint serves a
// different flag value; the test asserts the SDK eventually returns that FDv1 value,
// proving the transition completed and FDv1 data is the active source.
func (c CommonStreamingTests) DirectiveViaGoodbyeEngagesFDv1(t *ldtest.T) {
streamingValue := ldvalue.String("value-from-streaming")
streamingData := c.makeSDKDataWithFlag(1, streamingValue)
dataSystem := NewSDKDataSystem(t, streamingData)

fdv1Value := ldvalue.String("value-from-fdv1-via-goodbye")
fdv1Handler, fdv1Channel := httphelpers.RecordingHandler(
httphelpers.HandlerWithResponse(200, http.Header{"Content-Type": []string{"application/json"}},
fdv1FallbackBody(t, c, "flag-key", 2, fdv1Value)))
fdv1Endpoint := requireContext(t).harness.NewMockEndpoint(fdv1Handler, t.DebugLogger(),
harness.MockEndpointDescription("FDv1 polling service (goodbye path)"))
t.Defer(fdv1Endpoint.Close)

client := c.newFDv2SDKClient(t,
WithConfig(servicedef.SDKConfigParams{
StartWaitTimeMS: o.Some(ldtime.UnixMillisecondTime(5 * time.Second / time.Millisecond)),
}),
WithServiceEndpoints(servicedef.SDKConfigServiceEndpointsParams{
Streaming: dataSystem.Synchronizers[0].Endpoint().BaseURL(),
Polling: fdv1Endpoint.BaseURL(),
}),
dataSystem,
WithFDv1Fallback(servicedef.SDKConfigPollingParams{
BaseURI: fdv1Endpoint.BaseURL(),
}))

// Wait for the SDK to initialize from the streaming basis.
streamEndpoint := dataSystem.Synchronizers[0].Endpoint()
conn, err := streamEndpoint.AwaitConnection(time.Second * 2)
require.NoError(t, err)

context := c.flagEvaluationContext
h.RequireEventually(t, func() bool {
value := basicEvaluateFlag(t, client, "flag-key", context, defaultValue)
return m.In(t).Assert(value, m.JSONEqual(streamingValue))
}, time.Second*3, time.Millisecond*20,
"flag-key should have been served from the streaming payload before the goodbye event")

// Send the goodbye event with protocolFallbackTTL to trigger FDv1 fallback.
// TTL 0 means indefinite fallback — the SDK must not attempt FDv2 recovery.
// We do NOT cancel the connection ourselves — the SDK must close the stream
// after processing the directive. Forcing a cancel here would race the SDK's
// event processing and could mask a bug where the SDK ignores the goodbye.
dataSystem.Synchronizers[0].streaming.PushGoodbyeWithFallback("fdv1-fallback-directed", 0)

// The SDK must close the FDv2 streaming connection after processing the
// goodbye directive. The request context is tied to the TCP connection; if the
// SDK correctly stopped the Primary Synchronizer, the context will be cancelled.
h.RequireEventually(t, func() bool {
select {
case <-conn.Context.Done():
return true
default:
return false
}
}, time.Second*3, time.Millisecond*20,
"SDK did not close the FDv2 streaming connection after goodbye with protocolFallbackTTL — "+
"the Primary Synchronizer must be stopped when Directed Fallback engages")

// The SDK must contact the FDv1 polling endpoint after the goodbye directive.
h.RequireEventually(t, func() bool {
select {
case resp := <-fdv1Channel:
return fdv1FallbackPollPathMatches(c.sdkKind, resp.Request.URL.Path)
default:
return false
}
}, time.Second*3, time.Millisecond*10,
"FDv1 fallback endpoint was never contacted after goodbye event with protocolFallbackTTL")

// Evaluation must eventually reflect FDv1's value, proving the SDK installed
// the FDv1 payload and is serving from it rather than the stale streaming basis.
h.RequireEventually(t, func() bool {
value := basicEvaluateFlag(t, client, "flag-key", context, defaultValue)
return m.In(t).Assert(value, m.JSONEqual(fdv1Value))
}, time.Second*3, time.Millisecond*20,
"flag-key should have been served from the FDv1 fallback after goodbye with protocolFallbackTTL")

// The FDv2 streaming endpoint must not see any reconnection attempts — the
// directive is terminal and the SDK must not return to FDv2.
streamEndpoint.RequireNoMoreConnections(t, time.Millisecond*500)
}

func (c CommonStreamingTests) SavesPreviouslyKnownState(t *ldtest.T) {
dataBefore := c.makeSDKDataWithFlag(1, initialValue)
var dataAfter mockld.FDv2SDKData
Expand Down
Loading