@@ -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.
5356func (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).
5662type 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.
111117const 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).
115134const 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.
149176func (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.
224384func (c * Client ) WhoAmI (ctx context.Context ) (* Identity , error ) {
0 commit comments