gastownhall/beads · error

API error: %s (status %d)

Error message

API error: %s (status %d)

What it means

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.

Source

Thrown at internal/gitlab/client.go:185

			}

			// 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 {
		return
	}
	if filter.Labels != "" {
		params["labels"] = filter.Labels
	}
	if filter.Milestone != "" {
		params["milestone"] = filter.Milestone
	}
	if filter.Assignee != "" {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Read the body in the message: GitLab's JSON explains exactly what was rejected (e.g. '401 Unauthorized' vs field validation message)
  2. For 401/403: regenerate the token with the 'api' scope and correct role (at least Reporter to read, Developer to write)
  3. For 404: verify the project/group path in configuration matches GitLab exactly (case-sensitive, URL-encoded subgroups)
  4. For 422: fix the payload per the validation message (invalid label names, milestone IDs, state transitions)

Example fix

// before
Token: os.Getenv("GITLAB_TOKEN") // empty in CI -> 401
// after
tok := os.Getenv("GITLAB_TOKEN")
if tok == "" { return fmt.Errorf("GITLAB_TOKEN not set") }
Defensive patterns

Strategy: try-catch

Validate before calling

resp, err := http.Get(base + "/api/v4/projects/" + url.PathEscape(project))
if resp.StatusCode == 401 || resp.StatusCode == 403 {
	return fmt.Errorf("token invalid or missing 'api' scope")
}

Type guard

func isAuthError(err error) bool {
	return strings.Contains(err.Error(), "status 401") || strings.Contains(err.Error(), "status 403")
}

Try / catch

if err != nil && strings.Contains(err.Error(), "API error: ") {
	var status int
	fmt.Sscanf(err.Error(), "API error: %s (status %d)", new(string), &status)
	switch status {
	case 401, 403: rotateToken()
	case 404: fixProjectPath()
	case 422: fixPayload()
	}
}

Prevention

When it happens

Trigger: 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).

Common situations: 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).

Related errors


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