gastownhall/beads · warning

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

Error message

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

What it means

GitLab returned a retryable (transient) HTTP status such as 429, 502, 503, or 504. doRequest records this message as lastErr, computes a delay (honoring Retry-After when the server sends it, otherwise exponential backoff with jitter), waits, and retries. The error is only returned to the caller if all retries are exhausted, wrapped by error 1778.

Source

Thrown at internal/gitlab/client.go:176

			delay := RetryDelay * time.Duration(1<<attempt)
			useServerDelay := false

			// Use Retry-After header if present (no jitter — respect server-mandated delay)
			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, MaxRetries+1)
			select {
			case <-ctx.Done():
				return nil, nil, ctx.Err()
			case <-time.After(delay):
				continue
			}
		}

		return nil, nil, fmt.Errorf("API error: %s (status %d)", string(respBody), resp.StatusCode)
	}

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

// applyFilter adds IssueFilter fields as query parameters to the params map.
// ProjectID filtering is done client-side (not supported by GitLab API on group endpoints).
func applyFilter(params map[string]string, filter *IssueFilter) {
	if filter == nil {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Respect the rate limit: reduce polling frequency and use the Retry-After header the client already honors
  2. Batch reads: filter by updated_since so each FetchIssues pulls fewer, changed-only issues
  3. Use a scoped personal access token with higher rate-limit tier on self-hosted GitLab
  4. Back off at the application level (jittered exponential backoff) on top of the client's internal retries

Example fix

// before
for { issues, _, err := client.ListIssues(ctx, nil) } // tight loop -> 429
// after
ticker := time.NewTicker(30 * time.Second)
for range ticker.C { issues, _, err := client.ListIssues(ctx, &IssueFilter{UpdatedSince: since}) }
Defensive patterns

Strategy: retry

Validate before calling

// pre-check rate limit headers from a cheap call
resp, _ := http.Get(base + "/api/v4/user") // inspect X-RateLimit-Remaining

Type guard

func isTransientStatus(code int) bool {
	return code == 429 || code == 500 || code == 502 || code == 503 || code == 504
}

Try / catch

if err != nil && strings.Contains(err.Error(), "transient error") {
	// wait and retry with exponential backoff + jitter
	time.Sleep(backoffFor(statusCode))
}

Prevention

When it happens

Trigger: Server responds 429 (rate limit), 500/502/503/504 on any client API call, and the retry budget (MaxRetries) is spent while the status persists; the transient message itself is stored each attempt.

Common situations: Exceeding GitLab's per-user/project rate limits with automated sync loops; GitLab deploy windows returning 502; self-hosted instance under load; hitting a shared IP with other heavy API consumers.

Related errors


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