fix(coderd/x/chatd/chattool): retry workspace name conflicts (#25668)

Retry Coder Agents workspace creation once with a generated random
suffix when the requested workspace name already exists. This preserves
structured errors for other conflicts and avoids surfacing avoidable
name collisions.

Closes CODAGT-386
This commit is contained in:
Ethan
2026-06-01 23:31:25 +10:00
committed by GitHub
parent 85f56e4944
commit d0fa9ff986
2 changed files with 102 additions and 1 deletions
+37 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
@@ -238,7 +239,7 @@ func CreateWorkspace(db database.Store, organizationID, chatID uuid.UUID, option
)
}
workspace, err := options.CreateFn(ctx, ownerID, createReq)
workspace, err := createWorkspaceWithNameRetry(ctx, ownerID, createReq, options.CreateFn)
if err != nil {
if responseErr, ok := httperror.IsResponder(err); ok {
_, resp := responseErr.Response()
@@ -703,6 +704,41 @@ func waitForAgentReady(
}
}
func createWorkspaceWithNameRetry(
ctx context.Context,
ownerID uuid.UUID,
req codersdk.CreateWorkspaceRequest,
createFn CreateWorkspaceFn,
) (codersdk.Workspace, error) {
workspace, err := createFn(ctx, ownerID, req)
if err == nil {
return workspace, nil
}
if !isWorkspaceNameConflict(err) {
return codersdk.Workspace{}, err
}
req.Name = generatedWorkspaceName(req.Name)
return createFn(ctx, ownerID, req)
}
func isWorkspaceNameConflict(err error) bool {
responseErr, ok := httperror.IsResponder(err)
if !ok {
return false
}
status, resp := responseErr.Response()
if status != http.StatusConflict {
return false
}
for _, validation := range resp.Validations {
if validation.Field == "name" {
return true
}
}
return false
}
func generatedWorkspaceName(seed string) string {
base := codersdk.UsernameFrom(strings.TrimSpace(strings.ToLower(seed)))
if strings.TrimSpace(base) == "" {
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"sync"
"testing"
"time"
@@ -855,6 +856,70 @@ func TestCreateWorkspace_ResponderErrorPreservesStructuredFields(t *testing.T) {
}}, result.Validations)
}
func TestCreateWorkspaceWithNameRetry(t *testing.T) {
t.Parallel()
t.Run("NameConflictRetriesWithGeneratedName", func(t *testing.T) {
t.Parallel()
var names []string
workspace, err := createWorkspaceWithNameRetry(
context.Background(),
uuid.New(),
codersdk.CreateWorkspaceRequest{Name: "fun-dashboard"},
func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
names = append(names, req.Name)
if len(names) == 1 {
return codersdk.Workspace{}, workspaceNameConflictError(req.Name)
}
require.Regexp(t, `^fun-dashboard-[0-9a-f]{4}$`, req.Name)
return codersdk.Workspace{Name: req.Name}, nil
},
)
require.NoError(t, err)
require.Len(t, names, 2)
require.Equal(t, "fun-dashboard", names[0])
require.Equal(t, names[1], workspace.Name)
})
t.Run("OtherConflictDoesNotRetry", func(t *testing.T) {
t.Parallel()
calls := 0
wantErr := httperror.NewResponseError(http.StatusConflict, codersdk.Response{
Message: "quota exceeded",
Validations: []codersdk.ValidationError{{
Field: "quota",
Detail: "quota exceeded",
}},
})
_, err := createWorkspaceWithNameRetry(
context.Background(),
uuid.New(),
codersdk.CreateWorkspaceRequest{Name: "fun-dashboard"},
func(context.Context, uuid.UUID, codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
calls++
return codersdk.Workspace{}, wantErr
},
)
require.Same(t, wantErr, err)
require.Equal(t, 1, calls)
})
}
func workspaceNameConflictError(name string) error {
return httperror.NewResponseError(http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("Workspace %q already exists.", name),
Validations: []codersdk.ValidationError{{
Field: "name",
Detail: "This value is already in use and should be unique.",
}},
})
}
func TestCreateWorkspace_GlobalTTL(t *testing.T) {
t.Parallel()