gastownhall/beads · warning

transient error %d (attempt %d/%d)

Error message

transient error %d (attempt %d/%d)

What it means

The Azure DevOps client retry loop in doRequest hit a transient (retryable) HTTP status (e.g. 429, 5xx) and records this as lastErr on each failed attempt before backing off. The message includes the status code and attempt counter (attempt+1 of maxAttempts+1). It is usually surfaced wrapped by "max retries exceeded" (1408) rather than on its own; on its own it means the loop exited early via ctx cancellation. The client adds jittered exponential backoff, or honors a server-provided Retry-After delay when present.

Source

Thrown at internal/ado/client.go:274

		retriable := resp.StatusCode == http.StatusTooManyRequests ||
			resp.StatusCode >= 500

		if retriable && attempt < maxAttempts {
			delay := RetryDelay * time.Duration(1<<uint(attempt))
			useServerDelay := false
			if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
				if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
					delay = time.Duration(seconds) * time.Second
					useServerDelay = true
				}
			}
			// Only add jitter to our own exponential backoff, not server-mandated delays
			if !useServerDelay {
				if half := int64(delay / 2); half > 0 {
					delay += time.Duration(rand.Int64N(half)) //nolint:gosec // G404: jitter for retry backoff does not need crypto rand
				}
			}
			lastErr = fmt.Errorf("transient error %d (attempt %d/%d)", resp.StatusCode, attempt+1, maxAttempts+1)
			select {
			case <-ctx.Done():
				return nil, ctx.Err()
			case <-time.After(delay):
				continue
			}
		}

		return nil, &APIError{StatusCode: resp.StatusCode, Body: string(respBody)}
	}

	return nil, fmt.Errorf("max retries (%d) exceeded: %w", maxAttempts+1, lastErr)
}

// addAPIVersion appends the api-version query parameter to a URL string.
func addAPIVersion(urlStr string) string {
	if strings.Contains(urlStr, "?") {
		return urlStr + "&api-version=" + APIVersion

View on GitHub (pinned to 71377f2769)

Solutions

  1. Retry the operation later with backoff; if you see it repeatedly, reduce request rate or batch IDs into fewer calls.
  2. Check the HTTP status code embedded in the message: 429 means rate limiting — slow down or request a limit increase; 5xx suggests a server-side issue.
  3. Verify your context/deadline isn't cutting retries short; increase the timeout so all retry attempts fit.
  4. Check Azure DevOps service status for ongoing incidents.
  5. Refresh/validate the PAT — 401/403-adjacent auth churn can masquerade as repeated failures.

Example fix

// before
for _, id := range all3000IDs { c.FetchWorkItems(ctx, idsChunk300) } // hammers API, 429s
// after
for i := 0; i < len(allIDs); i += 200 { c.FetchWorkItems(ctx, allIDs[i:i+200]); time.Sleep(500*time.Millisecond) }
Defensive patterns

Strategy: retry

Validate before calling

// no pre-call check possible; optionally check ADO status endpoint
if rateLimitProbeFail() { time.Sleep(30 * time.Second) }

Try / catch

_, err := client.FetchWorkItems(ctx, ids)
if err != nil {
    var re *ado.RetryableError // or inspect wrapped status
    if errors.As(err, &re) {
        time.Sleep(backoffWithJitter(attempt))
        // retry
    }
}

Prevention

When it happens

Trigger: Any ADO REST call routed through doRequest (FetchWorkItems, fetchWorkItemsByWIQL, CreateWorkItem, UpdateWorkItem, AddWorkItemLink, RemoveWorkItemLink) receiving a retryable status on every attempt — typically 429 rate limiting or 500/502/503/504 from dev.azure.com.

Common situations: Bulk scripts hammering the WIQL/work-item API and tripping rate limits; shared PAT across many jobs exhausting org quotas; ADO service degradation/incidents; long batch operations where context deadline cancels mid-backoff.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/a193249bef17c65d. Report an issue: GitHub.