Repository navigation
Return an empty usage list for a user with no subscription - #42
Merged
Merged
Conversation
GetActiveSubscription discarded the not-found flag from its scan, so a
user with no active subscription came back as a zeroed Subscription with
an empty ID and a nil error rather than as a miss. Callers then passed
that empty string on to queries comparing it against a uuid column, and
PostgreSQL rejected the statement:
pq: invalid input syntax for type uuid: ""
That is an error nothing recognizes, so it surfaced as a 500 carrying the
generic message. Three routes were affected:
GET /users/{username}/usages
PUT /users/{username}/usages
PUT /user/{username}/updates (value_type: quotas)
Only ProcessUpdateForUsage guarded against it, which is why usage updates
worked while everything else around them did not. resource-usage-api
reads the usage listing to decide whether a first data-usage reading
exists, and could not tell "none recorded" from a genuine fault, so
affected users never got a first reading at all.
GetActiveSubscription now honors the flag and returns nil when there is
no active subscription, matching GetSubscriptionByID beside it and the
QMS-side implementation, which both already got this right. The write
paths move to GetOrCreateActiveSubscription, which also replaces the two
hand-copied subscribe-to-default-plan blocks in summary.go and updates.go.
Reading the usage list reports the empty list rather than subscribing
anyone: a GET that quietly enrolled whoever was named in the URL is a
side effect callers have already been surprised by on /summary. The write
paths do enrol an unsubscribed user, as the usage-update path already did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five handlers built their per-request logger by assigning to the
package-level one:
log = log.WithFields(logrus.Fields{"user": username})
That is a write to a shared variable, not a local shadow, so the username
stayed attached to every line the process logged afterwards. A request
about one user was logged against whoever came before it:
uri=/users/bob/usages user=alice
uri=/addons user=alice
Anyone triaging by username was therefore pointed at the wrong user, and
this is what made the uuid failure on the usage listing hard to place:
the log line naming the failure carried an unrelated caller's name.
Concurrent requests also raced on the variable, which the race detector
confirms.
Three of the five sites never logged anything at all, so their only
effect was on other requests' output; those calls are removed along with
the imports they needed. The two that do log now shadow with :=.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user field on the request log came from whichever handler had last assigned it to the package logger, so removing those assignments took the field off the access log entirely. The information it was meant to carry is worth having: an operator triaging a failure wants every line for a user, and the uri is the only other place the name appears. The request logger now reads the name from the route it matched, so the field is present on every request that names a user and is always that request's own subject. Requests that matched no route have no user to name and carry no field, rather than borrowing the last one seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GetOrCreateActiveSubscription builds on two lookups that report "absent"
as a nil result with no error, and dereferenced both.
EnsureUser upserts and reads back in one statement, so a transaction that
inserts the same new username first leaves the insert with nothing to
return and the read behind the statement's snapshot: the user exists and
the call still reports absent. Two simultaneous first-touch writes for
the same new user are enough to hit it, which this change makes more
reachable by routing quota updates and PUT /users/{u}/usages through here.
GetPlanByName reports a missing plan the same way, and GetActiveRate
takes a value receiver, so a nil plan is dereferenced inside
SetActiveSubscription rather than returning nil.
Either one panicked. middleware.Recover turned that into a 500 carrying
"runtime error: invalid memory address or nil pointer dereference" in the
response body -- the opaque failure this branch set out to stop emitting,
reintroduced by the code meant to fix it. Both are now reported as errors
that name the cause, and the messages name the user so a failed
auto-subscribe can be attributed from the logs.
Also restores the fields the request logger lost for PUT /users: it takes
its username from the body rather than the path, so the route says
nothing about who was subscribed to which plan or whether it was paid.
That is now an explicit log line where the subscription is created,
rather than fields left on the package logger for other requests to
inherit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
GET /users/{username}/usagesreturned 500 for any user with no subscription. Reported by the resource-usage-api merge work, which reads that route to decide whether a first data-usage reading exists — it could not tell "none recorded" from a genuine fault, so affected users never got a first reading at all. Roughly half of a probe of random DE users hit it in QA. It predates that merge.Root cause
db.GetActiveSubscriptiondiscarded the not-found flag from its scan:On a miss that leaves
resultzeroed and returns&Subscription{}withID == ""and a nil error. Callers passed that empty string to queries comparing it against auuid NOT NULLcolumn, and PostgreSQL rejected the statement:Nothing recognizes that error, so it fell through to a 500 and the generic message.
Blast radius
One defect, three broken routes:
GET /users/{u}/usages{"usages":[]}PUT /users/{u}/usagesPUT /user/{u}/updates(quotas)PUT /user/{u}/updates(usages)Only
ProcessUpdateForUsageguarded against it, which is why usage updates worked while everything around them did not.Commit 1 — the fix
GetActiveSubscriptionnow honors the flag and returnsnilwhen there is no active subscription — matchingGetSubscriptionByIDbeside it and the QMS-side implementation, both of which already got this right. Write paths move to a newGetOrCreateActiveSubscription, which also replaces the two hand-copied subscribe-to-default-plan blocks insummary.goandupdates.go.Behavior worth reviewing: reading the usage list reports the empty list rather than subscribing anyone — a GET that quietly enrolled whoever was named in the URL is a side effect callers have already been surprised by on
/summary. The write paths do enrol an unsubscribed user, matching what the usage-update path has always done.Commit 2 — the logger bug that hid this
Chasing why the failure was hard to place turned up a separate defect. Five handlers built their per-request logger by assigning to the package-level one instead of shadowing it:
The username then stuck to every line the process logged afterwards:
So anyone triaging by username was pointed at the wrong user — the line naming this uuid failure carried an unrelated caller's name. Concurrent requests also raced on the variable; the race detector confirms it. Three of the five sites never logged anything at all, so their only effect was on other requests' output; those are removed.
Commit 3 — put the user back on the request log
Removing those assignments took
user=off the access log entirely, since the request logger never set it itself. It now reads the name from the route it matched, so the field is present on every request that names a user, is always that request's own subject, and is absent (rather than stale) on requests that matched no route.Testing
New coverage in
apitest/against a real PostgreSQL (testcontainers):Both new test files were verified to fail against the pre-fix code. Full suite passes under
-race,golangci-lintreports 0 issues, and every pre-existing golden still matches — nothing on the wire changed for the working cases.Verified in QA
Built from this branch and deployed to the
qanamespace (sha256:fa44976f…):Log lines each carry their own correct user, and
/addonscarries none.🤖 Generated with Claude Code