Skip to content
Merged
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
49 changes: 49 additions & 0 deletions chain_capabilities/common/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
164 changes: 164 additions & 0 deletions chain_capabilities/common/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading