Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ab15699
feat: conform FDv1 streaming and polling to RETRY spec (SDK-2788)
tanderson-ld Aug 7, 2026
b68347d
build: drop local eventsource replace from PR diff
tanderson-ld Aug 7, 2026
a2972ad
reset polling n on transition into extended regime (SDK-2788)
tanderson-ld Aug 11, 2026
1c0064b
adopt eventsource RetryProfile API rename (SDK-2788)
tanderson-ld Aug 11, 2026
2d49623
bump eventsource to v1.13.0 with RetryProfile API (SDK-2788)
tanderson-ld Aug 12, 2026
1835d23
Merge branch 'v7' into ta/SDK-2788/retry-conformance-work
tanderson-ld Aug 14, 2026
655f9c6
fix(datasource): address Cursor review feedback on RETRY-conformance PR
tanderson-ld Aug 17, 2026
fba7a6a
fix(datasource): resolve golangci-lint gosec G118 and lll findings
tanderson-ld Aug 17, 2026
6d91fa8
Merge remote-tracking branch 'origin/v7' into ta/SDK-2788/retry-confo…
tanderson-ld Aug 17, 2026
c536a67
refactor: drop public API additions from RETRY-conformance work
tanderson-ld Aug 18, 2026
c355d5e
build(deps): bump eventsource to v1.14.0
tanderson-ld Aug 18, 2026
76dcb98
Merge branch 'v7' into ta/SDK-2788/retry-conformance-work
tanderson-ld Aug 18, 2026
5d76036
docs, log: clean up RETRY API docs and restore Error level for unexpe…
tanderson-ld Aug 19, 2026
1084159
Merge remote-tracking branch 'origin/ta/SDK-2788/retry-conformance-wo…
tanderson-ld Aug 19, 2026
28d906e
docs(datasource): tighten comments per PR review
tanderson-ld Aug 21, 2026
576f4d3
Update internal/datasource/polling_strategy.go
tanderson-ld Aug 21, 2026
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/google/uuid v1.1.1
github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f
github.com/launchdarkly/ccache v1.1.0
github.com/launchdarkly/eventsource v1.10.0
github.com/launchdarkly/eventsource v1.13.0
github.com/launchdarkly/go-jsonstream/v3 v3.1.1
github.com/launchdarkly/go-ntlm-proxy-auth v1.0.3
github.com/launchdarkly/go-sdk-common/v3 v3.5.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VAkR2k=
github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q=
github.com/launchdarkly/eventsource v1.10.0 h1:H9Tp6AfGu/G2qzBJC26iperrvwhzdbiA/gx7qE2nDFI=
github.com/launchdarkly/eventsource v1.10.0/go.mod h1:J3oa50bPvJesZqNAJtb5btSIo5N6roDWhiAS3IpsKck=
github.com/launchdarkly/eventsource v1.13.0 h1:SjC1LgNSR+ip8BgZcphGa/ar6N0I+pFTYQ/1ZzDnP98=
github.com/launchdarkly/eventsource v1.13.0/go.mod h1:dU+rZxkPOlGPsyJPpiDqiepAcFwIITDUClY9+A6RrMw=
github.com/launchdarkly/go-jsonstream/v3 v3.1.1 h1:ugupp2eNtwVbr69KCdeUrm1vUf1/3ju4Wdliaob95uY=
github.com/launchdarkly/go-jsonstream/v3 v3.1.1/go.mod h1:ZBjhKq8mhArCtqotGRGnteY6eXpNm1GaOdUSZHh+ZjM=
github.com/launchdarkly/go-ntlm-proxy-auth v1.0.3 h1:i3V0N+R0Fd2nXfGEVKCBIZ8kyttZ+SRKvBG8cdcphO4=
Expand Down
100 changes: 75 additions & 25 deletions internal/datasource/helpers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package datasource

import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net/http"

Expand All @@ -20,53 +23,100 @@ func (e httpStatusError) Error() string {
return e.Message
}

// Tests whether an HTTP error status represents a condition that might resolve on its own if we retry,
// or at least should not make us permanently stop sending requests.
func isHTTPErrorRecoverable(statusCode int) bool {
// FailureClass categorizes a data source failure per RETRY §1.5--§1.7. Under the
Comment thread
tanderson-ld marked this conversation as resolved.
Outdated
// RETRY spec no failure is permanently terminal: every failure is either "normal"
// (regular backoff and retry) or "unexpected" (extended backoff via a longer
// retry profile or wait interval, still retrying indefinitely).
type FailureClass int

const (
// FailureClassNormal indicates a failure that a caller should treat as an
// ordinary transient error.
FailureClassNormal FailureClass = iota
// FailureClassUnexpected indicates a failure that the caller should treat as
// signalling a durable, non-transient upstream problem.
FailureClassUnexpected
)

// classifyHTTPFailure returns the failure classification for an HTTP status code
// received during a data source request, per RETRY §1.6. Called only when the
// status indicates failure (non-2xx).
func classifyHTTPFailure(statusCode int) FailureClass {
if statusCode >= 400 && statusCode < 500 {
switch statusCode {
case 400: // bad request
return true
case 408: // request timeout
return true
case 429: // too many requests
return true
case 400, 408, 429:
return FailureClassNormal
default:
return false // all other 4xx errors are unrecoverable
return FailureClassUnexpected
}
}
return true
return FailureClassNormal
}

// classifyTransportFailure returns the failure classification for a transport-layer
// error (i.e., not an HTTP response, but a lower-level network or TLS failure)
// per RETRY §1.7. TLS/certificate validation failures are treated as unexpected;
// everything else is treated as normal.
func classifyTransportFailure(err error) FailureClass {
if err == nil {
return FailureClassNormal
}
var certErr *tls.CertificateVerificationError
if errors.As(err, &certErr) {
return FailureClassUnexpected
}
var x509UnknownAuthorityErr x509.UnknownAuthorityError
if errors.As(err, &x509UnknownAuthorityErr) {
return FailureClassUnexpected
}
var x509HostnameErr x509.HostnameError
if errors.As(err, &x509HostnameErr) {
return FailureClassUnexpected
}
var x509InvalidErr x509.CertificateInvalidError
if errors.As(err, &x509InvalidErr) {
return FailureClassUnexpected
}
return FailureClassNormal
}

func httpErrorDescription(statusCode int) string {
message := ""
if statusCode == 401 || statusCode == 403 {
message = " (invalid SDK key)"
message = " (authentication failed)"
}
return fmt.Sprintf("HTTP error %d%s", statusCode, message)
}

// Logs an HTTP error or network error at the appropriate level and determines whether it is recoverable
// (as defined by isHTTPErrorRecoverable).
func checkIfErrorIsRecoverableAndLog(
// classifyAndLogHTTPFailure classifies an HTTP failure per RETRY §1.6, logs it,
// and returns the classification for the caller to act on.
func classifyAndLogHTTPFailure(
loggers ldlog.Loggers,
errorDesc, errorContext string,
statusCode int,
recoverableMessage string,
) bool {
if statusCode > 0 && !isHTTPErrorRecoverable(statusCode) {
loggers.Errorf("Error %s (giving up permanently): %s", errorContext, errorDesc)
return false
}
loggers.Warnf("Error %s (%s): %s", errorContext, recoverableMessage, errorDesc)
return true
willRetryMessage string,
) FailureClass {
loggers.Warnf("Error %s (%s): %s", errorContext, willRetryMessage, errorDesc)
Comment thread
tanderson-ld marked this conversation as resolved.
Outdated
return classifyHTTPFailure(statusCode)
}

// classifyAndLogTransportFailure classifies a transport-layer failure per RETRY
// §1.7, logs it, and returns the classification.
func classifyAndLogTransportFailure(
loggers ldlog.Loggers,
err error,
errorContext, willRetryMessage string,
) FailureClass {
loggers.Warnf("Error %s (%s): %s", errorContext, willRetryMessage, err.Error())
return classifyTransportFailure(err)
}

func checkForHTTPError(statusCode int, url string) error {
if statusCode == http.StatusUnauthorized {
return httpStatusError{
Message: fmt.Sprintf("Invalid SDK key when accessing URL: %s. Verify that your SDK key is correct.", url),
Code: statusCode}
Message: fmt.Sprintf("Authentication failed for URL: %s. If this persists, verify that your SDK key is correct.",
url),
Code: statusCode}
}

if statusCode == http.StatusNotFound {
Expand Down
42 changes: 37 additions & 5 deletions internal/datasource/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package datasource

import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"strconv"
"testing"

Expand All @@ -12,19 +16,47 @@ func TestHTTPStatusError(t *testing.T) {
assert.Equal(t, "message", error.Error())
}

func TestIsHTTPErrorRecoverable(t *testing.T) {
// classifyHTTPFailure per RETRY §1.6: 400, 408, 429 are normal (transient
// server-side conditions); all other 4xx are unexpected (durable client-side
// misconfiguration); 5xx and everything else are normal.
func TestClassifyHTTPFailure(t *testing.T) {
for i := 400; i < 500; i++ {
Comment thread
tanderson-ld marked this conversation as resolved.
assert.Equal(t, i == 400 || i == 408 || i == 429, isHTTPErrorRecoverable(i), strconv.Itoa(i))
expected := FailureClassNormal
if !(i == 400 || i == 408 || i == 429) {
expected = FailureClassUnexpected
}
assert.Equal(t, expected, classifyHTTPFailure(i), strconv.Itoa(i))
}
for i := 500; i < 600; i++ {
assert.True(t, isHTTPErrorRecoverable(i))
assert.Equal(t, FailureClassNormal, classifyHTTPFailure(i), strconv.Itoa(i))
}
}

// classifyTransportFailure per RETRY §1.7: TLS/certificate validation failures
// are unexpected; other transport-layer errors are normal.
func TestClassifyTransportFailure(t *testing.T) {
assert.Equal(t, FailureClassNormal, classifyTransportFailure(nil))
assert.Equal(t, FailureClassNormal, classifyTransportFailure(errors.New("boom")))

// TLS certificate errors.
assert.Equal(t, FailureClassUnexpected,
classifyTransportFailure(&tls.CertificateVerificationError{}))
assert.Equal(t, FailureClassUnexpected,
classifyTransportFailure(x509.UnknownAuthorityError{}))
assert.Equal(t, FailureClassUnexpected,
classifyTransportFailure(x509.HostnameError{Host: "example.invalid"}))
assert.Equal(t, FailureClassUnexpected,
classifyTransportFailure(x509.CertificateInvalidError{Reason: x509.Expired}))

// Wrapped errors are still classified correctly.
wrapped := fmt.Errorf("some wrapper: %w", x509.UnknownAuthorityError{})
assert.Equal(t, FailureClassUnexpected, classifyTransportFailure(wrapped))
}

func TestHTTPErrorDescription(t *testing.T) {
assert.Equal(t, "HTTP error 400", httpErrorDescription(400))
assert.Equal(t, "HTTP error 401 (invalid SDK key)", httpErrorDescription(401))
assert.Equal(t, "HTTP error 403 (invalid SDK key)", httpErrorDescription(403))
assert.Equal(t, "HTTP error 401 (authentication failed)", httpErrorDescription(401))
assert.Equal(t, "HTTP error 403 (authentication failed)", httpErrorDescription(403))
assert.Equal(t, "HTTP error 500", httpErrorDescription(500))
}

Expand Down
79 changes: 35 additions & 44 deletions internal/datasource/polling_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ const (
// PollingConfig describes the configuration for a polling data source. It is exported so that
// it can be used in the PollingDataSourceBuilder.
type PollingConfig struct {
BaseURI string
PollInterval time.Duration
FilterKey string
BaseURI string
PollInterval time.Duration
FilterKey string
ExtendedInitialPollInterval time.Duration
}

// Requester allows PollingProcessor to delegate fetching data to another component.
Expand All @@ -43,6 +44,7 @@ type PollingProcessor struct {
dataSourceUpdates subsystems.DataSourceUpdateSink
requester Requester
pollInterval time.Duration
strategy *pollingStrategy
loggers ldlog.Loggers
setInitializedOnce sync.Once
isInitialized internal.AtomicBoolean
Expand All @@ -57,19 +59,24 @@ func NewPollingProcessor(
cfg PollingConfig,
) *PollingProcessor {
httpRequester := NewPollingRequester(context, context.GetHTTP().CreateHTTPClient(), cfg.BaseURI, cfg.FilterKey)
return newPollingProcessor(context, dataSourceUpdates, httpRequester, cfg.PollInterval)
return newPollingProcessor(
context, dataSourceUpdates, httpRequester,
cfg.PollInterval, cfg.ExtendedInitialPollInterval,
)
}

func newPollingProcessor(
context subsystems.ClientContext,
dataSourceUpdates subsystems.DataSourceUpdateSink,
requester Requester,
pollInterval time.Duration,
extendedInitialPollInterval time.Duration,
) *PollingProcessor {
pp := &PollingProcessor{
dataSourceUpdates: dataSourceUpdates,
requester: requester,
pollInterval: pollInterval,
strategy: newPollingStrategy(pollInterval, extendedInitialPollInterval),
loggers: context.GetLogging().Loggers,
quit: make(chan struct{}),
}
Expand All @@ -80,10 +87,13 @@ func newPollingProcessor(
func (pp *PollingProcessor) Start(closeWhenReady chan<- struct{}) {
pp.loggers.Infof("Starting LaunchDarkly polling with interval: %+v", pp.pollInterval)

ticker := newTickerWithInitialTick(pp.pollInterval)
// Fires immediately for the first poll; Reset after each iteration to schedule
// the next. Under RETRY (SDK-2775), the interval between polls is dynamic per
Comment thread
tanderson-ld marked this conversation as resolved.
Outdated
// the pollingStrategy state machine, so we can't use a fixed-period Ticker.
timer := time.NewTimer(0)

go func() {
defer ticker.Stop()
defer timer.Stop()

var readyOnce sync.Once
notifyReady := func() {
Expand All @@ -98,28 +108,23 @@ func (pp *PollingProcessor) Start(closeWhenReady chan<- struct{}) {
select {
case <-pp.quit:
return
case <-ticker.C:
case <-timer.C:
if err := pp.poll(); err != nil {
var class FailureClass
if hse, ok := err.(httpStatusError); ok {
errorInfo := interfaces.DataSourceErrorInfo{
Kind: interfaces.DataSourceErrorKindErrorResponse,
StatusCode: hse.Code,
Time: time.Now(),
}
recoverable := checkIfErrorIsRecoverableAndLog(
class = classifyAndLogHTTPFailure(
pp.loggers,
httpErrorDescription(hse.Code),
pollingErrorContext,
hse.Code,
pollingWillRetryMessage,
)
if recoverable {
pp.dataSourceUpdates.UpdateStatus(interfaces.DataSourceStateInterrupted, errorInfo)
} else {
pp.dataSourceUpdates.UpdateStatus(interfaces.DataSourceStateOff, errorInfo)
notifyReady()
return
}
pp.dataSourceUpdates.UpdateStatus(interfaces.DataSourceStateInterrupted, errorInfo)
} else {
errorInfo := interfaces.DataSourceErrorInfo{
Kind: interfaces.DataSourceErrorKindNetworkError,
Expand All @@ -129,17 +134,24 @@ func (pp *PollingProcessor) Start(closeWhenReady chan<- struct{}) {
if _, ok := err.(malformedJSONError); ok {
errorInfo.Kind = interfaces.DataSourceErrorKindInvalidData
}
checkIfErrorIsRecoverableAndLog(pp.loggers, err.Error(), pollingErrorContext, 0, pollingWillRetryMessage)
class = classifyAndLogTransportFailure(
pp.loggers, err, pollingErrorContext, pollingWillRetryMessage,
)
pp.dataSourceUpdates.UpdateStatus(interfaces.DataSourceStateInterrupted, errorInfo)
}
continue
if pp.strategy.OnFailure(class) {
pp.loggers.Info("Classified failure as UNEXPECTED; engaging extended backoff.")
Comment thread
cursor[bot] marked this conversation as resolved.
}
} else {
pp.dataSourceUpdates.UpdateStatus(interfaces.DataSourceStateValid, interfaces.DataSourceErrorInfo{})
pp.setInitializedOnce.Do(func() {
pp.isInitialized.Set(true)
pp.loggers.Info("First polling request successful")
notifyReady()
})
pp.strategy.OnSuccess()
}
pp.dataSourceUpdates.UpdateStatus(interfaces.DataSourceStateValid, interfaces.DataSourceErrorInfo{})
pp.setInitializedOnce.Do(func() {
pp.isInitialized.Set(true)
pp.loggers.Info("First polling request successful")
notifyReady()
})
timer.Reset(pp.strategy.NextWait())
}
}
}()
Expand Down Expand Up @@ -190,24 +202,3 @@ func (pp *PollingProcessor) GetPollInterval() time.Duration {
func (pp *PollingProcessor) GetFilterKey() string {
return pp.requester.FilterKey()
}

type tickerWithInitialTick struct {
*time.Ticker
C <-chan time.Time
}

func newTickerWithInitialTick(interval time.Duration) *tickerWithInitialTick {
c := make(chan time.Time)
ticker := time.NewTicker(interval)
t := &tickerWithInitialTick{
C: c,
Ticker: ticker,
}
go func() {
c <- time.Now() // Ensure we do an initial poll immediately
for tt := range ticker.C {
c <- tt
}
}()
return t
}
Loading
Loading