juanfont/headscale · error
api error (%d): %s
Error message
api error (%d): %s
What it means
Returned by the headscale CLI's v2 API error decoder when the OAuth client endpoint answers with a non-2xx status whose body is JSON containing a "message" field (the Tailscale-style error body, not RFC 7807 problem+json). The CLI formats the HTTP status code and the server's message into one error string. It is a plain fmt.Errorf, so the status code is only available by parsing the text.
Source
Thrown at cmd/headscale/cli/oauth_client.go:250
return ctx, client, cancel, nil
}
// v2Error turns a non-2xx v2 response into an error. The v2 API emits the
// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the
// "message" field instead of the generated problem+json types.
func v2Error(status int, body []byte) error {
if status >= http.StatusOK && status < http.StatusMultipleChoices {
return nil
}
var e struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &e) == nil && e.Message != "" {
//nolint:err113 // surfacing the server's message
return fmt.Errorf("api error (%d): %s", status, e.Message)
}
//nolint:err113 // surfacing the server's body
return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body)))
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func ptrStrs(s *[]string) []string {
if s == nil {
return nil
}View on GitHub (pinned to 565fd254d0)
Solutions
- Read the status number in parentheses: 401/403 means fix the API key (headscale apikeys create), 400 means fix the request payload, 404 means the referenced id does not exist.
- Reproduce with curl against the same v2 endpoint with the same bearer token to see the raw body.
- If 401 persists, verify the server address and key in the CLI configuration (headscale config view / HEAADSCALE_ env vars).
- For 5xx codes, check the headscale server logs for the corresponding request failure.
Example fix
// before: creating an OAuth client with an empty redirect URI
client.CreateOAuthClientWithResponse(ctx, body)
// after: validate before sending
if len(redirectURIs) == 0 {
return fmt.Errorf("at least one redirect URI is required")
} Defensive patterns
Strategy: try-catch
Type guard
// matches "api error (401): ..." from the v2 client
func isV2APIError(err error) bool {
return err != nil && strings.HasPrefix(err.Error(), "api error (")
}
func v2Status(err error) int {
var n int
if _, e := fmt.Sscanf(err.Error(), "api error (%d)", &n); e == nil {
return n
}
return 0
} Try / catch
err := createOAuthClient(...)
if err != nil {
if isV2APIError(err) {
switch v2Status(err) {
case 401, 403:
// refresh/recreate the API key, then retry once
case 400:
// fix request payload; do not retry
default:
return err
}
}
return err
} Prevention
- Rotate API keys on a schedule and store them in a secret manager, not in scripts.
- Validate request fields (redirect URIs, names) client-side before calling the v2 API.
- Keep the CLI version aligned with the server version so generated schemas match.
When it happens
Trigger: Any call through the v2 client (OAuth 2.0 client management, e.g. creating a client with a redirect URI the server rejects) that returns 4xx/5xx with a body like {"message":"invalid redirect_uri"}. Typical statuses: 400 validation failure, 401 bad/expired API key, 404 unknown id, 500 server-side failure.
Common situations: API key created for a different server or revoked; clock skew breaking token validity; sending a field combination the v2 schema rejects; server version older/newer than the CLI's generated client, so the endpoint validates differently.
Related errors
- loading ACL policy: %w
- listing preauthkeys: %w
- creating preauthkey: %w
- expiring preauthkey: %w
- deleting preauthkey: %w
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/cc025193d7304d7b.
Report an issue: GitHub.