{"record":{"id":"0a99c0e57f03196a","repo":"gastownhall/beads","slug":"transient-error-d-attempt-d-d-0a99c0","errorCode":null,"errorMessage":"transient error %d (attempt %d/%d)","messagePattern":"transient error (.+?) \\(attempt (.+?)/(.+?)\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"internal/gitlab/client.go","lineNumber":176,"sourceCode":"\t\t\tdelay := RetryDelay * time.Duration(1<<attempt)\n\t\t\tuseServerDelay := false\n\n\t\t\t// Use Retry-After header if present (no jitter — respect server-mandated delay)\n\t\t\tif retryAfter := resp.Header.Get(\"Retry-After\"); retryAfter != \"\" {\n\t\t\t\tif seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {\n\t\t\t\t\tdelay = time.Duration(seconds) * time.Second\n\t\t\t\t\tuseServerDelay = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Only add jitter to our own exponential backoff, not server-mandated delays\n\t\t\tif !useServerDelay {\n\t\t\t\tif half := int64(delay / 2); half > 0 {\n\t\t\t\t\tdelay += time.Duration(rand.Int64N(half)) //nolint:gosec // G404: jitter for retry backoff does not need crypto rand\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlastErr = fmt.Errorf(\"transient error %d (attempt %d/%d)\", resp.StatusCode, attempt+1, MaxRetries+1)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\treturn nil, nil, fmt.Errorf(\"API error: %s (status %d)\", string(respBody), resp.StatusCode)\n\t}\n\n\treturn nil, nil, fmt.Errorf(\"max retries (%d) exceeded: %w\", MaxRetries+1, lastErr)\n}\n\n// applyFilter adds IssueFilter fields as query parameters to the params map.\n// ProjectID filtering is done client-side (not supported by GitLab API on group endpoints).\nfunc applyFilter(params map[string]string, filter *IssueFilter) {\n\tif filter == nil {","sourceCodeStart":158,"sourceCodeEnd":194,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/gitlab/client.go#L158-L194","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Respect the rate limit: reduce polling frequency and use the Retry-After header the client already honors","Batch reads: filter by updated_since so each FetchIssues pulls fewer, changed-only issues","Use a scoped personal access token with higher rate-limit tier on self-hosted GitLab","Back off at the application level (jittered exponential backoff) on top of the client's internal retries"],"exampleFix":"// before\nfor { issues, _, err := client.ListIssues(ctx, nil) } // tight loop -> 429\n// after\nticker := time.NewTicker(30 * time.Second)\nfor range ticker.C { issues, _, err := client.ListIssues(ctx, &IssueFilter{UpdatedSince: since}) }","handlingStrategy":"retry","validationCode":"// pre-check rate limit headers from a cheap call\nresp, _ := http.Get(base + \"/api/v4/user\") // inspect X-RateLimit-Remaining","typeGuard":"func isTransientStatus(code int) bool {\n\treturn code == 429 || code == 500 || code == 502 || code == 503 || code == 504\n}","tryCatchPattern":"if err != nil && strings.Contains(err.Error(), \"transient error\") {\n\t// wait and retry with exponential backoff + jitter\n\ttime.Sleep(backoffFor(statusCode))\n}","preventionTips":["Throttle polling loops; respect Retry-After headers","Use updated_since filters to cut request volume","Request a higher rate-limit tier on self-hosted GitLab if you're a heavy integrator","Spread scheduled sync jobs across time instead of thundering-herd cron"],"tags":["rate-limit","http-status","retry","gitlab"],"backgroundTag":"http-429-rate-limited","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}