cd /news/developer-tools/the-cleanup-that-could-never-run-a-s… · home topics developer-tools article
[ARTICLE · art-76096] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

The cleanup that could never run: a silent Web Push leak in Go

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.

read6 min views1 publishedJul 27, 2026

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

Nothing reported this bug. No crash, no stack trace, no failing test, no alert. I found it by reading through the notifications path in 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.

The code was not missing a case. It handled the case, in the wrong branch, and had a comment explaining what it was doing.

push.sendToUser

loops 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

or 404 Not Found

, and the sender is supposed to drop the subscription.

Here is what the code did, abbreviated:

resp, err := webpush.SendNotification(...)
if err != nil {
    zlog.Warn().Err(err)...
    // Remove invalid/expired subscriptions (410 Gone)
    if resp != nil && resp.StatusCode == 410 {
        _ = c.repo.DeletePushSubscription(ctx, userID, sub.Endpoint)
    }
    continue
}
resp.Body.Close()

Read on its own, that block is fine. It knows about 410 Gone

. 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.

webpush.SendNotification

comes from SherClockHolmes/webpush-go

(v1.4.0), and it ends with return client.Do(req)

. So the function inherits the http.Client.Do

contract, which splits results in a way that matters here:

A transport-level failure, like a DNS error or a refused connection, returns (nil, err)

. err

is non-nil, so we enter the branch, but resp

is nil, so resp != nil && resp.StatusCode == 410

is false.

A request that completes returns (resp, nil)

, and that includes a 410 Gone

and a 404 Not Found

. As far as net/http

is concerned, receiving a 410

is a success: the round trip worked, the server answered. err

is nil, so the entire if err != nil

block is skipped.

The two conditions the code needs are in different universes. Whenever resp

is non-nil, err

is nil. Whenever err

is non-nil, resp

is nil. DeletePushSubscription

was unreachable from the moment it was written.

What 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

means 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.

RFC 8030 §7.3 says a push service returns 404

or 410

once a subscription no longer exists. Since those never triggered a delete, and since DeletePushSubscription

is otherwise only called from the explicit user-initiated unsubscribe handlers, nothing in the system ever removed a dead subscription.

Two things followed. Stale rows accumulated in push_subscriptions

with 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.

This 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.

It is also invisible to error monitoring, and not by accident. A dead endpoint answering 410

is 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.

The cleanup moves off the error branch and onto the successful one, where the status code actually lives:

if err != nil {
    zlog.Warn().Err(err).Str("endpoint", sub.Endpoint).Msg("[push] failed to send notification")
    continue
}
resp.Body.Close()

// webpush.SendNotification only returns a non-nil error for transport-level
// failures; a delivered-but-rejected request comes back here with err == nil and
// the failure encoded in the status code. Per RFC 8030 §7.3 the push service
// returns 404 Not Found or 410 Gone once a subscription no longer exists, so
// prune it — otherwise it lingers in the DB and every future event re-attempts
// delivery to a dead endpoint.
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
    _ = c.repo.DeletePushSubscription(ctx, userID, sub.Endpoint)
}

I widened 410

to 404

because 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.

The 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

and status codes right there, rather than having to rediscover the client.Do

contract.

This delivery path had no test coverage at all, which is the other reason the bug lasted.

The easy version of the test mocks out SendNotification

and 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

returning 410

and 404

and let the actual library make the actual round trip.

That has a cost: webpush.SendNotification

encrypts the payload before sending, so it needs real keys or it fails long before any HTTP happens. The tests generate them.

// newTestSubscriptionKeys returns a valid P-256 ECDH public key (p256dh) and a
// 16-byte auth secret, base64url-encoded exactly as a browser PushSubscription
// provides them. Real keys are required because webpush.SendNotification encrypts
// the payload before sending, so bogus keys would fail before any HTTP round-trip.
func newTestSubscriptionKeys(t *testing.T) (p256dh, auth string) {
    t.Helper()
    priv, err := ecdh.P256().GenerateKey(rand.Reader)
    // ...
    return base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()),
        base64.RawURLEncoding.EncodeToString(authBytes)
}

TestSendToUser_PrunesExpiredSubscription

asserts DeletePushSubscription(userID, endpoint)

is called for both statuses. TestSendToUser_KeepsSubscriptionOnSuccess

asserts a 201

does not delete, which is the assertion that would catch someone later "simplifying" the condition into something that prunes healthy subscriptions.

go test -race ./internal/...

is green, gofmt

and go vet

clean.

agentrq/agentrq#252, merged on July 9. It closes #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.

A comment is a claim, and a claim can be false. // Remove invalid/expired subscriptions (410 Gone)

was 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.

The 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @agentrq 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-cleanup-that-cou…] indexed:0 read:6min 2026-07-27 ·