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
33 changes: 32 additions & 1 deletion e2e/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,9 @@ Files over 64KB save fine if they contain only ASCII characters.`
// Bot-authored issues skip issues.opened dispatch (ADR 0054). Apply
// ready-for-triage in a follow-up call so the shim receives issues.labeled
// (#2636).
issue, err := env.client.CreateIssue(ctx, env.org, e2etest.TestRepo, issueTitle, issueBody)
issue, err := createIssueWithRetry(ctx, func() (*forge.Issue, error) {
return env.client.CreateIssue(ctx, env.org, e2etest.TestRepo, issueTitle, issueBody)
}, time.After, t.Logf)
require.NoError(t, err, "creating test issue")
t.Logf("Created test issue #%d: %s", issue.Number, issue.URL)
require.NoError(t, ensureRepoLabel(ctx, env.token, env.org, e2etest.TestRepo, "ready-for-triage"))
Expand Down Expand Up @@ -648,6 +650,35 @@ Files over 64KB save fine if they contain only ASCII characters.`
"issue should have a triage label (needs-info, ready-to-code, duplicate, or blocked), got: %v", labelNames)
}

// createIssueWithRetry retries the transient 401 observed after provisioning,
// while preserving fail-fast behavior for all other GitHub API errors.
func createIssueWithRetry(
ctx context.Context,
create func() (*forge.Issue, error),
after func(time.Duration) <-chan time.Time,
logf func(string, ...any),
) (*forge.Issue, error) {
for attempt := range 3 {
Comment thread
shairevivo marked this conversation as resolved.
issue, err := create()
if err == nil {
return issue, nil
}
var apiErr *gh.APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized || attempt == 2 {
return nil, err
}

delay := time.Duration(attempt+1) * 10 * time.Second
logf("Create issue attempt %d failed with status %d, retrying in %s...", attempt+1, apiErr.StatusCode, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-after(delay):
}
}
panic("unreachable")
}

// saveWorkflowRunDebugInfo fetches logs and artifacts for a workflow run and
// saves them to the screenshot directory. Called unconditionally so that even
// successful runs leave a log trail for diagnosing silent-skip problems.
Expand Down
130 changes: 130 additions & 0 deletions e2e/admin/create_issue_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//go:build e2e

package admin

import (
"context"
"fmt"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/fullsend-ai/fullsend/internal/forge"
gh "github.com/fullsend-ai/fullsend/internal/forge/github"
)

func TestCreateIssueWithRetry_RetriesUnauthorizedThenSucceeds(t *testing.T) {
t.Parallel()

attempts := 0
waits := make([]time.Duration, 0, 2)
want := &forge.Issue{Number: 42}

issue, err := createIssueWithRetry(
context.Background(),
Comment thread
shairevivo marked this conversation as resolved.
func() (*forge.Issue, error) {
attempts++
if attempts < 3 {
return nil, &gh.APIError{StatusCode: http.StatusUnauthorized, Message: "Bad credentials"}
}
return want, nil
},
func(delay time.Duration) <-chan time.Time {
waits = append(waits, delay)
ready := make(chan time.Time, 1)
ready <- time.Time{}
return ready
},
t.Logf,
)

require.NoError(t, err)
require.Same(t, want, issue)
require.Equal(t, 3, attempts)
require.Equal(t, []time.Duration{10 * time.Second, 20 * time.Second}, waits)
}

func TestCreateIssueWithRetry_DoesNotRetryOtherErrors(t *testing.T) {
t.Parallel()

wantErr := &gh.APIError{StatusCode: http.StatusForbidden, Message: "Resource not accessible"}
attempts := 0

issue, err := createIssueWithRetry(
context.Background(),
func() (*forge.Issue, error) {
attempts++
return nil, wantErr
},
func(time.Duration) <-chan time.Time {
t.Fatal("unexpected retry delay")
return nil
},
t.Logf,
)

require.Nil(t, issue)
require.ErrorIs(t, err, wantErr)
require.Equal(t, 1, attempts)
}

func TestCreateIssueWithRetry_StopsAfterThreeUnauthorizedErrors(t *testing.T) {
t.Parallel()

wantErr := &gh.APIError{StatusCode: http.StatusUnauthorized, Message: "Bad credentials"}
attempts := 0

issue, err := createIssueWithRetry(
context.Background(),
func() (*forge.Issue, error) {
attempts++
return nil, wantErr
},
func(time.Duration) <-chan time.Time {
ready := make(chan time.Time, 1)
ready <- time.Time{}
return ready
},
t.Logf,
)

require.Nil(t, issue)
require.ErrorIs(t, err, wantErr)
require.Equal(t, 3, attempts)
}

func TestCreateIssueWithRetry_RetriesWrappedUnauthorizedError(t *testing.T) {
t.Parallel()

attempts := 0
waits := make([]time.Duration, 0, 1)
want := &forge.Issue{Number: 42}

issue, err := createIssueWithRetry(
context.Background(),
func() (*forge.Issue, error) {
attempts++
if attempts == 1 {
return nil, fmt.Errorf("create issue: %w", &gh.APIError{
StatusCode: http.StatusUnauthorized,
Message: "Bad credentials",
})
}
return want, nil
},
func(delay time.Duration) <-chan time.Time {
waits = append(waits, delay)
ready := make(chan time.Time, 1)
ready <- time.Time{}
return ready
},
t.Logf,
)

require.NoError(t, err)
require.Same(t, want, issue)
require.Equal(t, 2, attempts)
require.Equal(t, []time.Duration{10 * time.Second}, waits)
}
Loading