{"record":{"id":"c98cb52319262f39","repo":"juanfont/headscale","slug":"setting-node-expiry-w","errorCode":null,"errorMessage":"setting node expiry: %w","messagePattern":"setting node expiry: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/auth.go","lineNumber":265,"sourceCode":"\t\treturn nodeToRegisterResponse(node), nil\n\t}\n\n\t// Update the internal state with the nodes new expiry, meaning it is\n\t// logged out.\n\t//\n\t// Clamp the client-supplied value to now: Tailscale sends the\n\t// sentinel time.Unix(123, 0) on logout (controlclient/direct.go),\n\t// and storing it verbatim propagates a 1970 KeyExpiry to every\n\t// peer's netmap. Semantically identical (expired as of now), but\n\t// sane in logs, debug dumps, and peer netmaps.\n\texpiry := req.Expiry\n\tif now := time.Now(); expiry.Before(now) {\n\t\texpiry = now\n\t}\n\n\tupdatedNode, c, err := h.state.SetNodeExpiry(node.ID(), &expiry)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"setting node expiry: %w\", err)\n\t}\n\n\th.Change(c)\n\n\treturn nodeToRegisterResponse(updatedNode), nil\n}\n\n// isAuthKey reports if the register request is a registration request\n// using an pre auth key.\nfunc isAuthKey(req tailcfg.RegisterRequest) bool {\n\treturn req.Auth != nil && req.Auth.AuthKey != \"\"\n}\n\nfunc nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {\n\tresp := &tailcfg.RegisterResponse{\n\t\tNodeKeyExpired: node.IsExpired(),\n\n\t\t// Headscale does not implement the concept of machine authorization","sourceCodeStart":247,"sourceCodeEnd":283,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/auth.go#L247-L283","documentation":"Wraps a failure of h.state.SetNodeExpiry(node.ID(), &expiry) during logout of a non-ephemeral node (hscontrol/auth.go:265). The code clamps the client-supplied expiry (tailscaled sends the 1970 sentinel time.Unix(123,0)) to now, then persists it; if the state/DB update fails, the error is returned and the node remains non-expired.","triggerScenarios":"A normal (non-ephemeral) node logs out while the database errors: connection lost, SQLite write lock timeout, or node row updated concurrently by the mapper (optimistic-update conflict).","commonSituations":"Logout racing a netmap update on the same node; DB restarts; SQLite deployments with concurrent writers.","solutions":["Read the wrapped error to identify the DB cause and address it (locks, connectivity).","Retry logout from the node after DB recovery — the expiry update is idempotent.","If the node cannot retry, expire it administratively: `headscale nodes expire -i <id>`.","Reduce write contention (single writer for SQLite, adequate pool for PostgreSQL) if it recurs during node churn."],"exampleFix":null,"handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"resp, err := h.handleRegister(req, mk)\nif err != nil && strings.Contains(err.Error(), \"setting node expiry\") {\n    // idempotent operation: retry logout, or run `headscale nodes expire -i <id>`\n}","preventionTips":["Treat expiry updates as idempotent retries, never as data loss.","Use the CLI expire command as an operator fallback when client retries fail.","Avoid DB maintenance overlapping logout storms."],"tags":["logout","database","node-expiry","registration"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}