tailscale/tailscale · error
wanted HTTP status code %d but got %d
Error message
wanted HTTP status code %d but got %d
What it means
In testNewACLs, gitops-pusher POSTs the policy to https://<api-server>/api/v2/tailnet/<tailnet>/acl/validate. If the response body carries no ACLTestError message/data but the HTTP status is not 200, this generic mismatch error is returned (want is hardcoded to http.StatusOK). Typical statuses: 401/403 from a bad API key, 5xx during control outages, or non-200 responses whose bodies decode to an empty error object.
Source
Thrown at cmd/gitops-pusher/gitops-pusher.go:372
if err != nil {
return err
}
defer resp.Body.Close()
var ate ACLGitopsTestError
err = json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
}
if len(ate.Message) != 0 || len(ate.Data) != 0 {
return ate
}
got := resp.StatusCode
want := http.StatusOK
if got != want {
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
}
return nil
}
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
// ACLGitopsTestError is redefined here so we can add a custom .Error() response
type ACLGitopsTestError struct {
tsclient.ACLTestError
}
func (ate ACLGitopsTestError) Error() string {
var sb strings.Builder
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) {
sp := lineColMessageSplit.FindStringSubmatch(ate.Message)
View on GitHub (pinned to cfe32b8be6)
Solutions
- Print/inspect resp.StatusCode before the check (or capture the body) to identify 401/403/404/500.
- Verify the API key is valid and has ACL permissions for the tailnet.
- Check the tailnet name and --api-server spelling.
- Retry transient 5xx; check status.tailscale.com for control incidents.
Example fix
// before: opaque mismatch error
if got != want {
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
}
// after: include the status text and a body snippet for diagnosis
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
if got != want {
return fmt.Errorf("wanted HTTP status code %d but got %d (%s): %s", want, got, http.StatusText(got), body)
} Defensive patterns
Strategy: try-catch
Validate before calling
// Pre-validate credentials before pushing policy.
func apiKeyWorks(server, key string) bool {
req, _ := http.NewRequest("GET", "https://"+server+"/api/v2/tailnet/-/acl", nil)
req.SetBasicAuth(key, "")
resp, err := http.DefaultClient.Do(req)
if err != nil { return false }
resp.Body.Close()
return resp.StatusCode != 401 && resp.StatusCode != 403
} Type guard
func isStatusMismatch(err error) bool {
return err != nil && strings.Contains(err.Error(), "wanted HTTP status code")
} Try / catch
err := testNewACLs(ctx, tailnet, *policyFname)
if isStatusMismatch(err) {
// 401/403 => credentials; 5xx => retry with backoff; re-run after fixing keys
} Prevention
- Rotate API keys before expiry; store them in CI secret managers, not the repo.
- Fail fast on 401/403 (never retry auth errors).
- Retry only 5xx with exponential backoff and jitter.
- Log resp.StatusCode plus body snippet when wrapping this call.
When it happens
Trigger: Calling the test/apply flow with an invalid or expired API key, wrong --api-server, a tailnet name that does not exist, or hitting a control-plane 5xx whose body decodes to an empty JSON object (no 'message'/'data' fields), falling through to the status-code check.
Common situations: Expired ts-api-key in CI secrets; typo in --org/--tailnet; api.tailscale.com incident; proxy returning an HTML error page that fails JSON decode earlier (that surfaces as the json error instead).
Related errors
- tailscale.ACL: %w
- tailscale.ACLHuJSON: %w
- json.Unmarshal %q: %w
- tailscale.SetACL: %w
- tailscale.SetACLHuJSON: %w
AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15).
Data as JSON: /api/errors/76cf9f89a03f2673.
Report an issue: GitHub.