{"record":{"id":"148fac70209b9695","repo":"gastownhall/beads","slug":"api-error-s-status-d","errorCode":null,"errorMessage":"API error: %s (status %d)","messagePattern":"API error: (.+?) \\(status (.+?)\\)","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/gitlab/client.go","lineNumber":185,"sourceCode":"\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 {\n\t\treturn\n\t}\n\tif filter.Labels != \"\" {\n\t\tparams[\"labels\"] = filter.Labels\n\t}\n\tif filter.Milestone != \"\" {\n\t\tparams[\"milestone\"] = filter.Milestone\n\t}\n\tif filter.Assignee != \"\" {","sourceCodeStart":167,"sourceCodeEnd":203,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/gitlab/client.go#L167-L203","documentation":"GitLab returned a non-2xx, non-transient status (e.g. 401, 403, 404, 422). The response body is included verbatim in the message along with the status code, and the error is returned immediately without retrying, since retrying permanent failures is pointless. This is the terminal error for definitive API rejections.","triggerScenarios":"401 from an invalid/expired/revoked PRIVATE-TOKEN; 403 insufficient permissions on the project/group; 404 wrong project path or private project; 422 validation rejection on create/update (bad labels, title too long, closed-state conflicts).","commonSituations":"Token rotated or expired in CI secrets; token missing 'api' scope; repo moved/renamed so the configured project path 404s; trying to update an issue field GitLab rejects (e.g. invalid state_event or milestone id).","solutions":["Read the body in the message: GitLab's JSON explains exactly what was rejected (e.g. '401 Unauthorized' vs field validation message)","For 401/403: regenerate the token with the 'api' scope and correct role (at least Reporter to read, Developer to write)","For 404: verify the project/group path in configuration matches GitLab exactly (case-sensitive, URL-encoded subgroups)","For 422: fix the payload per the validation message (invalid label names, milestone IDs, state transitions)"],"exampleFix":"// before\nToken: os.Getenv(\"GITLAB_TOKEN\") // empty in CI -> 401\n// after\ntok := os.Getenv(\"GITLAB_TOKEN\")\nif tok == \"\" { return fmt.Errorf(\"GITLAB_TOKEN not set\") }","handlingStrategy":"try-catch","validationCode":"resp, err := http.Get(base + \"/api/v4/projects/\" + url.PathEscape(project))\nif resp.StatusCode == 401 || resp.StatusCode == 403 {\n\treturn fmt.Errorf(\"token invalid or missing 'api' scope\")\n}","typeGuard":"func isAuthError(err error) bool {\n\treturn strings.Contains(err.Error(), \"status 401\") || strings.Contains(err.Error(), \"status 403\")\n}","tryCatchPattern":"if err != nil && strings.Contains(err.Error(), \"API error: \") {\n\tvar status int\n\tfmt.Sscanf(err.Error(), \"API error: %s (status %d)\", new(string), &status)\n\tswitch status {\n\tcase 401, 403: rotateToken()\n\tcase 404: fixProjectPath()\n\tcase 422: fixPayload()\n\t}\n}","preventionTips":["Validate the token at startup with a cheap /user call","Keep the token in a secret manager and rotate before expiry; require 'api' scope","Pin and verify the project path in config; URL-encode subgroup paths","Read the response body embedded in the error message - GitLab states the exact rejection reason"],"tags":["api-error","http-status","auth","gitlab"],"backgroundTag":"api-error-status","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}