From 659db15881245485a9deb7eceb18fe6d3a384323 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Fri, 8 May 2026 15:26:35 +0200 Subject: [PATCH 1/3] feat: add options to configure split polling and execution config merging behavior --- router/core/init_config_poller.go | 9 ++++++ router/core/router.go | 7 +++-- router/core/supervisor_instance.go | 9 +++--- router/pkg/config/config.go | 14 ++++++++++ router/pkg/config/config.schema.json | 17 +++++++++++ router/pkg/config/fixtures/full.yaml | 6 ++++ .../pkg/config/testdata/config_defaults.json | 4 +++ router/pkg/config/testdata/config_full.json | 7 +++++ .../configpoller/config_poller.go | 12 ++++---- .../configpoller/split_config_poller.go | 28 +++++++++++++++++++ router/pkg/errs/errors.go | 16 +++++++++++ router/pkg/routerconfig/cdn/client.go | 24 ++++++---------- router/pkg/routerconfig/cdn/split_fetcher.go | 11 ++++---- .../routerconfig/cdn/split_fetcher_test.go | 7 +++-- router/pkg/routerconfig/s3/client.go | 6 ++-- 15 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 router/pkg/errs/errors.go diff --git a/router/core/init_config_poller.go b/router/core/init_config_poller.go index 38de03c448..1a3aa5f26c 100644 --- a/router/core/init_config_poller.go +++ b/router/core/init_config_poller.go @@ -204,10 +204,19 @@ func newSplitConfigPoller(r *Router) (*configpoller.ConfigPoller, error) { return nil, fmt.Errorf("failed to create split config fetcher: %w", err) } + ignoredFeatureFlags := make(map[string]struct{}) + for _, featureFlag := range r.routerConfigPollerConfig.SplitConfigPoller.IgnoredFeatureFlags { + ignoredFeatureFlags[featureFlag] = struct{}{} + } + splitPoller := configpoller.NewSplitConfigPoller( fetcher, configpoller.WithSplitLogger(r.logger), configpoller.WithSplitPolling(r.routerConfigPollerConfig.PollInterval, r.routerConfigPollerConfig.PollJitter), + configpoller.WithConfigRules(configpoller.ConfigRules{ + SkipMissingFeatureFlags: r.routerConfigPollerConfig.SplitConfigPoller.SkipMissingFeatureFlags, + IgnoredFeatureFlags: ignoredFeatureFlags, + }), ) return &splitPoller, nil } diff --git a/router/core/router.go b/router/core/router.go index 4dc4a5a9fe..849dc356bc 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -157,9 +157,10 @@ type ( RouterConfigPollerConfig struct { config.ExecutionConfig - PollInterval time.Duration - PollJitter time.Duration - GraphSignKey string + PollInterval time.Duration + PollJitter time.Duration + GraphSignKey string + SplitConfigPoller config.SplitConfigPollerRules } ExecutionConfig struct { diff --git a/router/core/supervisor_instance.go b/router/core/supervisor_instance.go index 1fd8eb8acb..70abb409ae 100644 --- a/router/core/supervisor_instance.go +++ b/router/core/supervisor_instance.go @@ -172,10 +172,11 @@ func newRouter(ctx context.Context, params RouterResources, additionalOptions .. })) } else { options = append(options, WithConfigPollerConfig(&RouterConfigPollerConfig{ - GraphSignKey: cfg.Graph.SignKey, - PollInterval: cfg.PollInterval, - PollJitter: cfg.PollJitter, - ExecutionConfig: cfg.ExecutionConfig, + GraphSignKey: cfg.Graph.SignKey, + PollInterval: cfg.PollInterval, + PollJitter: cfg.PollJitter, + ExecutionConfig: cfg.ExecutionConfig, + SplitConfigPoller: cfg.SplitConfigPoller, })) } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 225f68c6b4..64d0fbb294 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -988,6 +988,19 @@ type ExecutionConfigFile struct { WatchInterval time.Duration `yaml:"watch_interval,omitempty" envDefault:"1s" env:"EXECUTION_CONFIG_FILE_WATCH_INTERVAL"` } +// SplitConfigPollerRules governs the behavior of the split-config polling strategy used to +// assemble the final router execution config. The split poller fetches the base graph and each +// feature flag config as separate files from the CDN. These rules apply when individual files +// are missing or should be excluded entirely. +type SplitConfigPollerRules struct { + // SkipMissingFeatureFlags keeps polling alive when a feature flag listed in the mapper cannot + // be fetched. When false (default), a single missing feature flag aborts the poll cycle. + SkipMissingFeatureFlags bool `yaml:"skip_missing_feature_flags" envDefault:"false" env:"SKIP_MISSING_FEATURE_FLAGS"` + // IgnoredFeatureFlags is the list of feature flag names to skip entirely during polling. + // Listed flags are not fetched even when present in the mapper. + IgnoredFeatureFlags []string `yaml:"ignored_feature_flags,omitempty" env:"IGNORED_FEATURE_FLAGS"` +} + type ExecutionConfig struct { File ExecutionConfigFile `yaml:"file,omitempty"` Storage ExecutionConfigStorage `yaml:"storage,omitempty" envPrefix:"EXECUTION_CONFIG_STORAGE_"` @@ -1305,6 +1318,7 @@ type Config struct { StorageProviders StorageProviders `yaml:"storage_providers" envPrefix:"STORAGE_PROVIDER_"` ExecutionConfig ExecutionConfig `yaml:"execution_config"` + SplitConfigPoller SplitConfigPollerRules `yaml:"split_config_poller" envPrefix:"SPLIT_CONFIG_POLLER_"` PersistedOperationsConfig PersistedOperationsConfig `yaml:"persisted_operations" envPrefix:"PERSISTED_OPERATIONS_"` AutomaticPersistedQueries AutomaticPersistedQueriesConfig `yaml:"automatic_persisted_queries"` ApolloCompatibilityFlags ApolloCompatibilityFlags `yaml:"apollo_compatibility_flags"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 54fa556e93..9d0aa4111e 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -402,6 +402,23 @@ } ] }, + "split_config_poller": { + "type": "object", + "description": "Behavior overrides for the split-config polling strategy, which assembles the final router execution config by fetching the base graph and each feature flag config as separate files from the CDN. Only applied when the router is enrolled in split-config loading.", + "additionalProperties": false, + "properties": { + "skip_missing_feature_flags": { + "type": "boolean", + "default": false, + "description": "Skip feature flags that cannot be fetched instead of aborting the poll cycle. Use this when occasional feature flag fetch failures should not stop the router from picking up changes to other graphs." + }, + "ignored_feature_flags": { + "type": "array", + "items": { "type": "string" }, + "description": "Feature flag names to skip entirely during config polling. Listed flags are not fetched even when present in the mapper." + } + } + }, "graphql_metrics": { "type": "object", "additionalProperties": false, diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index eed7b3f973..1dbaa22ce9 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -534,6 +534,12 @@ execution_config: provider_id: s3 object_path: '5ef73d80-cae4-4d0e-98a7-1e9fa922c1a4/92c25b45-a75b-4954-b8f6-6592a9b203eb/routerconfigs/latest.json' +split_config_poller: + skip_missing_feature_flags: true + ignored_feature_flags: + - 'experimental-checkout' + - 'ab-test-foo' + router_config_path: 'latest.json' client_header: diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index d64a0b40fb..4f9422c091 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -548,6 +548,10 @@ "ObjectPath": "" } }, + "SplitConfigPoller": { + "SkipMissingFeatureFlags": false, + "IgnoredFeatureFlags": null + }, "PersistedOperationsConfig": { "Disabled": false, "LogUnknown": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index fbc42cf002..e3be23b88a 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -993,6 +993,13 @@ "ObjectPath": "" } }, + "SplitConfigPoller": { + "SkipMissingFeatureFlags": true, + "IgnoredFeatureFlags": [ + "experimental-checkout", + "ab-test-foo" + ] + }, "PersistedOperationsConfig": { "Disabled": false, "LogUnknown": true, diff --git a/router/pkg/controlplane/configpoller/config_poller.go b/router/pkg/controlplane/configpoller/config_poller.go index 0c34bd2aa2..92a371acc4 100644 --- a/router/pkg/controlplane/configpoller/config_poller.go +++ b/router/pkg/controlplane/configpoller/config_poller.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "github.com/wundergraph/cosmo/router/pkg/controlplane" @@ -13,9 +14,6 @@ import ( type Option func(cp *configPoller) -var ErrConfigNotModified = errors.New("config not modified") -var ErrConfigNotFound = errors.New("config not found") - type ConfigPoller interface { // Subscribe subscribes to the config poller with a handler function that will be invoked // with the latest router config. If the handler takes longer than the poll interval @@ -68,7 +66,7 @@ func (c *configPoller) Subscribe(ctx context.Context, handler func(newConfig *ro cfg, err := c.getRouterConfig(ctx) if err != nil { - if errors.Is(err, ErrConfigNotModified) { + if errors.Is(err, errs.ErrConfigNotModified) { c.logger.Debug("No new router config available. Trying again ...", zap.String("poll_interval", c.pollInterval.String()), zap.String("fetch_time", time.Since(start).String()), @@ -135,11 +133,11 @@ func (c *configPoller) getRouterConfig(ctx context.Context) (*routerconfig.Respo return config, nil } - if errors.Is(err, ErrConfigNotModified) { + if errors.Is(err, errs.ErrConfigNotModified) { return nil, err } - if c.demoMode && c.fallbackConfigClient == nil && errors.Is(err, ErrConfigNotFound) { + if c.demoMode && c.fallbackConfigClient == nil && errors.Is(err, errs.ErrRouterConfigNotFound) { c.logger.Warn("The router is running in demo mode and no execution config has been found, using a demo execution config for testing purposes.") return &routerconfig.Response{Config: routerconfig.GetDefaultConfig()}, nil } @@ -151,7 +149,7 @@ func (c *configPoller) getRouterConfig(ctx context.Context) (*routerconfig.Respo c.logger.Warn("Failed to retrieve execution config. Attempting with fallback storage") config, err = (*c.fallbackConfigClient).RouterConfig(ctx, c.latestRouterConfigVersion, c.latestRouterConfigDate) - if c.demoMode && errors.Is(err, ErrConfigNotFound) { + if c.demoMode && errors.Is(err, errs.ErrRouterConfigNotFound) { return &routerconfig.Response{Config: routerconfig.GetDefaultConfig()}, nil } if err != nil { diff --git a/router/pkg/controlplane/configpoller/split_config_poller.go b/router/pkg/controlplane/configpoller/split_config_poller.go index 855a4b44e2..915e182093 100644 --- a/router/pkg/controlplane/configpoller/split_config_poller.go +++ b/router/pkg/controlplane/configpoller/split_config_poller.go @@ -2,6 +2,7 @@ package configpoller import ( "context" + "errors" "fmt" "maps" "slices" @@ -10,6 +11,7 @@ import ( "github.com/cespare/xxhash/v2" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/pkg/controlplane" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.uber.org/zap" "google.golang.org/protobuf/proto" @@ -29,6 +31,11 @@ type SplitConfigFetcher interface { // SplitConfigPollerOption configures a splitConfigPoller. type SplitConfigPollerOption func(*splitConfigPoller) +type ConfigRules struct { + SkipMissingFeatureFlags bool + IgnoredFeatureFlags map[string]struct{} +} + type splitConfigPoller struct { logger *zap.Logger poller controlplane.Poller @@ -40,6 +47,7 @@ type splitConfigPoller struct { knownHashes map[string]string // name -> hash from last successful mapper fetch ("" = base) currentConfig *nodev1.RouterConfig // last successfully assembled full config latestVersion string // composite hash used for change detection + configRules ConfigRules // config rules to apply to the config } // NewSplitConfigPoller creates a ConfigPoller that uses the split-config strategy. @@ -73,6 +81,12 @@ func WithSplitPolling(interval time.Duration, jitter time.Duration) SplitConfigP } } +func WithConfigRules(rules ConfigRules) SplitConfigPollerOption { + return func(p *splitConfigPoller) { + p.configRules = rules + } +} + // computeCompositeVersion returns a deterministic version string derived from all mapper entries. func computeCompositeVersion(graphConfigs map[string]string) string { keys := make([]string, 0, len(graphConfigs)) @@ -103,13 +117,27 @@ func (p *splitConfigPoller) fetchAndAssembleAll(ctx context.Context, activeGraph CompatibilityVersion: baseConfig.CompatibilityVersion, } + hasIgnoredFeatureFlags := len(p.configRules.IgnoredFeatureFlags) > 0 + // Fetch feature flag configs. for name := range activeGraphs { if name == "" { continue // base graph already handled above } + + if hasIgnoredFeatureFlags { + if _, ok := p.configRules.IgnoredFeatureFlags[name]; ok { + p.logger.Info("Feature flag is ignored, skipping", zap.String("feature_flag", name)) + continue + } + } + ffConfig, err := p.fetcher.FetchConfig(ctx, name) if err != nil { + if p.configRules.SkipMissingFeatureFlags && errors.Is(err, errs.ErrFileNotFound) { + p.logger.Warn("Feature flag config not found, skipping", zap.String("feature_flag", name)) + continue + } return nil, fmt.Errorf("failed to fetch config for feature flag %q: %w", name, err) } if assembled.FeatureFlagConfigs == nil { diff --git a/router/pkg/errs/errors.go b/router/pkg/errs/errors.go new file mode 100644 index 0000000000..35d2b4977f --- /dev/null +++ b/router/pkg/errs/errors.go @@ -0,0 +1,16 @@ +package errs + +import "errors" + +// config poller errors +var ( + ErrConfigNotModified = errors.New("config not modified") + ErrRouterConfigNotFound = errors.New("router config not found") +) + +// CDN errors +var ( + ErrMissingSignatureHeader = errors.New("signature header not found in CDN response") + ErrInvalidSignature = errors.New("invalid config signature, potential tampering detected") + ErrFileNotFound = errors.New("file not found") +) diff --git a/router/pkg/routerconfig/cdn/client.go b/router/pkg/routerconfig/cdn/client.go index 3bd8289aba..1986174bde 100644 --- a/router/pkg/routerconfig/cdn/client.go +++ b/router/pkg/routerconfig/cdn/client.go @@ -19,7 +19,7 @@ import ( "github.com/wundergraph/cosmo/router/internal/httpclient" "github.com/wundergraph/cosmo/router/internal/jwt" - "github.com/wundergraph/cosmo/router/pkg/controlplane/configpoller" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "github.com/wundergraph/cosmo/router/pkg/execution_config" @@ -30,12 +30,6 @@ const ( sigResponseHeaderName = "X-Signature-SHA256" ) -var ( - ErrMissingSignatureHeader = errors.New("signature header not found in CDN response") - ErrInvalidSignature = errors.New("invalid config signature, potential tampering detected") - ErrConfigNotFound error = &routerConfigNotFoundError{} -) - type Options struct { Logger *zap.Logger SignatureKey string @@ -147,7 +141,7 @@ func (cdn *Client) getRouterConfig(ctx context.Context, version string, _ time.T if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { - return nil, ErrConfigNotFound + return nil, errs.ErrRouterConfigNotFound } if resp.StatusCode == http.StatusUnauthorized { return nil, errors.New("could not authenticate against CDN") @@ -156,7 +150,7 @@ func (cdn *Client) getRouterConfig(ctx context.Context, version string, _ time.T return nil, errors.New("bad request") } if resp.StatusCode == http.StatusNotModified { - return nil, configpoller.ErrConfigNotModified + return nil, errs.ErrConfigNotModified } return nil, fmt.Errorf("unexpected status code when loading router config, statusCode: %d", resp.StatusCode) @@ -193,9 +187,9 @@ func (cdn *Client) getRouterConfig(ctx context.Context, version string, _ time.T if configSignature == "" { cdn.logger.Error( "Signature header not found in CDN response. Ensure that your Admission Controller was able to sign the config. Open the compositions page in the Studio to check the status of the last deployment", - zap.Error(ErrMissingSignatureHeader), + zap.Error(errs.ErrMissingSignatureHeader), ) - return nil, ErrMissingSignatureHeader + return nil, errs.ErrMissingSignatureHeader } // create a signature of the received config body @@ -214,9 +208,9 @@ func (cdn *Client) getRouterConfig(ctx context.Context, version string, _ time.T if subtle.ConstantTimeCompare(rawSignature, dataHmac) != 1 { cdn.logger.Error( "Invalid config signature, potential tampering detected. Ensure that your Admission Controller has signed the config correctly. Open the compositions page in the Studio to check the status of the last deployment", - zap.Error(ErrInvalidSignature), + zap.Error(errs.ErrInvalidSignature), ) - return nil, ErrInvalidSignature + return nil, errs.ErrInvalidSignature } cdn.logger.Info("Config signature validation successful", @@ -232,8 +226,8 @@ func (cdn *Client) RouterConfig(ctx context.Context, version string, modifiedSin res := &routerconfig.Response{} body, err := cdn.getRouterConfig(ctx, version, modifiedSince) - if err != nil && errors.Is(err, ErrConfigNotFound) { - return nil, configpoller.ErrConfigNotFound + if err != nil && errors.Is(err, errs.ErrFileNotFound) { + return nil, errs.ErrRouterConfigNotFound } else if err != nil { return nil, err } diff --git a/router/pkg/routerconfig/cdn/split_fetcher.go b/router/pkg/routerconfig/cdn/split_fetcher.go index c250d1e957..efab90ab9f 100644 --- a/router/pkg/routerconfig/cdn/split_fetcher.go +++ b/router/pkg/routerconfig/cdn/split_fetcher.go @@ -19,6 +19,7 @@ import ( nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/httpclient" "github.com/wundergraph/cosmo/router/internal/jwt" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/execution_config" "go.uber.org/zap" ) @@ -108,7 +109,7 @@ func (f *SplitFetcher) post(ctx context.Context, path string) ([]byte, error) { case http.StatusOK: // handled below case http.StatusNotFound: - return nil, ErrConfigNotFound + return nil, errs.ErrFileNotFound case http.StatusUnauthorized: return nil, errors.New("could not authenticate against CDN") case http.StatusBadRequest: @@ -143,9 +144,9 @@ func (f *SplitFetcher) post(ctx context.Context, path string) ([]byte, error) { if configSignature == "" { f.logger.Error( "Signature header not found in CDN response. Ensure that your Admission Controller was able to sign the config.", - zap.Error(ErrMissingSignatureHeader), + zap.Error(errs.ErrMissingSignatureHeader), ) - return nil, ErrMissingSignatureHeader + return nil, errs.ErrMissingSignatureHeader } if _, err := f.hash.Write(body); err != nil { @@ -162,9 +163,9 @@ func (f *SplitFetcher) post(ctx context.Context, path string) ([]byte, error) { if subtle.ConstantTimeCompare(rawSignature, dataHmac) != 1 { f.logger.Error( "Invalid config signature, potential tampering detected.", - zap.Error(ErrInvalidSignature), + zap.Error(errs.ErrInvalidSignature), ) - return nil, ErrInvalidSignature + return nil, errs.ErrInvalidSignature } f.logger.Info("Config signature validation successful", diff --git a/router/pkg/routerconfig/cdn/split_fetcher_test.go b/router/pkg/routerconfig/cdn/split_fetcher_test.go index ff9b2d8ddb..61e875384f 100644 --- a/router/pkg/routerconfig/cdn/split_fetcher_test.go +++ b/router/pkg/routerconfig/cdn/split_fetcher_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/routerconfig/cdn" "google.golang.org/protobuf/encoding/protojson" ) @@ -123,7 +124,7 @@ func TestFetchMapper_HTTPErrors(t *testing.T) { { name: "not found", statusCode: http.StatusNotFound, - wantErr: cdn.ErrConfigNotFound, + wantErr: errs.ErrFileNotFound, }, { name: "unauthorized", @@ -316,7 +317,7 @@ func TestFetchMapper_Signature(t *testing.T) { { name: "missing signature header", sigKey: sigKey, - wantErr: cdn.ErrMissingSignatureHeader, + wantErr: errs.ErrMissingSignatureHeader, }, { name: "invalid base64 in signature", @@ -328,7 +329,7 @@ func TestFetchMapper_Signature(t *testing.T) { name: "signature mismatch", sigKey: sigKey, respSig: wrongSig, - wantErr: cdn.ErrInvalidSignature, + wantErr: errs.ErrInvalidSignature, }, } diff --git a/router/pkg/routerconfig/s3/client.go b/router/pkg/routerconfig/s3/client.go index 31586ec153..75f9db0f51 100644 --- a/router/pkg/routerconfig/s3/client.go +++ b/router/pkg/routerconfig/s3/client.go @@ -15,7 +15,7 @@ import ( "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" - "github.com/wundergraph/cosmo/router/pkg/controlplane/configpoller" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/execution_config" "github.com/wundergraph/cosmo/router/pkg/routerconfig" ) @@ -141,9 +141,9 @@ func (c Client) RouterConfig(ctx context.Context, _ string, modifiedSince time.T var minioErr minio.ErrorResponse if errors.As(err, &minioErr) { if minioErr.StatusCode == http.StatusNotModified { - return nil, configpoller.ErrConfigNotModified + return nil, errs.ErrConfigNotModified } else if minioErr.Code == "NoSuchKey" { - return nil, configpoller.ErrConfigNotFound + return nil, errs.ErrRouterConfigNotFound } } From a34e6a305d72d3b3ca207c5d28d899e3037d6315 Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Fri, 8 May 2026 15:37:19 +0200 Subject: [PATCH 2/3] chore: add tests --- .../configpoller/split_config_poller.go | 3 + .../configpoller/split_config_poller_test.go | 158 ++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/router/pkg/controlplane/configpoller/split_config_poller.go b/router/pkg/controlplane/configpoller/split_config_poller.go index 915e182093..c900a8323e 100644 --- a/router/pkg/controlplane/configpoller/split_config_poller.go +++ b/router/pkg/controlplane/configpoller/split_config_poller.go @@ -140,11 +140,13 @@ func (p *splitConfigPoller) fetchAndAssembleAll(ctx context.Context, activeGraph } return nil, fmt.Errorf("failed to fetch config for feature flag %q: %w", name, err) } + if assembled.FeatureFlagConfigs == nil { assembled.FeatureFlagConfigs = &nodev1.FeatureFlagRouterExecutionConfigs{ ConfigByFeatureFlagName: make(map[string]*nodev1.FeatureFlagRouterExecutionConfig), } } + assembled.FeatureFlagConfigs.ConfigByFeatureFlagName[name] = &nodev1.FeatureFlagRouterExecutionConfig{ EngineConfig: ffConfig.EngineConfig, Version: ffConfig.Version, @@ -161,6 +163,7 @@ func (p *splitConfigPoller) GetRouterConfig(ctx context.Context) (*routerconfig. if err != nil { return nil, fmt.Errorf("failed to fetch mapper: %w", err) } + if len(activeGraphs) == 0 { return nil, fmt.Errorf("empty graph configs") } diff --git a/router/pkg/controlplane/configpoller/split_config_poller_test.go b/router/pkg/controlplane/configpoller/split_config_poller_test.go index 9edd766921..c0b86fd66f 100644 --- a/router/pkg/controlplane/configpoller/split_config_poller_test.go +++ b/router/pkg/controlplane/configpoller/split_config_poller_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/errs" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.uber.org/zap" ) @@ -143,6 +144,163 @@ func TestSplitGetRouterConfig_ConfigFetchError(t *testing.T) { assert.Contains(t, err.Error(), "CDN unavailable") } +// ---- ConfigRules tests ---- + +func TestSplitGetRouterConfig_IgnoredFeatureFlag_NotFetched(t *testing.T) { + baseCfg := makeRouterConfig("v1") + activeCfg := makeRouterConfig("active-v1") + mock := &mockSplitFetcher{ + mapperResult: map[string]string{ + "": "hash-base", + "active": "hash-active", + "ignored": "hash-ignored", + }, + configResults: map[string]*nodev1.RouterConfig{ + "": baseCfg, + "active": activeCfg, + "ignored": makeRouterConfig("ignored-v1"), + }, + } + + p := newTestPoller(mock) + p.configRules = ConfigRules{ + IgnoredFeatureFlags: map[string]struct{}{"ignored": {}}, + } + + resp, err := p.GetRouterConfig(context.Background()) + require.NoError(t, err) + + require.NotNil(t, resp.Config.FeatureFlagConfigs) + assert.Contains(t, resp.Config.FeatureFlagConfigs.ConfigByFeatureFlagName, "active") + assert.NotContains(t, resp.Config.FeatureFlagConfigs.ConfigByFeatureFlagName, "ignored", + "ignored feature flag must not appear in the assembled config") + + assert.Contains(t, mock.fetchConfigCalls, "active") + assert.NotContains(t, mock.fetchConfigCalls, "ignored", + "ignored feature flag must not be fetched from the CDN") +} + +func TestSplitGetRouterConfig_AllFeatureFlagsIgnored(t *testing.T) { + baseCfg := makeRouterConfig("v1") + mock := &mockSplitFetcher{ + mapperResult: map[string]string{ + "": "hash-base", + "ff1": "hash-ff1", + "ff2": "hash-ff2", + }, + configResults: map[string]*nodev1.RouterConfig{"": baseCfg}, + } + + p := newTestPoller(mock) + p.configRules = ConfigRules{ + IgnoredFeatureFlags: map[string]struct{}{ + "ff1": {}, + "ff2": {}, + }, + } + + resp, err := p.GetRouterConfig(context.Background()) + require.NoError(t, err) + + assert.Nil(t, resp.Config.FeatureFlagConfigs, + "FeatureFlagConfigs should be nil when every feature flag is ignored") + assert.Equal(t, []string{""}, mock.fetchConfigCalls, + "only the base graph should be fetched when all feature flags are ignored") +} + +func TestSplitGetRouterConfig_SkipMissingFeatureFlag_FileNotFoundSkipped(t *testing.T) { + baseCfg := makeRouterConfig("v1") + availableCfg := makeRouterConfig("available-v1") + mock := &mockSplitFetcher{ + mapperResult: map[string]string{ + "": "hash-base", + "available": "hash-available", + "missing": "hash-missing", + }, + configResults: map[string]*nodev1.RouterConfig{ + "": baseCfg, + "available": availableCfg, + }, + configErrors: map[string]error{ + "missing": errs.ErrFileNotFound, + }, + } + + p := newTestPoller(mock) + p.configRules = ConfigRules{SkipMissingFeatureFlags: true} + + resp, err := p.GetRouterConfig(context.Background()) + require.NoError(t, err, "ErrFileNotFound must be tolerated when SkipMissingFeatureFlags is true") + + require.NotNil(t, resp.Config.FeatureFlagConfigs) + assert.Contains(t, resp.Config.FeatureFlagConfigs.ConfigByFeatureFlagName, "available") + assert.NotContains(t, resp.Config.FeatureFlagConfigs.ConfigByFeatureFlagName, "missing") +} + +func TestSplitGetRouterConfig_SkipMissingFeatureFlag_DisabledByDefault(t *testing.T) { + baseCfg := makeRouterConfig("v1") + mock := &mockSplitFetcher{ + mapperResult: map[string]string{ + "": "hash-base", + "missing": "hash-missing", + }, + configResults: map[string]*nodev1.RouterConfig{"": baseCfg}, + configErrors: map[string]error{"missing": errs.ErrFileNotFound}, + } + + p := newTestPoller(mock) + // SkipMissingFeatureFlags defaults to false. + + _, err := p.GetRouterConfig(context.Background()) + require.Error(t, err, "ErrFileNotFound must abort the poll when SkipMissingFeatureFlags is false") + assert.ErrorIs(t, err, errs.ErrFileNotFound) + assert.Contains(t, err.Error(), `"missing"`) +} + +func TestSplitGetRouterConfig_SkipMissingFeatureFlag_OnlyFileNotFoundSuppressed(t *testing.T) { + baseCfg := makeRouterConfig("v1") + transientErr := errors.New("transient CDN failure") + mock := &mockSplitFetcher{ + mapperResult: map[string]string{ + "": "hash-base", + "flaky": "hash-flaky", + }, + configResults: map[string]*nodev1.RouterConfig{"": baseCfg}, + configErrors: map[string]error{"flaky": transientErr}, + } + + p := newTestPoller(mock) + p.configRules = ConfigRules{SkipMissingFeatureFlags: true} + + _, err := p.GetRouterConfig(context.Background()) + require.Error(t, err, "non-ErrFileNotFound errors must propagate even with SkipMissingFeatureFlags enabled") + assert.ErrorIs(t, err, transientErr) + assert.NotErrorIs(t, err, errs.ErrFileNotFound) +} + +func TestSplitGetRouterConfig_BaseConfigCannotBeSkippedOrIgnored(t *testing.T) { + // The skip/ignore rules apply to feature flags only. A missing base config must always + // abort the poll, even when both rules are configured aggressively. + mock := &mockSplitFetcher{ + mapperResult: map[string]string{ + "": "hash-base", + "ff1": "hash-ff1", + }, + configErrors: map[string]error{"": errs.ErrFileNotFound}, + } + + p := newTestPoller(mock) + p.configRules = ConfigRules{ + SkipMissingFeatureFlags: true, + IgnoredFeatureFlags: map[string]struct{}{"": {}}, + } + + _, err := p.GetRouterConfig(context.Background()) + require.Error(t, err, "missing base config must always abort, regardless of skip/ignore rules") + assert.ErrorIs(t, err, errs.ErrFileNotFound) + assert.Contains(t, err.Error(), "base config") +} + // ---- Subscribe / polling tests ---- // pollOnce manually executes one poll iteration using the poller's internal logic. From 0db93307189aa1d335d7fa093b968e13c2c00f1d Mon Sep 17 00:00:00 2001 From: Ludwig Bedacht Date: Fri, 8 May 2026 15:41:56 +0200 Subject: [PATCH 3/3] chore: add to router config docs --- docs-website/router/configuration.mdx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs-website/router/configuration.mdx b/docs-website/router/configuration.mdx index 1c157274a0..77b270e66f 100644 --- a/docs-website/router/configuration.mdx +++ b/docs-website/router/configuration.mdx @@ -1282,6 +1282,31 @@ You can configure a fallback storage for fetching the execution config in the ev | EXECUTION_CONFIG_FALLBACK_STORAGE_PROVIDER_ID | execution_config.fallback_storage.provider_id | | The ID of the storage provider. The ID must match the ID of the storage provider in the `storage_providers` section. | | | EXECUTION_CONFIG_FALLBACK_STORAGE_OBJECT_PATH | execution_config.fallback_storage.object_path | | The path to the execution config in the storage provider. The path is used to download the execution config from the S3 bucket. | | +## Split Config Poller + +The split-config polling strategy assembles the final router execution config by fetching the base graph and each feature flag config as separate files from the CDN. These rules govern its behavior when individual feature flag files are missing or should be excluded entirely. They are only applied when the router is enrolled in split-config loading; with a custom storage provider the router falls back to the regular polling strategy and these rules have no effect. + +The base graph is always required. Skip and ignore rules apply to feature flags only. A missing or unfetchable base graph always aborts the poll cycle, regardless of these rules. + +### Example YAML config: + +```yaml config.yaml +version: "1" + +split_config_poller: + skip_missing_feature_flags: true + ignored_feature_flags: + - "experimental-checkout" + - "ab-test-foo" +``` + +### Split config poller options + +| Environment Variable | YAML | Required | Description | Default Value | +| ------------------------------------------------- | --------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| SPLIT_CONFIG_POLLER_SKIP_MISSING_FEATURE_FLAGS | split_config_poller.skip_missing_feature_flags | | Keep polling alive when a feature flag listed in the mapper cannot be fetched (responds with `file not found`). When false, a single missing feature flag aborts the poll cycle. | false | +| SPLIT_CONFIG_POLLER_IGNORED_FEATURE_FLAGS | split_config_poller.ignored_feature_flags | | Feature flag names to skip entirely during polling. Listed flags are not fetched even when present in the mapper, and are absent from the assembled execution config. | [] | + ## Traffic Shaping Configure rules for traffic shaping like maximum request body size, timeouts, retry behavior, etc. For more info, check this section in the docs: [Traffic shaping](/router/traffic-shaping)