Skip to content

Return an empty usage list for a user with no subscription - #42

Merged
johnworth merged 4 commits into
mainfrom
fix-usages-without-subscription
Aug 12, 2026
Merged

johnworth merged 4 commits into
mainfrom
fix-usages-without-subscription

Conversation

@johnworth

@johnworth johnworth commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

The bug

GET /users/{username}/usages returned 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.GetActiveSubscription discarded the not-found flag from its scan:

if _, err = query.Executor().ScanStructContext(ctx, &result); err != nil {

On a miss that leaves result zeroed and returns &Subscription{} with ID == "" and a nil error. Callers passed that empty string to queries comparing it against a uuid NOT NULL column, and PostgreSQL rejected the statement:

pq: invalid input syntax for type uuid: ""

Nothing recognizes that error, so it fell through to a 500 and the generic message.

Blast radius

One defect, three broken routes:

Route Before After
GET /users/{u}/usages 500 200 {"usages":[]}
PUT /users/{u}/usages 500 200, subscribes the user
PUT /user/{u}/updates (quotas) 500 200, subscribes the user
PUT /user/{u}/updates (usages) 200 unchanged

Only ProcessUpdateForUsage guarded against it, which is why usage updates worked while everything around them did not.

Commit 1 — the fix

GetActiveSubscription now honors the flag and returns nil when there is no active subscription — matching GetSubscriptionByID beside it and the QMS-side implementation, both of which already got this right. Write paths move to a new GetOrCreateActiveSubscription, which also replaces the two hand-copied subscribe-to-default-plan blocks in summary.go and updates.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:

log = log.WithFields(logrus.Fields{"user": username})

The username then stuck to every line the process logged afterwards:

uri=/users/bob/usages   user=alice
uri=/addons             user=alice

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):

  • all four routes above for a user with no subscription
  • listing usages creates no subscription; recording usage does
  • a username from one request does not appear in the next request's logs, sequentially and concurrently
  • the request log names the request's own user, for both route parameter spellings

Both new test files were verified to fail against the pre-fix code. Full suite passes under -race, golangci-lint reports 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 qa namespace (sha256:fa44976f…):

GET /users/zzznewuser99@iplantcollaborative.org/usages   → 200 {"usages":[]}   (was 500)
GET /users/wregglej@iplantcollaborative.org/usages       → 200, full usage list intact
GET /users/{u}/overages                                  → 200, unchanged

Log lines each carry their own correct user, and /addons carries none.

🤖 Generated with Claude Code

John Wregglesworth and others added 4 commits August 12, 2026 12:24
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>
@johnworth
johnworth merged commit 60a009e into main Aug 12, 2026
4 of 5 checks passed
@johnworth
johnworth deleted the fix-usages-without-subscription branch August 12, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant