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

  1. Print/inspect resp.StatusCode before the check (or capture the body) to identify 401/403/404/500.
  2. Verify the API key is valid and has ACL permissions for the tailnet.
  3. Check the tailnet name and --api-server spelling.
  4. 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

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


AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15). Data as JSON: /api/errors/76cf9f89a03f2673. Report an issue: GitHub.