Skip to content

Commit 1bd2dfe

Browse files
committed
feat(go): align CLI/SDK with TS guest auth and task start
Port lazy 401 login, guest task start, download-based results, and non-retriable 4xx handling to Go. Tolerate empty JSON bodies from PUT /start so guest convert no longer fails on 204 responses.
1 parent 605d46e commit 1bd2dfe

6 files changed

Lines changed: 356 additions & 36 deletions

File tree

apps/go-cli/main.go

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -566,14 +566,6 @@ func (c *appContext) apiBase() string {
566566
}
567567

568568
func (c *appContext) getClient(ctx context.Context) (*deckops.Client, error) {
569-
if c.config.Token == "" {
570-
if _, err := c.ensureLoggedIn(ctx, defaultLoginPort, "explicit"); err != nil {
571-
return nil, err
572-
}
573-
}
574-
if c.config.Token == "" {
575-
return nil, fmt.Errorf("Login did not provide a token. Please run `deckflow login` again.")
576-
}
577569
if c.client != nil {
578570
return c.client, nil
579571
}
@@ -582,7 +574,13 @@ func (c *appContext) getClient(ctx context.Context) (*deckops.Client, error) {
582574
Token: c.config.Token,
583575
SpaceID: c.config.SpaceID,
584576
OnUnauthorized: func(ctx context.Context) (deckops.AuthRefresh, error) {
585-
token, err := c.ensureLoggedIn(ctx, defaultLoginPort, "unauthorized")
577+
// First-time visit (no token yet) feels like an explicit login;
578+
// an expired token reads as "auth expired".
579+
reason := "unauthorized"
580+
if c.config.Token == "" {
581+
reason = "explicit"
582+
}
583+
token, err := c.ensureLoggedIn(ctx, defaultLoginPort, reason)
586584
if err != nil {
587585
return deckops.AuthRefresh{}, err
588586
}
@@ -1045,6 +1043,10 @@ func (c *appContext) runTask(args []string) error {
10451043
return nil
10461044
}
10471045
}
1046+
task, err = c.attachDownloadResult(context.Background(), client, task)
1047+
if err != nil {
1048+
return err
1049+
}
10481050
c.output(task, func() string { return formatTaskDetails(task) })
10491051
case "delete":
10501052
if len(args) != 2 {
@@ -1428,6 +1430,17 @@ func (c *appContext) runFileTask(options fileTaskOptions) error {
14281430
if err != nil {
14291431
return err
14301432
}
1433+
1434+
// Guest mode (no token): the backend parks the task in a pending state
1435+
// and waits for an explicit start signal before executing.
1436+
// If Create triggered a 401 → login → retry, config.Token is now set
1437+
// and we skip the start call (authenticated tasks auto-start).
1438+
if c.config.Token == "" {
1439+
if _, err := client.Tasks.Start(context.Background(), task.ID); err != nil {
1440+
return err
1441+
}
1442+
}
1443+
14311444
c.info("Task created: " + task.ID)
14321445
if options.wait {
14331446
timeoutSec, err := positiveInt(options.timeout, "--timeout")
@@ -1458,14 +1471,12 @@ func (c *appContext) runFileTask(options fileTaskOptions) error {
14581471
return nil
14591472
}
14601473
}
1461-
if options.wait && task.Status == deckops.TaskStatusCompleted {
1462-
var downloadResult any
1463-
if err := client.Tasks.Down(context.Background(), task.ID, deckops.TaskDownloadOptions{}, &downloadResult); err != nil {
1464-
return err
1465-
}
1466-
printJSON(downloadResult)
1467-
return nil
1474+
1475+
task, err = c.attachDownloadResult(context.Background(), client, task)
1476+
if err != nil {
1477+
return err
14681478
}
1479+
14691480
c.output(task, func() string {
14701481
lines := []string{
14711482
options.title + ":",
@@ -1710,9 +1721,12 @@ func (c *appContext) tryWriteTaskOutput(ctx context.Context, client *deckops.Cli
17101721
return result, true
17111722
}
17121723
lastErr = err
1713-
if attempt < 3 {
1724+
// Only retry transient network/upstream failures — never 403/4xx business errors.
1725+
if attempt < 3 && deckops.IsRetriableError(err) {
17141726
time.Sleep(10 * time.Second)
1727+
continue
17151728
}
1729+
break
17161730
}
17171731
message := fmt.Sprintf("Task completed, but --out result could not be saved to %s after 3 attempts. The task result will be printed below; you can manually download the file from the target/result JSON.", outPath)
17181732
if c.json {
@@ -1725,6 +1739,20 @@ func (c *appContext) tryWriteTaskOutput(ctx context.Context, client *deckops.Cli
17251739
return outputWriteResult{}, false
17261740
}
17271741

1742+
// attachDownloadResult loads task result from GET /tools/tasks/:id/download.
1743+
// Task detail/SSE only carries status/progress metadata now.
1744+
func (c *appContext) attachDownloadResult(ctx context.Context, client *deckops.Client, task *deckops.Task) (*deckops.Task, error) {
1745+
if task.Status != deckops.TaskStatusCompleted {
1746+
return task, nil
1747+
}
1748+
var result any
1749+
if err := client.Tasks.Down(ctx, task.ID, deckops.TaskDownloadOptions{}, &result); err != nil {
1750+
return nil, err
1751+
}
1752+
task.Result = result
1753+
return task, nil
1754+
}
1755+
17281756
func (c *appContext) writeTaskOutput(ctx context.Context, client *deckops.Client, task *deckops.Task, outPath string) (outputWriteResult, error) {
17291757
var downloadResult any
17301758
if err := client.Tasks.Down(ctx, task.ID, deckops.TaskDownloadOptions{}, &downloadResult); err != nil {
@@ -1740,9 +1768,6 @@ func (c *appContext) writeTaskOutput(ctx context.Context, client *deckops.Client
17401768
return outputWriteResult{}, err
17411769
}
17421770
payload := downloadResult
1743-
if payload == nil {
1744-
payload = task.Result
1745-
}
17461771
if payload == nil {
17471772
payload = task
17481773
}

sdks/go/errors.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func (e *APIError) Error() string {
4343
}
4444

4545
func isRetriableStatus(status int) bool {
46+
// Only transient upstream/gateway failures — never 4xx business errors (403, 404, …).
4647
return status == StatusUpstreamUnavailable || status == http.StatusBadGateway
4748
}
4849

@@ -57,6 +58,66 @@ func isRetriableTransportError(err error) bool {
5758
return true
5859
}
5960

61+
// IsRetriableError reports whether an error is worth retrying.
62+
//
63+
// Retries exist for transient network / upstream issues only.
64+
// Explicit application errors (403, 401, 404, 4xx in general) must fail immediately.
65+
func IsRetriableError(err error) bool {
66+
if err == nil {
67+
return false
68+
}
69+
var apiErr *APIError
70+
if errors.As(err, &apiErr) {
71+
return isRetriableStatus(apiErr.StatusCode)
72+
}
73+
74+
msg := err.Error()
75+
if status, ok := parseHTTPStatusFromErrorMessage(msg); ok {
76+
return isRetriableStatus(status)
77+
}
78+
79+
lower := strings.ToLower(msg)
80+
return strings.Contains(lower, "network") ||
81+
strings.Contains(lower, "timeout") ||
82+
strings.Contains(lower, "connection refused") ||
83+
strings.Contains(lower, "connection reset") ||
84+
strings.Contains(lower, "no such host") ||
85+
strings.Contains(lower, "i/o timeout") ||
86+
strings.Contains(lower, "temporary failure")
87+
}
88+
89+
func parseHTTPStatusFromErrorMessage(message string) (int, bool) {
90+
// Matches: "API Error (403): ..." or "Failed to download https://...: 403 Forbidden"
91+
for _, prefix := range []string{"API Error (", "Failed to download "} {
92+
idx := strings.Index(message, prefix)
93+
if idx < 0 {
94+
continue
95+
}
96+
rest := message[idx+len(prefix):]
97+
if prefix == "Failed to download " {
98+
colon := strings.LastIndex(rest, ": ")
99+
if colon < 0 {
100+
continue
101+
}
102+
rest = rest[colon+2:]
103+
}
104+
if len(rest) >= 3 {
105+
status := 0
106+
for i := 0; i < 3; i++ {
107+
if rest[i] < '0' || rest[i] > '9' {
108+
status = 0
109+
break
110+
}
111+
status = status*10 + int(rest[i]-'0')
112+
}
113+
if status >= 100 && status <= 599 {
114+
return status, true
115+
}
116+
}
117+
}
118+
return 0, false
119+
}
120+
60121
func unauthorizedAPIKeyError(base *APIError) *APIError {
61122
return &APIError{
62123
StatusCode: http.StatusUnauthorized,

sdks/go/http_client.go

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,8 @@ func (c *httpClient) ResolveSpaceID(ctx context.Context, spaceID string) (string
112112
}
113113
c.mu.RUnlock()
114114

115-
c.mu.RLock()
116-
hasCredentials := c.token != "" || c.apiKey != ""
117-
c.mu.RUnlock()
118-
if !hasCredentials {
119-
return "", fmt.Errorf("spaceId is required")
120-
}
121-
115+
// Resolve from GET /user. The endpoint only requires X-Auth-UUID, so
116+
// it works for both authenticated users and guests.
122117
var user UserSelf
123118
if _, err := c.getJSON(ctx, "/user", nil, nil, &user); err != nil {
124119
return "", err
@@ -152,10 +147,8 @@ func (c *httpClient) getJSON(ctx context.Context, path string, query url.Values,
152147
if err != nil {
153148
return nil, err
154149
}
155-
if out != nil {
156-
if err := json.Unmarshal(res.Body, out); err != nil {
157-
return nil, err
158-
}
150+
if err := decodeJSONBody(res.Body, out); err != nil {
151+
return nil, err
159152
}
160153
return res, nil
161154
}
@@ -173,14 +166,40 @@ func (c *httpClient) postJSON(ctx context.Context, path string, in any, out any)
173166
if err != nil {
174167
return nil, err
175168
}
176-
if out != nil {
177-
if err := json.Unmarshal(res.Body, out); err != nil {
169+
if err := decodeJSONBody(res.Body, out); err != nil {
170+
return nil, err
171+
}
172+
return res, nil
173+
}
174+
175+
func (c *httpClient) putJSON(ctx context.Context, path string, query url.Values, in any, out any) (*httpResponse, error) {
176+
var body []byte
177+
var err error
178+
if in != nil {
179+
body, err = json.Marshal(in)
180+
if err != nil {
178181
return nil, err
179182
}
180183
}
184+
res, err := c.do(ctx, http.MethodPut, path, query, nil, body, false)
185+
if err != nil {
186+
return nil, err
187+
}
188+
if err := decodeJSONBody(res.Body, out); err != nil {
189+
return nil, err
190+
}
181191
return res, nil
182192
}
183193

194+
// decodeJSONBody unmarshals JSON into out. Empty bodies (common for 204 / start)
195+
// are treated as success and leave out unchanged.
196+
func decodeJSONBody(body []byte, out any) error {
197+
if out == nil || len(bytes.TrimSpace(body)) == 0 {
198+
return nil
199+
}
200+
return json.Unmarshal(body, out)
201+
}
202+
184203
func (c *httpClient) delete(ctx context.Context, path string, query url.Values) error {
185204
_, err := c.do(ctx, http.MethodDelete, path, query, nil, nil, false)
186205
return err

0 commit comments

Comments
 (0)