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

  1. Read the embedded body in the error — it names the exact problem (auth, scope, or server error)
  2. For 401/403: regenerate the Linear API key and verify it has the required scopes (read/write as needed)
  3. For 5xx: check Linear's status page and retry later with backoff at the call site
  4. Verify the configured endpoint URL is https://api.linear.to/graphql and not a proxy or typo'd host
  5. 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

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


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