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

  1. 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.
  2. Reproduce with curl against the same v2 endpoint with the same bearer token to see the raw body.
  3. If 401 persists, verify the server address and key in the CLI configuration (headscale config view / HEAADSCALE_ env vars).
  4. 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

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


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/cc025193d7304d7b. Report an issue: GitHub.