{"record":{"id":"3230d340ef3c44cb","repo":"gastownhall/beads","slug":"api-error-s-status-d-3230d3","errorCode":null,"errorMessage":"API error: %s (status %d)","messagePattern":"API error: (.+?) \\(status (.+?)\\)","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/linear/client.go","lineNumber":414,"sourceCode":"\t\t\t\tdelay = RetryDelay * time.Duration(1<<attempt) // Exponential backoff\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} else if delay > MaxRetryAfterDelay {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"linear: Retry-After %v exceeds cap %v; using cap\\n\", delay, MaxRetryAfterDelay)\n\t\t\t\tdelay = MaxRetryAfterDelay\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"rate limited (attempt %d/%d), retrying after %v\", attempt+1, MaxRetries+1, delay)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, lastStatus, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t\treturn nil, lastStatus, fmt.Errorf(\"API error: %s (status %d)\", string(respBody), resp.StatusCode)\n\t\t}\n\n\t\tvar gqlResp struct {\n\t\t\tData   json.RawMessage `json:\"data\"`\n\t\t\tErrors []GraphQLError  `json:\"errors,omitempty\"`\n\t\t}\n\t\tif err := json.Unmarshal(respBody, &gqlResp); err != nil {\n\t\t\treturn nil, lastStatus, fmt.Errorf(\"failed to parse response: %w (body: %s)\", err, string(respBody))\n\t\t}\n\n\t\tif len(gqlResp.Errors) > 0 {\n\t\t\terrMsgs := make([]string, len(gqlResp.Errors))\n\t\t\tfor i, e := range gqlResp.Errors {\n\t\t\t\terrMsgs[i] = e.Message\n\t\t\t}\n\t\t\treturn nil, lastStatus, fmt.Errorf(\"GraphQL errors: %s\", strings.Join(errMsgs, \"; \"))\n\t\t}\n","sourceCodeStart":396,"sourceCodeEnd":432,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/linear/client.go#L396-L432","documentation":"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.","triggerScenarios":"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.","commonSituations":"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).","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"],"exampleFix":"// before: key with read-only scope used for mutations\nclient := linear.NewClient(os.Getenv(\"LINEAR_API_KEY\"))\n_, err := client.Execute(ctx, mutationReq) // API error: {\"errors\":[{\"message\":\"Unauthorized\"}]} (status 403)\n// after: create a key with write scopes and verify auth before mutating\nkey := os.Getenv(\"LINEAR_WRITE_API_KEY\") // key created with write permissions\nif key == \"\" { return errors.New(\"LINEAR_WRITE_API_KEY not set\") }\nclient := linear.NewClient(key)\n_, err = client.Execute(ctx, mutationReq)","handlingStrategy":"try-catch","validationCode":"func preflightAuth(ctx context.Context, client *linear.Client) error {\n    _, err := client.Execute(ctx, &linear.GraphQLRequest{Query: `{ viewer { id } }`})\n    return err // fails fast with API error body if key invalid/insufficient\n}","typeGuard":"type APIError struct{ Status int; Body string }\nfunc asAPIError(err error) (status int, body string, ok bool) {\n    m := regexp.MustCompile(`API error: (.*) \\(status (\\d+)\\)`).FindStringSubmatch(err.Error())\n    if m == nil { return 0, \"\", false }\n    n, _ := strconv.Atoi(m[2])\n    return n, m[1], true\n}","tryCatchPattern":"_, err := client.Execute(ctx, req)\nif err != nil {\n    if status, body, ok := asAPIError(err); ok {\n        switch {\n        case status == 401 || status == 403:\n            return fmt.Errorf(\"linear auth failed (status %d): %s — check API key/scopes\", status, body)\n        case status >= 500:\n            return fmt.Errorf(\"linear outage (status %d), retry later: %s\", status, body)\n        }\n    }\n    return err\n}","preventionTips":["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"],"tags":["http","api-error","authentication","linear-client"],"backgroundTag":"http-api-error-status","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}