Skip to content
Open
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
5 changes: 5 additions & 0 deletions AI_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ PR description. Add an `Assisted-by:` trailer to each affected commit:
Assisted-by: GitHub Copilot
Assisted-by: Claude Code
Assisted-by: ChatGPT o3
Assisted-by: OpenCode
```

Use the name of the AI coding tool that actually produced the work (e.g.
`OpenCode`, `Claude Code`, `GitHub Copilot`), not the underlying model. Do not
blindly copy an example — pick the value that matches the tool you are running.

Disclosure is not a penalty — it is trust infrastructure. It preserves
transparency, helps reviewers calibrate their attention, and keeps provenance
clear for the project's long-term health.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,11 @@ Code reviews are required for all submissions via GitHub pull requests.
Assisted-by: GitHub Copilot
Assisted-by: Claude Code
Assisted-by: ChatGPT o3
Assisted-by: OpenCode
```

Use the name of the AI coding tool that actually produced the work (e.g. `OpenCode`, `Claude Code`, `GitHub Copilot`), not the underlying model. Do not blindly copy an example — pick the value that matches the tool you are running.

2. **The PR description** MUST disclose the AI assistance.

Apply this on the *first* commit/PR you create — do not wait for review feedback to add it. If you amend or rebase a commit that previously lacked the trailer, add it as part of the amend.
Expand Down
86 changes: 86 additions & 0 deletions app/controlplane/internal/dispatcher/dispatch_timeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package dispatcher

import (
"context"
"errors"
"io"
"testing"
"time"

"github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1"
mockedSDK "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1/mocks"
"github.com/go-kratos/kratos/v2/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// setMaxDispatchElapsedTimeForTest temporarily shrinks the total retry
// budget so tests don't wait for the real 5m. The returned func restores
// the production value.
func setMaxDispatchElapsedTimeForTest(d time.Duration) func() {
prev := maxDispatchElapsedTime
maxDispatchElapsedTime = d
return func() { maxDispatchElapsedTime = prev }
}

// TestDispatchSucceedsOnRetry verifies that a transient failure followed by
// success stops the retry loop and returns nil.
func TestDispatchSucceedsOnRetry(t *testing.T) {
// Safety net: if the mock setup is wrong, fail fast instead of waiting 5m.
restore := setMaxDispatchElapsedTimeForTest(2 * time.Second)
t.Cleanup(restore)

plugin := mockedSDK.NewFanOut(t)
plugin.On("String", mock.Anything).Return("test-plugin").Maybe()

// First call fails, second call succeeds. .Once() ensures each
// expectation is consumed exactly once, so the second call returns nil.
plugin.On("Execute", mock.Anything, mock.Anything).Return(errors.New("transient failure")).Once()
plugin.On("Execute", mock.Anything, mock.Anything).Return(nil).Once()

logger := log.NewHelper(log.NewStdLogger(io.Discard))
opts := &sdk.ExecutionRequest{
Input: &sdk.ExecuteInput{
Attestation: &sdk.ExecuteAttestation{},
},
}

err := dispatch(context.Background(), plugin, opts, logger)
require.NoError(t, err)

var execCalls int
for _, c := range plugin.Calls {
if c.Method == "Execute" {
execCalls++
}
}
assert.Equal(t, 2, execCalls, "should have called Execute exactly twice")
}

// TestDispatchNoInput verifies the fast-fail path that doesn't enter the retry loop.
func TestDispatchNoInput(t *testing.T) {
plugin := mockedSDK.NewFanOut(t)
logger := log.NewHelper(log.NewStdLogger(io.Discard))
opts := &sdk.ExecutionRequest{Input: &sdk.ExecuteInput{}}

err := dispatch(context.Background(), plugin, opts, logger)
require.Error(t, err)
assert.Contains(t, err.Error(), "no input provided")
plugin.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
}
15 changes: 13 additions & 2 deletions app/controlplane/internal/dispatcher/dispatcher.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Canceled dispatches can keep retrying until the full 5-minute retry budget because the retry policy is no longer wrapped with the parent context. Using backoff.WithContext(b, ctx) here preserves the longer retry budget while stopping promptly when the caller cancels.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/controlplane/internal/dispatcher/dispatcher.go, line 322:

<comment>Canceled dispatches can keep retrying until the full 5-minute retry budget because the retry policy is no longer wrapped with the parent context. Using backoff.WithContext(b, ctx) here preserves the longer retry budget while stopping promptly when the caller cancels.</comment>

<file context>
@@ -310,21 +310,16 @@ func dispatch(ctx context.Context, plugin sdk.FanOut, opts *sdk.ExecutionRequest
 			return err
 		},
-		backoff.WithContext(b, ctx),
+		b,
 		func(err error, delay time.Duration) {
 			logger.Warnf("error executing integration %s, will retry in %s - %s", plugin.String(), delay, err)
</file context>
Suggested change
//
backoff.WithContext(b, ctx),

// Copyright 2023 The Chainloop Authors.
// Copyright 2023-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -48,6 +48,17 @@ type FanOutDispatcher struct {
loaded sdk.AvailablePlugins
}

// maxDispatchElapsedTime bounds the total time the dispatcher will keep
// retrying a single integration delivery. With the default exponential
// backoff (500ms initial, ×1.5 multiplier, 60s max interval) this yields
// roughly 12-15 attempts — enough to ride out a transient blip or a brief
// endpoint restart, while staying short enough not to leak goroutines in
// the current in-process, fire-and-forget dispatch model.
//
// It is a var (not a const) so tests can shrink it to avoid waiting for
// the real 5m budget. Production code must not mutate it.
var maxDispatchElapsedTime = 5 * time.Minute

func New(integrationUC *biz.IntegrationUseCase, wfUC *biz.WorkflowUseCase, wfRunUC *biz.WorkflowRunUseCase, creds credentials.ReaderWriter, c biz.CASClient, registered sdk.AvailablePlugins, l log.Logger) *FanOutDispatcher {
return &FanOutDispatcher{integrationUC, wfUC, wfRunUC, creds, c, servicelogger.ScopedHelper(l, "fanout-dispatcher"), l, registered}
}
Expand Down Expand Up @@ -280,7 +291,7 @@ func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue,

func dispatch(ctx context.Context, plugin sdk.FanOut, opts *sdk.ExecutionRequest, logger *log.Helper) error {
b := backoff.NewExponentialBackOff()
b.MaxElapsedTime = 10 * time.Second
b.MaxElapsedTime = maxDispatchElapsedTime

var inputType string
switch {
Expand Down
15 changes: 10 additions & 5 deletions app/controlplane/plugins/core/webhook/v1/webhook.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 The Chainloop Authors.
// Copyright 2025-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -20,15 +20,20 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/url"
"time"

schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1"
"github.com/go-kratos/kratos/v2/log"
)

// perAttemptTimeout caps how long a single webhook HTTP call may take,
// preventing a hung endpoint from blocking retries indefinitely.
const perAttemptTimeout = 10 * time.Second

// Integration implements a generic webhook integration
type Integration struct {
*sdk.FanOutIntegration
Expand Down Expand Up @@ -69,7 +74,7 @@ func New(l log.Logger) (sdk.FanOut, error) {
base, err := sdk.NewFanOut(
&sdk.NewParams{
ID: "webhook",
Version: "1.1",
Version: "1.2",
Description: "Send Attestation and SBOMs to a generic POST webhook URL",
Logger: l,
InputSchema: &sdk.InputSchema{
Expand All @@ -87,7 +92,7 @@ func New(l log.Logger) (sdk.FanOut, error) {

return &Integration{
FanOutIntegration: base,
client: &http.Client{},
client: &http.Client{Timeout: perAttemptTimeout},
}, nil
}

Expand Down Expand Up @@ -259,7 +264,7 @@ func (i *Integration) sendWebhook(ctx context.Context, url, kind string, payload
}()

// Read response body for more detailed error messages
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
i.Logger.Warnw("failed to read response body", "error", err)
}
Expand Down
Loading