{"slug": "the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go", "title": "The cleanup that could never run: a silent Web Push leak in Go", "summary": "A developer discovered a silent bug in the Go backend of agentrq, a human-in-the-loop task manager for AI agents, where HTTP 410 Gone responses from expired Web Push subscriptions were never handled due to a misunderstanding of Go's http.Client.Do contract. The bug caused stale subscriptions to accumulate and waste resources indefinitely, with no errors or alerts to signal the problem.", "body_md": "*This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.*\n\nNothing reported this bug. No crash, no stack trace, no failing test, no alert. I found it by reading through the notifications path in [agentrq](https://github.com/agentrq/agentrq), a Go backend for a human-in-the-loop task manager for AI agents, and noticing that a block of error handling could not execute.\n\nThe code was not missing a case. It handled the case, in the wrong branch, and had a comment explaining what it was doing.\n\n`push.sendToUser`\n\nloops over a user's browser push subscriptions and delivers a notification to each one. Browsers hand out push endpoints that expire: the user clears site data, uninstalls the PWA, or the subscription lapses on its own. When that happens the push service starts answering `410 Gone`\n\nor `404 Not Found`\n\n, and the sender is supposed to drop the subscription.\n\nHere is what the code did, abbreviated:\n\n```\nresp, err := webpush.SendNotification(...)\nif err != nil {\n    zlog.Warn().Err(err)...\n    // Remove invalid/expired subscriptions (410 Gone)\n    if resp != nil && resp.StatusCode == 410 {\n        _ = c.repo.DeletePushSubscription(ctx, userID, sub.Endpoint)\n    }\n    continue\n}\nresp.Body.Close()\n```\n\nRead on its own, that block is fine. It knows about `410 Gone`\n\n. It nil-checks the response before touching it. It calls the right repository method with the right arguments. Someone understood the problem well enough to write a comment naming it.\n\n`webpush.SendNotification`\n\ncomes from `SherClockHolmes/webpush-go`\n\n(v1.4.0), and it ends with `return client.Do(req)`\n\n. So the function inherits the `http.Client.Do`\n\ncontract, which splits results in a way that matters here:\n\nA transport-level failure, like a DNS error or a refused connection, returns `(nil, err)`\n\n. `err`\n\nis non-nil, so we enter the branch, but `resp`\n\nis nil, so `resp != nil && resp.StatusCode == 410`\n\nis false.\n\nA request that completes returns `(resp, nil)`\n\n, and that includes a `410 Gone`\n\nand a `404 Not Found`\n\n. As far as `net/http`\n\nis concerned, receiving a `410`\n\nis a success: the round trip worked, the server answered. `err`\n\nis nil, so the entire `if err != nil`\n\nblock is skipped.\n\nThe two conditions the code needs are in different universes. Whenever `resp`\n\nis non-nil, `err`\n\nis nil. Whenever `err`\n\nis non-nil, `resp`\n\nis nil. `DeletePushSubscription`\n\nwas unreachable from the moment it was written.\n\nWhat I find interesting is where the mistake actually sits. Everything about Web Push in that block is correct: the right status code, the right RFC semantics, the right cleanup call. The wrong assumption is one layer down, about what `err`\n\nmeans in Go's HTTP client. The author was right about the domain and wrong about the platform, and the compiler has no opinion about that.\n\n[RFC 8030 §7.3](https://datatracker.ietf.org/doc/html/rfc8030#section-7.3) says a push service returns `404`\n\nor `410`\n\nonce a subscription no longer exists. Since those never triggered a delete, and since `DeletePushSubscription`\n\nis otherwise only called from the explicit user-initiated unsubscribe handlers, nothing in the system ever removed a dead subscription.\n\nTwo things followed. Stale rows accumulated in `push_subscriptions`\n\nwith no upper bound. And every future task or message event kept re-attempting delivery to endpoints that were already gone, which is one wasted outbound HTTP request per event, per dead subscription, forever.\n\nThis is the part I keep thinking about. The cost is not a spike, it is a slope. Each dead subscription adds a small permanent tax on every event the system will ever process, and the tax compounds as more subscriptions die. There is no moment where it breaks, so there is no moment where anyone looks.\n\nIt is also invisible to error monitoring, and not by accident. A dead endpoint answering `410`\n\nis a completed HTTP request. There is no exception to capture, no non-2xx client error thrown in the app, no log line above warning level. A dashboard would show a healthy delivery path with a slowly rising request count, which is exactly what a growing product looks like.\n\nThe cleanup moves off the error branch and onto the successful one, where the status code actually lives:\n\n```\nif err != nil {\n    zlog.Warn().Err(err).Str(\"endpoint\", sub.Endpoint).Msg(\"[push] failed to send notification\")\n    continue\n}\nresp.Body.Close()\n\n// webpush.SendNotification only returns a non-nil error for transport-level\n// failures; a delivered-but-rejected request comes back here with err == nil and\n// the failure encoded in the status code. Per RFC 8030 §7.3 the push service\n// returns 404 Not Found or 410 Gone once a subscription no longer exists, so\n// prune it — otherwise it lingers in the DB and every future event re-attempts\n// delivery to a dead endpoint.\nif resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {\n    _ = c.repo.DeletePushSubscription(ctx, userID, sub.Endpoint)\n}\n```\n\nI widened `410`\n\nto `404`\n\nbecause RFC 8030 treats both as gone, and the original comment had only mentioned one of them. The transport-error path keeps doing what it did, logging and continuing, since a network failure says nothing about whether the subscription is still valid. Retrying later is correct there.\n\nThe comment is longer than the code. That was deliberate. The thing that made this bug survive is that the broken version looked reasonable, so I wanted the next person reading it to find the reasoning about `err`\n\nand status codes right there, rather than having to rediscover the `client.Do`\n\ncontract.\n\nThis delivery path had no test coverage at all, which is the other reason the bug lasted.\n\nThe easy version of the test mocks out `SendNotification`\n\nand asserts the delete. It would have passed against the broken code just as happily, because it never exercises the branch structure that was wrong. So I stood up a real `httptest.Server`\n\nreturning `410`\n\nand `404`\n\nand let the actual library make the actual round trip.\n\nThat has a cost: `webpush.SendNotification`\n\nencrypts the payload before sending, so it needs real keys or it fails long before any HTTP happens. The tests generate them.\n\n```\n// newTestSubscriptionKeys returns a valid P-256 ECDH public key (p256dh) and a\n// 16-byte auth secret, base64url-encoded exactly as a browser PushSubscription\n// provides them. Real keys are required because webpush.SendNotification encrypts\n// the payload before sending, so bogus keys would fail before any HTTP round-trip.\nfunc newTestSubscriptionKeys(t *testing.T) (p256dh, auth string) {\n    t.Helper()\n    priv, err := ecdh.P256().GenerateKey(rand.Reader)\n    // ...\n    return base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()),\n        base64.RawURLEncoding.EncodeToString(authBytes)\n}\n```\n\n`TestSendToUser_PrunesExpiredSubscription`\n\nasserts `DeletePushSubscription(userID, endpoint)`\n\nis called for both statuses. `TestSendToUser_KeepsSubscriptionOnSuccess`\n\nasserts a `201`\n\ndoes not delete, which is the assertion that would catch someone later \"simplifying\" the condition into something that prunes healthy subscriptions.\n\n`go test -race ./internal/...`\n\nis green, `gofmt`\n\nand `go vet`\n\nclean.\n\n[agentrq/agentrq#252](https://github.com/agentrq/agentrq/pull/252), merged on July 9. It closes [#251](https://github.com/agentrq/agentrq/issues/251), the issue I opened with the diagnosis before writing the fix. Two files, 99 lines added, 4 removed, and most of the additions are tests.\n\nA comment is a claim, and a claim can be false. `// Remove invalid/expired subscriptions (410 Gone)`\n\nwas not a description of what the code did. It was a description of what someone meant it to do, and it made the block read as solved to everyone who came after.\n\nThe other thing is about where to look. This bug was not in unfamiliar or clever code. It was at a seam, in the handful of lines where application logic meets a library's error convention. The domain reasoning above the seam was right and the assumption below it was wrong, and that combination produces code that reviews well, tests green, and quietly does nothing.", "url": "https://wpnews.pro/news/the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go", "canonical_source": "https://dev.to/tonytonycoder11/the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go-1kaj", "published_at": "2026-07-27 22:16:24+00:00", "updated_at": "2026-07-27 22:30:54.014873+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["agentrq", "SherClockHolmes/webpush-go", "RFC 8030"], "alternates": {"html": "https://wpnews.pro/news/the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go", "markdown": "https://wpnews.pro/news/the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go.md", "text": "https://wpnews.pro/news/the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go.txt", "jsonld": "https://wpnews.pro/news/the-cleanup-that-could-never-run-a-silent-web-push-leak-in-go.jsonld"}}