Skip to content

Commit 8d804fe

Browse files
ZacxDevclaude
andauthored
fix(submit): scope a longer 120s timeout to the upload, not fast calls (#26)
* fix(submit): scope a longer 120s timeout to the upload, not fast calls `civitai app submit` reported a false failure: Error: Post ".../api/v1/blocks/submit-version": context deadline exceeded (Client.Timeout exceeded while awaiting headers) …on submissions that had ACTUALLY succeeded server-side — the natural retry then hit `400: you already have a pending submission`. Root cause: the api.Client used a single shared http.Client whose timeout governed both the slow submit upload (multi-file base64 ZIP + server-side publish-request processing) and the fast interactive calls. The fix scopes the long timeout to the submit upload only: - fast calls (whoami, submissions): 30s shared client (was 120s) - submit upload: dedicated 120s client (submitClient), mirroring the shared client's Transport so connection reuse/config is preserved authedDo/doOnce now take an explicit *http.Client (authedDoWith / doOnceWith) so the submit path can use the long-timeout client without lengthening the fast calls. Added a unit test pinning submitTimeout=120s, defaultTimeout=30s, submit > fast, and that the shared client keeps the short timeout while submitClient() uses the long one (no network). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(submit): recover from a timed-out submit by polling for the landed submission A scoped 120s timeout alone doesn't fix F6's root symptom: the upload can land server-side while its HTTP response is slow or lost, so the CLI still reports a false "context deadline exceeded" and the user retries into "you already have a pending submission". SubmitVersion now distinguishes a timeout / deadline-exceeded / no-response error (isTimeoutErr: context.DeadlineExceeded, os.IsTimeout, net.Error.Timeout) from a clean HTTP error. On a timeout it polls GET /api/v1/blocks/submissions (reusing ListSubmissions) up to a bounded number of attempts for a row matching this slug+version; if found it reports SUCCESS surfacing the pubreq id, else it returns a clear error telling the user to check `civitai app status` first. SubmitVersion gains slug+version params (threaded from the manifest in doUpload) so the recovery knows what to look for; Client gains test-only SubmitTimeout / SubmitPollDelay seams. Adds unit tests for the recovery (landed -> success with pubreq id; absent -> clear error; exact slug+version match) and the timeout classifier. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 74550ae commit 8d804fe

7 files changed

Lines changed: 346 additions & 19 deletions

File tree

internal/api/api.go

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ import (
1919
"context"
2020
"encoding/base64"
2121
"encoding/json"
22+
"errors"
2223
"fmt"
2324
"io"
25+
"net"
2426
"net/http"
2527
"net/url"
28+
"os"
2629
"strings"
2730
"time"
2831
)
@@ -52,9 +55,12 @@ func (s StaticToken) Token(context.Context) (string, error) { return string(s),
5255
// Refresh always fails: a personal key has no refresh path.
5356
func (s StaticToken) Refresh(context.Context) (string, error) { return "", ErrNoRefresh }
5457

55-
// Submitter submits a packaged bundle and returns the server's response.
58+
// Submitter submits a packaged bundle and returns the server's response. The
59+
// slug + version identify the submission so that, if the upload's response is
60+
// lost to a timeout, the submit path can poll for a landed submission and
61+
// recover rather than reporting a false failure (see SubmitVersion).
5662
type Submitter interface {
57-
SubmitVersion(ctx context.Context, zipBytes []byte) (*SubmitResult, error)
63+
SubmitVersion(ctx context.Context, zipBytes []byte, slug, version string) (*SubmitResult, error)
5864
}
5965

6066
// Verifier verifies a token and returns the authenticated identity.
@@ -110,6 +116,19 @@ type SubmitResult struct {
110116
// DefaultSubmitPath is the token-authenticated submit-version route.
111117
const DefaultSubmitPath = "/api/v1/blocks/submit-version"
112118

119+
// defaultTimeout governs the fast, interactive calls (whoami, submissions).
120+
// Kept short so a hung connection surfaces quickly.
121+
const defaultTimeout = 30 * time.Second
122+
123+
// submitTimeout governs the submit-version upload specifically. A submit
124+
// uploads a multi-file base64 ZIP and then waits for server-side processing
125+
// (publish-request creation), which can take well over the fast-call timeout.
126+
// A short timeout here produced a FALSE failure ("context deadline exceeded")
127+
// on submits that had actually succeeded server-side, so the user's retry hit
128+
// "you already have a pending submission". Scope this longer window to submit
129+
// only — do NOT lengthen the fast calls.
130+
const submitTimeout = 120 * time.Second
131+
113132
// SubmissionsPath is the token-authenticated, self-scoped submission-status
114133
// route (GET; civitai/civitai src/pages/api/v1/blocks/submissions.ts).
115134
const SubmissionsPath = "/api/v1/blocks/submissions"
@@ -120,6 +139,14 @@ type Client struct {
120139
Tokens TokenSource
121140
SubmitPath string // route for submit-version; CIVITAI_SUBMIT_PATH overrides
122141
HTTP *http.Client
142+
// SubmitTimeout overrides the submit-upload timeout when non-zero; it
143+
// defaults to submitTimeout. Used by tests to exercise the timeout-recovery
144+
// path without a real slow upload.
145+
SubmitTimeout time.Duration
146+
// SubmitPollDelay overrides the inter-attempt delay of the post-timeout
147+
// recovery poll when set (>= 0 with the zero value meaning "use the
148+
// default"); tests set it to 0 to avoid sleeping.
149+
SubmitPollDelay *time.Duration
123150
}
124151

125152
// New builds a Client with sane defaults from a static token (personal API key
@@ -138,7 +165,7 @@ func NewWithSource(baseURL string, src TokenSource, submitPath string) *Client {
138165
BaseURL: strings.TrimRight(baseURL, "/"),
139166
Tokens: src,
140167
SubmitPath: submitPath,
141-
HTTP: &http.Client{Timeout: 120 * time.Second},
168+
HTTP: &http.Client{Timeout: defaultTimeout},
142169
}
143170
}
144171

@@ -147,11 +174,18 @@ func NewWithSource(baseURL string, src TokenSource, submitPath string) *Client {
147174
// refreshing once on a 401 and retrying. The returned response body is fully
148175
// read into raw and the response is closed.
149176
func (c *Client) authedDo(ctx context.Context, build func() (*http.Request, error)) (int, []byte, error) {
177+
return c.authedDoWith(ctx, c.HTTP, build)
178+
}
179+
180+
// authedDoWith is authedDo against a specific *http.Client, so a slow call
181+
// (the submit upload) can use a longer-timeout client without affecting the
182+
// fast, interactive calls that share c.HTTP.
183+
func (c *Client) authedDoWith(ctx context.Context, httpClient *http.Client, build func() (*http.Request, error)) (int, []byte, error) {
150184
token, err := c.Tokens.Token(ctx)
151185
if err != nil {
152186
return 0, nil, err
153187
}
154-
status, raw, err := c.doOnce(ctx, build, token)
188+
status, raw, err := c.doOnceWith(httpClient, build, token)
155189
if err != nil {
156190
return 0, nil, err
157191
}
@@ -160,21 +194,21 @@ func (c *Client) authedDo(ctx context.Context, build func() (*http.Request, erro
160194
// ErrNoRefresh and we keep the original 401.
161195
newTok, rerr := c.Tokens.Refresh(ctx)
162196
if rerr == nil && newTok != "" {
163-
return c.doOnce(ctx, build, newTok)
197+
return c.doOnceWith(httpClient, build, newTok)
164198
}
165199
}
166200
return status, raw, nil
167201
}
168202

169-
func (c *Client) doOnce(ctx context.Context, build func() (*http.Request, error), token string) (int, []byte, error) {
203+
func (c *Client) doOnceWith(httpClient *http.Client, build func() (*http.Request, error), token string) (int, []byte, error) {
170204
req, err := build()
171205
if err != nil {
172206
return 0, nil, err
173207
}
174208
if token != "" {
175209
req.Header.Set("Authorization", "Bearer "+token)
176210
}
177-
resp, err := c.HTTP.Do(req)
211+
resp, err := httpClient.Do(req)
178212
if err != nil {
179213
return 0, nil, err
180214
}
@@ -188,9 +222,42 @@ type submitBody struct {
188222
BundleBase64 string `json:"bundleBase64"`
189223
}
190224

225+
// submitClient returns an *http.Client for the submit upload: it mirrors the
226+
// shared client's Transport but uses the longer submitTimeout, so the slow
227+
// upload + server-side processing doesn't trip the short fast-call timeout
228+
// (which caused false "context deadline exceeded" failures on submits that had
229+
// already succeeded). If no shared client is set, a zero Client is used.
230+
func (c *Client) submitClient() *http.Client {
231+
base := c.HTTP
232+
if base == nil {
233+
base = &http.Client{}
234+
}
235+
timeout := submitTimeout
236+
if c.SubmitTimeout > 0 {
237+
timeout = c.SubmitTimeout
238+
}
239+
return &http.Client{
240+
Timeout: timeout,
241+
Transport: base.Transport,
242+
CheckRedirect: base.CheckRedirect,
243+
Jar: base.Jar,
244+
}
245+
}
246+
191247
// SubmitVersion uploads the bundle to the token-authenticated submit route,
192248
// refreshing the OAuth access token transparently if needed.
193-
func (c *Client) SubmitVersion(ctx context.Context, zipBytes []byte) (*SubmitResult, error) {
249+
//
250+
// The upload can complete server-side while its HTTP response is slow or never
251+
// arrives within the timeout — observed in the wild as a false "context
252+
// deadline exceeded" failure on a submit that had actually landed, leaving the
253+
// user to retry into "you already have a pending submission". So when (and only
254+
// when) the POST fails with a timeout / deadline-exceeded / no-response error
255+
// (as opposed to a clean HTTP error status), this polls
256+
// GET /api/v1/blocks/submissions for a submission matching slug+version and, if
257+
// one is now present, reports it as a success — surfacing the pubreq id. If no
258+
// matching submission is found, it returns a clear error telling the user to
259+
// check `civitai app status` before resubmitting.
260+
func (c *Client) SubmitVersion(ctx context.Context, zipBytes []byte, slug, version string) (*SubmitResult, error) {
194261
body, err := json.Marshal(submitBody{
195262
BundleBase64: base64.StdEncoding.EncodeToString(zipBytes),
196263
})
@@ -205,8 +272,11 @@ func (c *Client) SubmitVersion(ctx context.Context, zipBytes []byte) (*SubmitRes
205272
req.Header.Set("Content-Type", "application/json")
206273
return req, nil
207274
}
208-
status, raw, err := c.authedDo(ctx, build)
275+
status, raw, err := c.authedDoWith(ctx, c.submitClient(), build)
209276
if err != nil {
277+
if isTimeoutErr(err) {
278+
return c.recoverTimedOutSubmit(ctx, slug, version, err)
279+
}
210280
return nil, err
211281
}
212282
if status != http.StatusOK {
@@ -219,6 +289,96 @@ func (c *Client) SubmitVersion(ctx context.Context, zipBytes []byte) (*SubmitRes
219289
return &out, nil
220290
}
221291

292+
// isTimeoutErr reports whether err is a request timeout / deadline-exceeded /
293+
// no-response condition (as opposed to a clean HTTP error response). It matches
294+
// context.DeadlineExceeded, os timeouts, and any net.Error whose Timeout() is
295+
// true (which is what http.Client.Timeout surfaces when awaiting headers).
296+
func isTimeoutErr(err error) bool {
297+
if err == nil {
298+
return false
299+
}
300+
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
301+
return true
302+
}
303+
var netErr net.Error
304+
if errors.As(err, &netErr) {
305+
return netErr.Timeout()
306+
}
307+
return false
308+
}
309+
310+
// submitPollAttempts / submitPollDelay bound the post-timeout recovery poll:
311+
// the submission may take a moment to become visible after the upload lands, so
312+
// poll a few times with a short delay rather than just once.
313+
const (
314+
submitPollAttempts = 3
315+
submitPollDelay = 2 * time.Second
316+
)
317+
318+
// recoverTimedOutSubmit is called only after a submit POST timed out. It polls
319+
// the submissions list for a row matching slug+version; if found it reports a
320+
// success (the submit landed), otherwise a clear error citing the original
321+
// timeout and pointing at `civitai app status`.
322+
func (c *Client) recoverTimedOutSubmit(ctx context.Context, slug, version string, cause error) (*SubmitResult, error) {
323+
delay := submitPollDelay
324+
if c.SubmitPollDelay != nil {
325+
delay = *c.SubmitPollDelay
326+
}
327+
for attempt := 0; attempt < submitPollAttempts; attempt++ {
328+
if attempt > 0 && delay > 0 {
329+
t := time.NewTimer(delay)
330+
select {
331+
case <-ctx.Done():
332+
t.Stop()
333+
return nil, timedOutSubmitError(slug, cause)
334+
case <-t.C:
335+
}
336+
}
337+
subs, err := c.ListSubmissions(ctx, slug)
338+
if err != nil {
339+
// The poll itself failed; keep trying within the bound.
340+
continue
341+
}
342+
if sub := latestMatchingSubmission(subs, slug, version); sub != nil {
343+
return &SubmitResult{
344+
PublishRequestID: sub.ID,
345+
Slug: sub.BlockID,
346+
Version: sub.Version,
347+
Status: sub.Status,
348+
}, nil
349+
}
350+
}
351+
return nil, timedOutSubmitError(slug, cause)
352+
}
353+
354+
// latestMatchingSubmission returns the first submission matching slug+version in
355+
// a non-terminal (pending/submitted) state, falling back to any slug+version
356+
// match. Submissions are returned newest-first, so the first match is latest.
357+
func latestMatchingSubmission(subs []Submission, slug, version string) *Submission {
358+
var anyMatch *Submission
359+
for i := range subs {
360+
s := &subs[i]
361+
if s.BlockID != slug || s.Version != version {
362+
continue
363+
}
364+
switch strings.ToLower(s.Status) {
365+
case "pending", "submitted":
366+
return s
367+
}
368+
if anyMatch == nil {
369+
anyMatch = s
370+
}
371+
}
372+
return anyMatch
373+
}
374+
375+
// timedOutSubmitError builds the actionable error returned when a submit timed
376+
// out and no matching submission could be confirmed afterwards.
377+
func timedOutSubmitError(slug string, cause error) error {
378+
return fmt.Errorf("submit timed out and the upload may not have completed (%v) — "+
379+
"run `civitai app status %s` to check whether it landed before resubmitting", cause, slug)
380+
}
381+
222382
// WhoAmI verifies the token against /api/v1/me, refreshing the OAuth access
223383
// token transparently if needed.
224384
func (c *Client) WhoAmI(ctx context.Context) (*Identity, error) {

internal/api/api_extra_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestSubmitVersionStatusErrors(t *testing.T) {
3434
_, _ = w.Write([]byte(`{"error":"boom"}`))
3535
}))
3636
c := New(srv.URL, "tok", "")
37-
_, err := c.SubmitVersion(context.Background(), []byte("z"))
37+
_, err := c.SubmitVersion(context.Background(), []byte("z"), "my-block", "0.1.0")
3838
srv.Close()
3939
if err == nil {
4040
t.Errorf("status %d: expected error", tc.status)
@@ -52,7 +52,7 @@ func TestSubmitVersionGarbageResponse(t *testing.T) {
5252
}))
5353
defer srv.Close()
5454
c := New(srv.URL, "tok", "")
55-
if _, err := c.SubmitVersion(context.Background(), []byte("z")); err == nil {
55+
if _, err := c.SubmitVersion(context.Background(), []byte("z"), "my-block", "0.1.0"); err == nil {
5656
t.Fatal("expected error for non-JSON 200 response")
5757
}
5858
}

internal/api/api_test.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,36 @@ import (
99
"net/http/httptest"
1010
"strings"
1111
"testing"
12+
"time"
1213
)
1314

15+
// TestSubmitTimeoutIsLongAndScoped pins the F6 fix: the submit upload gets a
16+
// substantially longer timeout than the fast, interactive calls, scoped to the
17+
// submit client only. A short shared timeout previously produced false
18+
// "context deadline exceeded" failures on submits that had already succeeded
19+
// server-side, so the user's retry hit "you already have a pending submission".
20+
func TestSubmitTimeoutIsLongAndScoped(t *testing.T) {
21+
if submitTimeout != 120*time.Second {
22+
t.Errorf("submitTimeout = %v, want 120s", submitTimeout)
23+
}
24+
if defaultTimeout != 30*time.Second {
25+
t.Errorf("defaultTimeout = %v, want 30s", defaultTimeout)
26+
}
27+
if submitTimeout <= defaultTimeout {
28+
t.Errorf("submit timeout (%v) must exceed the fast-call timeout (%v)", submitTimeout, defaultTimeout)
29+
}
30+
31+
c := New("https://example.com", "tok", "")
32+
// The shared client stays on the short fast-call timeout...
33+
if c.HTTP.Timeout != defaultTimeout {
34+
t.Errorf("shared client timeout = %v, want %v (fast calls must not be lengthened)", c.HTTP.Timeout, defaultTimeout)
35+
}
36+
// ...while the submit client uses the long timeout.
37+
if got := c.submitClient().Timeout; got != submitTimeout {
38+
t.Errorf("submit client timeout = %v, want %v", got, submitTimeout)
39+
}
40+
}
41+
1442
func TestSubmitVersionSendsBearerAndBase64(t *testing.T) {
1543
var gotAuth, gotBody string
1644
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -27,7 +55,7 @@ func TestSubmitVersionSendsBearerAndBase64(t *testing.T) {
2755
defer srv.Close()
2856

2957
c := New(srv.URL, "tok123", "/api/blocks/submit-version")
30-
res, err := c.SubmitVersion(context.Background(), []byte("ZIPDATA"))
58+
res, err := c.SubmitVersion(context.Background(), []byte("ZIPDATA"), "my-block", "0.1.0")
3159
if err != nil {
3260
t.Fatalf("SubmitVersion: %v", err)
3361
}
@@ -51,7 +79,7 @@ func TestSubmitVersionSurfacesServerMessage(t *testing.T) {
5179
defer srv.Close()
5280

5381
c := New(srv.URL, "tok", "")
54-
_, err := c.SubmitVersion(context.Background(), []byte("x"))
82+
_, err := c.SubmitVersion(context.Background(), []byte("x"), "my-block", "0.1.0")
5583
if err == nil {
5684
t.Fatal("expected error")
5785
}

internal/api/client_refresh_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func TestSubmitVersionUsesV1RouteAndRefreshes(t *testing.T) {
9595
defer srv.Close()
9696

9797
c := New(srv.URL, "tok", "")
98-
if _, err := c.SubmitVersion(context.Background(), []byte("ZIP")); err != nil {
98+
if _, err := c.SubmitVersion(context.Background(), []byte("ZIP"), "my-block", "0.1.0"); err != nil {
9999
t.Fatalf("SubmitVersion: %v", err)
100100
}
101101
if path != DefaultSubmitPath {

0 commit comments

Comments
 (0)