tailscale/tailscale · error

tailscale.Routes: %w

Error message

tailscale.Routes: %w

What it means

Wrapper error from Client.Routes: any failure fetching a device's subnet routes via GET /api/v2/device/<id>/routes is wrapped as 'tailscale.Routes: %w'. Wrapped causes: http.NewRequestWithContext failure, transport error from sendRequest, non-200 converted by HandleErrorResponse (ErrResponse), or JSON decode failure — note netip.Prefix fields fail to unmarshal if the server sends invalid prefixes.

Source

Thrown at client/tailscale/routes.go:30

	"fmt"
	"net/http"
	"net/netip"
)

// Routes contains the lists of subnet routes that are currently advertised by a device,
// as well as the subnets that are enabled to be routed by the device.
type Routes struct {
	AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
	EnabledRoutes    []netip.Prefix `json:"enabledRoutes"`
}

// Routes retrieves the list of subnet routes that have been enabled for a device.
// The routes that are returned are not necessarily advertised by the device,
// they have only been preapproved.
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("tailscale.Routes: %w", err)
		}
	}()

	path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
	req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
	if err != nil {
		return nil, err
	}

	b, resp, err := c.sendRequest(req)
	if err != nil {
		return nil, err
	}
	// If status code was not successful, return the error.
	// TODO: Change the check for the StatusCode to include other 2XX success codes.
	if resp.StatusCode != http.StatusOK {
		return nil, HandleErrorResponse(b, resp)
	}

View on GitHub (pinned to cfe32b8be6)

Solutions

  1. errors.As(err, &tailscale.ErrResponse{}) and handle 401/404 specifically.
  2. Validate the deviceID exists via Client.Devices first.
  3. Sanitize deviceID (numeric node IDs only) before building the path.
  4. If a JSON prefix error appears, print the body to spot schema drift.

Example fix

// before
 r, err := c.Routes(ctx, deviceID)
 if err != nil { return err }

// after
 r, err := c.Routes(ctx, deviceID)
 if err != nil {
     var apiErr tailscale.ErrResponse
     if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
         return nil, fmt.Errorf("device %s not found", deviceID)
     }
     return err
 }
Defensive patterns

Strategy: try-catch

Validate before calling

if deviceID == "" || !isNumeric(deviceID) {
    return fmt.Errorf("invalid deviceID %q", deviceID)
}
ids, _ := c.Devices(ctx, nil) // optional existence pre-check

Type guard

func isErrResponse(err error) (tailscale.ErrResponse, bool) {
    var e tailscale.ErrResponse
    return e, errors.As(err, &e)
}

Try / catch

r, err := c.Routes(ctx, deviceID)
if err != nil {
    var apiErr tailscale.ErrResponse
    if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
        return nil, ErrDeviceGone
    }
    return nil, err
}

Prevention

When it happens

Trigger: Calling Routes with a nonexistent deviceID (404), unauthorized token (401), deviceID containing path-breaking characters (URL parse error — this function does not PathEscape), or a response whose advertised/enabled routes are not parseable netip prefixes.

Common situations: Inventory scripts querying routes for devices already removed; passing a deviceID with a slash/space breaking the URL; control server and client library version skew changing the routes payload shape.

Related errors


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