Skip to content

Commit 323462c

Browse files
committed
Poll codespace on ErrCreateAsyncRetry error
- Introduce tests for the poller - Attempt to fetch codespace for 2 mins
1 parent 0b68aaa commit 323462c

File tree

2 files changed

+137
-6
lines changed

2 files changed

+137
-6
lines changed

cmd/ghcs/create.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,19 @@ func create(opts *createOptions) error {
9292
ctx, userResult.User, repository, machine, branch, locationResult.Location,
9393
)
9494
if err != nil {
95+
// This error is returned by the API when the initial creation fails with a retryable error.
96+
// A retryable error means that GitHub will retry to re-create Codespace and clients should poll
97+
// the API and attempt to fetch the Codespace for the next two minutes.
9598
if err == api.ErrCreateAsyncRetry {
96-
createRetryCtx, cancelRetry := context.WithTimeout(ctx, 2*time.Minute)
97-
defer cancelRetry()
99+
log.Print("Switching to async provisioning...")
100+
pollctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
101+
defer cancel()
102+
103+
codespace, err = pollForCodespace(pollctx, apiClient, log, userResult.User, codespace)
104+
log.Print("\n")
98105

99-
codespace, err = pollForProvisionedCodespace(createRetryCtx, codespace)
100106
if err != nil {
101-
return fmt.Errorf("error creating codespace after retry: %w", err)
107+
return fmt.Errorf("error creating codespace with async provisioning: %s: %w", codespace.Name, err)
102108
}
103109
}
104110

@@ -118,8 +124,40 @@ func create(opts *createOptions) error {
118124
return nil
119125
}
120126

121-
func pollForProvisionedCodespace(ctx context.Context, provisioningCodespace *api.Codespace) (*api.Codespace, error) {
122-
return nil, nil
127+
type apiClient interface {
128+
GetCodespaceToken(context.Context, string, string) (string, error)
129+
GetCodespace(context.Context, string, string, string) (*api.Codespace, error)
130+
}
131+
132+
// pollForCodespace polls the Codespaces API every second fetching the codespace.
133+
// If it succeeds at fetching the codespace, we consider the codespace provisioned.
134+
// Context should be cancelled to stop polling.
135+
func pollForCodespace(
136+
ctx context.Context, client apiClient, log *output.Logger, user *api.User, provisioningCodespace *api.Codespace,
137+
) (*api.Codespace, error) {
138+
ticker := time.NewTicker(1 * time.Second)
139+
defer ticker.Stop()
140+
141+
for {
142+
select {
143+
case <-ctx.Done():
144+
return nil, ctx.Err()
145+
case <-ticker.C:
146+
log.Print(".")
147+
token, err := client.GetCodespaceToken(ctx, user.Login, provisioningCodespace.Name)
148+
if err != nil {
149+
// Do nothing. We expect this to fail until the codespace is provisioned
150+
continue
151+
}
152+
153+
codespace, err := client.GetCodespace(ctx, token, user.Login, provisioningCodespace.Name)
154+
if err != nil {
155+
return nil, fmt.Errorf("failed to get codespace: %w", err)
156+
}
157+
158+
return codespace, nil
159+
}
160+
}
123161
}
124162

125163
// showStatus polls the codespace for a list of post create states and their status. It will keep polling

cmd/ghcs/create_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"testing"
8+
"time"
9+
10+
"github.com/github/ghcs/cmd/ghcs/output"
11+
"github.com/github/ghcs/internal/api"
12+
)
13+
14+
type mockAPIClient struct {
15+
getCodespaceToken func(context.Context, string, string) (string, error)
16+
getCodespace func(context.Context, string, string, string) (*api.Codespace, error)
17+
}
18+
19+
func (m *mockAPIClient) GetCodespaceToken(ctx context.Context, userLogin, codespaceName string) (string, error) {
20+
if m.getCodespaceToken == nil {
21+
return "", errors.New("mock api client GetCodespaceToken not implemented")
22+
}
23+
24+
return m.getCodespaceToken(ctx, userLogin, codespaceName)
25+
}
26+
27+
func (m *mockAPIClient) GetCodespace(ctx context.Context, token, userLogin, codespaceName string) (*api.Codespace, error) {
28+
if m.getCodespace == nil {
29+
return nil, errors.New("mock api client GetCodespace not implemented")
30+
}
31+
32+
return m.getCodespace(ctx, token, userLogin, codespaceName)
33+
}
34+
35+
func TestPollForCodespace(t *testing.T) {
36+
logger := output.NewLogger(nil, nil, false)
37+
user := &api.User{Login: "test"}
38+
tmpCodespace := &api.Codespace{Name: "tmp-codespace"}
39+
codespaceToken := "codespace-token"
40+
41+
ctxTimeout := 1 * time.Second
42+
exceedTime := 2 * time.Second
43+
exceedProvisioningTime := false
44+
45+
api := &mockAPIClient{
46+
getCodespaceToken: func(ctx context.Context, userLogin, codespace string) (string, error) {
47+
if exceedProvisioningTime {
48+
ticker := time.NewTicker(exceedTime)
49+
defer ticker.Stop()
50+
<-ticker.C
51+
}
52+
if userLogin != user.Login {
53+
return "", fmt.Errorf("user does not match, got: %s, expected: %s", userLogin, user.Login)
54+
}
55+
if codespace != tmpCodespace.Name {
56+
return "", fmt.Errorf("codespace does not match, got: %s, expected: %s", codespace, tmpCodespace.Name)
57+
}
58+
return codespaceToken, nil
59+
},
60+
getCodespace: func(ctx context.Context, token, userLogin, codespace string) (*api.Codespace, error) {
61+
if token != codespaceToken {
62+
return nil, fmt.Errorf("token does not match, got: %s, expected: %s", token, codespaceToken)
63+
}
64+
if userLogin != user.Login {
65+
return nil, fmt.Errorf("user does not match, got: %s, expected: %s", userLogin, user.Login)
66+
}
67+
if codespace != tmpCodespace.Name {
68+
return nil, fmt.Errorf("codespace does not match, got: %s, expected: %s", codespace, tmpCodespace.Name)
69+
}
70+
return tmpCodespace, nil
71+
},
72+
}
73+
74+
ctx, cancel := context.WithTimeout(context.Background(), ctxTimeout)
75+
defer cancel()
76+
77+
codespace, err := pollForCodespace(ctx, api, logger, user, tmpCodespace)
78+
if err != nil {
79+
t.Error(err)
80+
}
81+
if tmpCodespace.Name != codespace.Name {
82+
t.Errorf("returned codespace does not match, got: %s, expected: %s", codespace.Name, tmpCodespace.Name)
83+
}
84+
85+
exceedProvisioningTime = true
86+
ctx, cancel = context.WithTimeout(ctx, ctxTimeout)
87+
defer cancel()
88+
89+
_, err = pollForCodespace(ctx, api, logger, user, tmpCodespace)
90+
if err == nil {
91+
t.Error("expected context deadline exceeded error, got nil")
92+
}
93+
}

0 commit comments

Comments
 (0)