DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two Postgres error codes are the only ones we retry, and the retry sits underneath a React cache()

Every signed-in page in PubTrivia needs the same two facts before it can render anything: who you are, and which venue you run. The layout needs them to draw the header. The page needs them to query. Any server action fired from that page needs them again to authorise itself.

Both facts are expensive in different ways. The first is a network round trip to an auth server. The second is up to two Postgres queries over a connection that may have just been created from cold. So both of them live in one 77 line file, and that file contains the only retry loop in the repository.

The auth call is a network call, 33 times

export const getAuthenticatedUser = cache(async () => {
    const supabase = await createClient()
    const { data: { user }, error } = await supabase.auth.getUser()
    if (error || !user) redirect('/login')
    return user
})
Enter fullscreen mode Exit fullscreen mode

cache is React's per-request memo, not a cross-request cache. Inside one render pass, every call with the same arguments returns the same promise; the next request starts clean. That matters here because supabase.auth.getUser() is not a cookie read. It is an HTTPS request to the Supabase auth endpoint that validates the token, every time, which is the whole point of preferring it over the local session read.

Counting the call sites:

$ grep -rn "await getAuthenticatedUser()" app components | wc -l
33
Enter fullscreen mode Exit fullscreen mode

Thirty-three, across 19 files: page components, layouts, and every action that writes a table, a pack, a team invite or a profile. Without the memo, a dashboard page rendering inside its layout and then firing one action would be three validations of the same token in a couple of seconds.

The layout carries the comment that explains why it goes through the helper rather than doing the obvious thing:

// getAuthenticatedUser is cache()-wrapped, so this call and the ones in the
// page rendered inside this layout collapse into a single Auth round-trip.
// Calling supabase.auth.getUser() directly here defeated that, and getUser()
// always hits the Supabase Auth endpoint to validate the JWT.
Enter fullscreen mode Exit fullscreen mode

That comment exists because somebody, me, once wrote the direct call in the layout, which is the shortest path and also the one that quietly doubles the auth traffic of the entire signed-in product.

The venue lookup, and the one retry loop in the codebase

async function withRetry<T>(fn: () => Promise<T>, attempts = 3, delayMs = 150): Promise<T> {
    let lastError: unknown
    for (let i = 0; i < attempts; i++) {
        try {
            return await fn()
        } catch (err: unknown) {
            const code = (err as { code?: string })?.code
            // 57014 = statement_timeout, 57P01 = admin shutdown, both transient
            if (code === '57014' || code === '57P01') {
                lastError = err
                if (i < attempts - 1) {
                    await new Promise(r => setTimeout(r, delayMs * (i + 1)))
                }
            } else {
                throw err
            }
        }
    }
    throw lastError
}
Enter fullscreen mode Exit fullscreen mode

Three attempts, 150ms then 300ms between them, so the worst case adds 450ms of waiting to a request that was going to fail anyway. Two SQLSTATEs are retried and every other error is rethrown on the first attempt, with no logging and no wrapping.

The two codes are not a general "transient error" list, they are the two specific ways a pooled connection can be dead or slow when you get it:

57014, statement_timeout. The statement ran out of time. On a cold Supabase pooler connection this shows up for a query that normally takes single-digit milliseconds, because the time was spent somewhere other than executing it.

57P01, admin_shutdown. The backend you were handed went away underneath you. In transaction pooling mode your connection is a lease on a backend, and a lease can end.

Both are worth one more go. A constraint violation, a syntax error, a permission failure and a connection refusal are not, and the else branch is as important as the if: an exhausted connection limit retried three times is three times the load on the thing that is already saturated.

This sits directly on top of the connection configuration I wrote about separately, where the production client is deliberately set to max: 1 with idle_timeout: 0 so that no connection ever survives long enough to go stale. The retry is what covers the case where the reconnect itself lands badly. Serverless Postgres is a stack of small defences like this; no single one of them is the reason it works.

What the retry actually wraps

export const getUserVenue = cache(async (userId?: string) => {
    const uid = userId ?? (await getAuthenticatedUser()).id

    return withRetry(async () => {
        // 1. Fast path: user is the owner of a venue
        const ownedVenues = await db.select().from(venuesTable)
            .where(eq(venuesTable.owner_user_id, uid)).limit(1)
        if (ownedVenues.length > 0) return ownedVenues[0]

        // 2. Membership path: user was invited to a venue as a host.
        const memberRows = await db.select({ venue: venuesTable })
            .from(venueMembersTable)
            .innerJoin(venuesTable, eq(venueMembersTable.venue_id, venuesTable.id))
            .where(and(
                eq(venueMembersTable.user_id, uid),
                eq(venueMembersTable.status, 'active'),
            ))
            .limit(1)

        return memberRows[0]?.venue ?? null
    })
})
Enter fullscreen mode Exit fullscreen mode

Two queries, in order, because there are two ways to be a host: you own the venue, or you were invited to somebody else's. Owners are the common case and get one query. Invited hosts pay for two, which is the right way round.

Note what the retry encloses: the whole function, not each query. If the membership join times out, the retry re-runs the ownership query too. For two idempotent reads that is harmless and it is also simpler to reason about than per-statement retries, but it means a retried call is up to four queries, and that is worth knowing before you put a write inside one of these.

The cache key is the argument list, which we use two ways

cache() memoises per argument list. These are not the same entry:

const venue = await getUserVenue(user.id)   // 17 call sites
const venue = await getUserVenue()          // 2 call sites
Enter fullscreen mode Exit fullscreen mode
$ grep -rn "getUserVenue(user.id)" app | wc -l
17
$ grep -rn "getUserVenue()" app | grep -v dashboard/actions.ts | wc -l
2
Enter fullscreen mode Exit fullscreen mode

Seventeen calls pass the id they already have. Two call it bare and let it resolve the id itself, through the memoised auth call, so the bare form costs nothing extra on its own. But in a render that used both shapes, React would see two distinct keys and run the venue queries twice for the same person.

Today that does not happen: the two bare calls are on pages that make no other venue call. Nothing enforces that, and the fix is boring, which is to pick one shape. I would keep the bare one, because a helper that can find the current user's venue without being told who the current user is has exactly one correct argument list.

The read in the layout that is not protected by any of this

The dashboard layout does one query of its own, inline:

const userRecord = await db
    .select({ seen_subscribe: usersTable.seen_subscribe })
    .from(usersTable)
    .where(eq(usersTable.id, user.id))
    .limit(1)
    .then(r => r[0] ?? null)
Enter fullscreen mode Exit fullscreen mode

One boolean, to decide whether to show a first-run banner. It is not inside withRetry, and it is not memoised, because it is called once.

So the shell around every dashboard page has a failure mode the page inside it does not. A 57014 on the venue lookup is retried twice and nobody notices. The same 57014 on this one line fails the layout, which means the error boundary renders instead of the page, for a banner. Of the reads on the signed-in render path, the one that matters least is the one with no protection at all.

That is the bug this post found. The fix is either to move the flag onto the already-retried venue read, or to make withRetry the default by putting it in the data layer rather than in one helper, which is where I think it belongs. A retry policy that lives in one file is a retry policy that covers the call sites somebody remembered.

None of this exists on the public site

Worth saying, because it bounds how much the above matters. 70 of the 72 URLs in our sitemap are prerendered and served from a CDN, and none of them open a database connection:

$ curl -sI https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -i x-nextjs-prerender
x-nextjs-prerender: 1
Enter fullscreen mode Exit fullscreen mode

Everything in this post is behind a login, on the path a host takes five minutes before the first round, on whatever connection the venue has. That is the audience for a 150ms retry: one person, in a hurry, who will not file a bug report, they will just tell their regulars the app is being slow again.

The free tier needs no card, and signing up puts you on exactly this path: one auth round trip, one or two venue queries, two SQLSTATEs forgiven.

Top comments (0)