From 513bd2b2e7088c485af5b7f3c1db33709f15c9a5 Mon Sep 17 00:00:00 2001 From: Shai Revivo Date: Sun, 6 Sep 2026 11:39:59 +0300 Subject: [PATCH 1/2] ci(#2618): retry transient issue creation auth failures Retry e2e issue creation up to three times when GitHub returns 401. Fail immediately for other errors and keep backoff context-aware. Co-authored-by: Codex Signed-off-by: Shai Revivo --- e2e/admin/admin_test.go | 31 +++++++++- e2e/admin/create_issue_retry_test.go | 92 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 e2e/admin/create_issue_retry_test.go diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index 623faf7763..828bf73f2a 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -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) 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")) @@ -648,6 +650,33 @@ 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, +) (*forge.Issue, error) { + for attempt := range 3 { + 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 + 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. diff --git a/e2e/admin/create_issue_retry_test.go b/e2e/admin/create_issue_retry_test.go new file mode 100644 index 0000000000..cc8506c72b --- /dev/null +++ b/e2e/admin/create_issue_retry_test.go @@ -0,0 +1,92 @@ +//go:build e2e + +package admin + +import ( + "context" + "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(), + 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 + }, + ) + + 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 + }, + ) + + 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 + }, + ) + + require.Nil(t, issue) + require.ErrorIs(t, err, wantErr) + require.Equal(t, 3, attempts) +} From 95d2515b5347439ccdd1d07ed21310dc65f26fbb Mon Sep 17 00:00:00 2001 From: Shai Revivo Date: Tue, 8 Sep 2026 18:07:29 +0300 Subject: [PATCH 2/2] ci(e2e): address issue retry review feedback Add wrapped-error coverage and retry logging. Signed-off-by: Shai Revivo Co-authored-by: Codex --- e2e/admin/admin_test.go | 4 ++- e2e/admin/create_issue_retry_test.go | 38 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index 828bf73f2a..6fa80512e6 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -497,7 +497,7 @@ Files over 64KB save fine if they contain only ASCII characters.` // (#2636). issue, err := createIssueWithRetry(ctx, func() (*forge.Issue, error) { return env.client.CreateIssue(ctx, env.org, e2etest.TestRepo, issueTitle, issueBody) - }, time.After) + }, 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")) @@ -656,6 +656,7 @@ 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 { issue, err := create() @@ -668,6 +669,7 @@ func createIssueWithRetry( } 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() diff --git a/e2e/admin/create_issue_retry_test.go b/e2e/admin/create_issue_retry_test.go index cc8506c72b..5d7cb119fc 100644 --- a/e2e/admin/create_issue_retry_test.go +++ b/e2e/admin/create_issue_retry_test.go @@ -4,6 +4,7 @@ package admin import ( "context" + "fmt" "net/http" "testing" "time" @@ -36,6 +37,7 @@ func TestCreateIssueWithRetry_RetriesUnauthorizedThenSucceeds(t *testing.T) { ready <- time.Time{} return ready }, + t.Logf, ) require.NoError(t, err) @@ -60,6 +62,7 @@ func TestCreateIssueWithRetry_DoesNotRetryOtherErrors(t *testing.T) { t.Fatal("unexpected retry delay") return nil }, + t.Logf, ) require.Nil(t, issue) @@ -84,9 +87,44 @@ func TestCreateIssueWithRetry_StopsAfterThreeUnauthorizedErrors(t *testing.T) { 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) +}