diff --git a/chain_capabilities/common/helpers.go b/chain_capabilities/common/helpers.go index a4a6bd22a..2bc88d00f 100644 --- a/chain_capabilities/common/helpers.go +++ b/chain_capabilities/common/helpers.go @@ -15,6 +15,7 @@ import ( caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" "github.com/smartcontractkit/chainlink-common/pkg/utils/retry" commonmon "github.com/smartcontractkit/capabilities/libs/monitoring" @@ -172,3 +173,51 @@ func WithRetry[T any](ctx context.Context, lggr logger.Logger, fn func(context.C } return result, nil } + +// MaxRequestTimeout returns the maximum RequestTimeout configured across +// capabilityID's CapabilityMethodConfig entries for donID. Method configs for +// WriteReport and LogTrigger methods are excluded — their timeout semantics +// differ from regular executable methods. If the config can't be fetched or no +// eligible RemoteExecutableConfig.RequestTimeout values are found, it returns +// fallback. +func MaxRequestTimeout(ctx context.Context, registry core.CapabilitiesRegistry, capabilityID string, donID uint32, fallback time.Duration, lggr logger.Logger) time.Duration { + if registry == nil { + return fallback + } + + cfg, err := WithPollingRetry(ctx, lggr, func(ctx context.Context) (capabilities.CapabilityConfiguration, error) { + return registry.ConfigForCapability(ctx, capabilityID, donID) + }) + if err != nil { + lggr.Errorw("failed getting config for capability", "capabilityID", capabilityID, "error", err) + return fallback + } + + var maxTimeout time.Duration + var count int + for method, methodCfg := range cfg.CapabilityMethodConfig { + if isNonReadMethod(method) { + continue + } + if methodCfg.RemoteExecutableConfig == nil || methodCfg.RemoteExecutableConfig.RequestTimeout == 0 { + continue + } + if methodCfg.RemoteExecutableConfig.RequestTimeout > maxTimeout { + maxTimeout = methodCfg.RemoteExecutableConfig.RequestTimeout + } + count++ + } + if count == 0 { + return fallback + } + return maxTimeout +} + +func isNonReadMethod(method string) bool { + switch method { + case "WriteReport", "LogTrigger": + return true + default: + return false + } +} diff --git a/chain_capabilities/common/helpers_test.go b/chain_capabilities/common/helpers_test.go new file mode 100644 index 000000000..ad40ebf7c --- /dev/null +++ b/chain_capabilities/common/helpers_test.go @@ -0,0 +1,164 @@ +package capcommon + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/core/mocks" +) + +func TestAverageRequestTimeout(t *testing.T) { + t.Parallel() + + const capID = "evm:ChainSelector:42@1.0.0" + const donID = uint32(10) + const fallback = 5 * time.Second + lggr := logger.Test(t) + + // withTimeout returns a RemoteExecutableConfig with the given RequestTimeout. + withTimeout := func(d time.Duration) *capabilities.RemoteExecutableConfig { + return &capabilities.RemoteExecutableConfig{RequestTimeout: d} + } + + // configWith builds a CapabilityConfiguration with one method config per + // RemoteExecutableConfig, keyed by the given method names. Nil entries yield + // method configs without a RemoteExecutableConfig. + configWith := func(recs ...*capabilities.RemoteExecutableConfig) capabilities.CapabilityConfiguration { + methodCfgs := make(map[string]capabilities.CapabilityMethodConfig, len(recs)) + for i, rec := range recs { + methodCfgs[fmt.Sprintf("method-%d", i)] = capabilities.CapabilityMethodConfig{RemoteExecutableConfig: rec} + } + return capabilities.CapabilityConfiguration{CapabilityMethodConfig: methodCfgs} + } + + // configWithMethods is like configWith but takes explicit method names. + configWithMethods := func(methodCfgs map[string]capabilities.CapabilityMethodConfig) capabilities.CapabilityConfiguration { + return capabilities.CapabilityConfiguration{CapabilityMethodConfig: methodCfgs} + } + + t.Run("returns fallback when registry is nil", func(t *testing.T) { + t.Parallel() + + got := MaxRequestTimeout(context.Background(), nil, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("returns max RequestTimeout across method configs", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + withTimeout(1*time.Second), + withTimeout(2*time.Second), + withTimeout(3*time.Second), + ), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 3*time.Second, got) + }) + + t.Run("skips method configs without RemoteExecutableConfig or zero RequestTimeout", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + nil, + withTimeout(0), + withTimeout(4*time.Second), + withTimeout(8*time.Second), + ), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 8*time.Second, got) + }) + + t.Run("returns fallback when no method config has a RequestTimeout", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + nil, + withTimeout(0), + ), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("returns fallback when config is empty", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith(), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("excludes WriteReport and LogTrigger methods from the max", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWithMethods(map[string]capabilities.CapabilityMethodConfig{ + "CallContract": {RemoteExecutableConfig: withTimeout(2 * time.Second)}, + "FilterLogs": {RemoteExecutableConfig: withTimeout(4 * time.Second)}, + "WriteReport": {RemoteExecutableConfig: withTimeout(100 * time.Second)}, + "LogTrigger": {RemoteExecutableConfig: withTimeout(200 * time.Second)}, + }), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 4*time.Second, got) + }) + + t.Run("returns fallback when only WriteReport and LogTrigger have RequestTimeout", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWithMethods(map[string]capabilities.CapabilityMethodConfig{ + "WriteReport": {RemoteExecutableConfig: withTimeout(100 * time.Second)}, + "LogTrigger": {RemoteExecutableConfig: withTimeout(200 * time.Second)}, + }), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("returns fallback when config fetch fails", func(t *testing.T) { + t.Parallel() + + // Cancelled ctx makes WithPollingRetry give up after the first failure + // instead of retrying for up to 60s. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID). + Return(capabilities.CapabilityConfiguration{}, errors.New("boom")) + + got := MaxRequestTimeout(ctx, reg, capID, donID, fallback, lggr) + require.Equal(t, fallback, got) + }) + + t.Run("retries after a transient error and returns max", func(t *testing.T) { + t.Parallel() + + reg := mocks.NewCapabilitiesRegistry(t) + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID). + Return(capabilities.CapabilityConfiguration{}, errors.New("metadataRegistry information not available")).Once() + reg.EXPECT().ConfigForCapability(mock.Anything, capID, donID).Return(configWith( + withTimeout(1*time.Second), + withTimeout(3*time.Second), + ), nil) + + got := MaxRequestTimeout(context.Background(), reg, capID, donID, fallback, lggr) + require.Equal(t, 3*time.Second, got) + }) +}