gastownhall/beads · error
API error: %s (status %d)
Error message
API error: %s (status %d)
What it means
Linear answered with an HTTP status outside 200–299 (other than the handled 429 and OAuth-retried 401); the client aborts immediately and returns the raw response body plus the status code (internal/linear/client.go:414). Unlike transport errors, this is not retried — it is a definitive server rejection. The body usually contains Linear's JSON error explanation.
Source
Thrown at internal/linear/client.go:414
delay = RetryDelay * time.Duration(1<<attempt) // Exponential backoff
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
}
} else if delay > MaxRetryAfterDelay {
fmt.Fprintf(os.Stderr, "linear: Retry-After %v exceeds cap %v; using cap\n", delay, MaxRetryAfterDelay)
delay = MaxRetryAfterDelay
}
lastErr = fmt.Errorf("rate limited (attempt %d/%d), retrying after %v", attempt+1, MaxRetries+1, delay)
select {
case <-ctx.Done():
return nil, lastStatus, ctx.Err()
case <-time.After(delay):
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, lastStatus, fmt.Errorf("API error: %s (status %d)", string(respBody), resp.StatusCode)
}
var gqlResp struct {
Data json.RawMessage `json:"data"`
Errors []GraphQLError `json:"errors,omitempty"`
}
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, lastStatus, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(respBody))
}
if len(gqlResp.Errors) > 0 {
errMsgs := make([]string, len(gqlResp.Errors))
for i, e := range gqlResp.Errors {
errMsgs[i] = e.Message
}
return nil, lastStatus, fmt.Errorf("GraphQL errors: %s", strings.Join(errMsgs, "; "))
}
View on GitHub (pinned to 71377f2769)
Solutions
- Read the embedded body in the error — it names the exact problem (auth, scope, or server error)
- For 401/403: regenerate the Linear API key and verify it has the required scopes (read/write as needed)
- For 5xx: check Linear's status page and retry later with backoff at the call site
- Verify the configured endpoint URL is https://api.linear.to/graphql and not a proxy or typo'd host
- For OAuth mode, 401 is auto-retried once after token invalidation; in apiKey mode re-provision the key
Example fix
// before: key with read-only scope used for mutations
client := linear.NewClient(os.Getenv("LINEAR_API_KEY"))
_, err := client.Execute(ctx, mutationReq) // API error: {"errors":[{"message":"Unauthorized"}]} (status 403)
// after: create a key with write scopes and verify auth before mutating
key := os.Getenv("LINEAR_WRITE_API_KEY") // key created with write permissions
if key == "" { return errors.New("LINEAR_WRITE_API_KEY not set") }
client := linear.NewClient(key)
_, err = client.Execute(ctx, mutationReq) Defensive patterns
Strategy: try-catch
Validate before calling
func preflightAuth(ctx context.Context, client *linear.Client) error {
_, err := client.Execute(ctx, &linear.GraphQLRequest{Query: `{ viewer { id } }`})
return err // fails fast with API error body if key invalid/insufficient
} Type guard
type APIError struct{ Status int; Body string }
func asAPIError(err error) (status int, body string, ok bool) {
m := regexp.MustCompile(`API error: (.*) \(status (\d+)\)`).FindStringSubmatch(err.Error())
if m == nil { return 0, "", false }
n, _ := strconv.Atoi(m[2])
return n, m[1], true
} Try / catch
_, err := client.Execute(ctx, req)
if err != nil {
if status, body, ok := asAPIError(err); ok {
switch {
case status == 401 || status == 403:
return fmt.Errorf("linear auth failed (status %d): %s — check API key/scopes", status, body)
case status >= 500:
return fmt.Errorf("linear outage (status %d), retry later: %s", status, body)
}
}
return err
} Prevention
- Validate the API key with a cheap `{ viewer { id } }` query before batch operations
- Create Linear keys with the scopes the operations need (read vs write)
- Keep the endpoint on https://api.linear.to/graphql; don't route through experimental proxies
- Monitor Linear's status page for 5xx windows and pause jobs during incidents
When it happens
Trigger: 401 with an invalid/expired API key in non-OAuth mode, 403 for insufficient scopes or a team the key cannot access, 500/502/503 from Linear-side incidents, or 400 from a malformed endpoint/proxy. The returned error text embeds the full response body.
Common situations: Rotated or revoked Linear API keys, API keys lacking the required scopes for a mutation, wrong team/project IDs causing authorization failures, or hitting api.linear.to during an outage (5xx).
Related errors
- failed to list projects: %w
- failed to list projects: %w
- failed to get work item types: %w
- jira API returned %d: %s
- request failed (attempt %d/%d): %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/3230d340ef3c44cb.
Report an issue: GitHub.