DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A malformed uuid is a 500, not an empty result, which is why one five line function has 19 call sites

A player at a PubTrivia quiz night joins by scanning the code on their table, and that code is a URL with two uuids in it: the venue and the table. The route is app/play/[venueId]/[tableId], which means both ids are strings that arrived from outside, and one of them is a segment somebody can type by hand.

So what happens when they type nonsense? The answer most people assume, and the one I assumed for longer than I would like, is that the query finds nothing and the page renders an empty state.

That is not what happens.

Postgres does not miss, it objects

Compare a uuid column to a string that is not a uuid and Postgres raises an error:

ERROR:  invalid input syntax for type uuid: "banana"
SQLSTATE: 22P02
Enter fullscreen mode Exit fullscreen mode

It is not a zero row result. It is an exception, thrown while parsing the parameter, before any row is ever considered. Which means this extremely normal looking code is a 500:

const [row] = await db.select().from(tablesTable).where(eq(tablesTable.id, tableId)).limit(1)
if (!row) return null
Enter fullscreen mode Exit fullscreen mode

The if (!row) reads like it covers everything. It covers the case where the id is well formed and unknown. The case where the id is not an id at all never reaches it.

In practice that means a typo in a URL produces an error screen instead of a "this table does not exist" message, every crawler that mangles a link generates an exception, and your error monitor fills up with other people's typing. The shape of the bug is that a validation failure has been promoted into a server failure.

Five lines, one comment

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

/**
 * Ids arrive from browsers. Postgres rejects a malformed uuid with an
 * exception rather than "no rows", so check the shape before querying.
 */
export function isUuid(value: unknown): value is string {
    return typeof value === 'string' && UUID_RE.test(value)
}
Enter fullscreen mode Exit fullscreen mode

Two details in a five line function worth more than they look.

It takes unknown, not string. Half the places it is used are handling a value whose type the compiler cannot vouch for: a URL segment, a JSON body on an internal route, a field off an object that was Record<string, unknown> three lines ago. Typing the parameter as string would make every one of those call sites cast first, and a cast is the opposite of a check.

And it is a type predicate, value is string, so passing the check also narrows the type. One call does the runtime job and the compile time job, and there is no branch in which the compiler believes something the code has not verified.

The regex is not RFC strict. It does not validate the version or variant nibbles, because the goal is not "is this a correctly versioned uuid", it is "will Postgres accept this without raising". A loose pattern that exactly matches what the database tolerates is the right pattern here.

19 call sites, 14 lines, 5 files

That one function is invoked 19 times, and the repetition is not an accident of style. Three of the layers it appears in are reached by different callers.

The host page checks the id in the query string before it touches the database:

const requested =
    venue && params.session && isUuid(params.session)
        ? await getVenueSession(params.session, venue.id)
        : null
Enter fullscreen mode Exit fullscreen mode

The host's server actions check their arguments at the top of the body:

if (!isUuid(sessionId)) return GameError.INVALID_INPUT
Enter fullscreen mode Exit fullscreen mode

And the game engine, which those actions call, checks again:

export async function launchQuestion(sessionId: string, questionIndex: number) {
    if (!isUuid(sessionId) || !Number.isInteger(questionIndex) || questionIndex < 0) {
        return { success: false, error: GameError.INVALID_INPUT }
    }
Enter fullscreen mode Exit fullscreen mode

That third one looks redundant and is not, because the engine has two callers. One is the host pressing a button. The other is our WebSocket server, which holds the countdown and calls an internal HTTP route when a question's time is up. On that path, no page and no server action ran, so the engine's own guard is not a second line of defence, it is the only one.

The same line also checks questionIndex, with Number.isInteger and a lower bound, because 3.5 and -1 are exactly as much "a value that arrived from outside" as the uuid next to them.

The same failure, two different error codes

Here is the part I did not expect to find interesting. A bad uuid does not always mean the same thing to the person who will read the result.

In a host action, it is invalid_input. The host is using our UI, so a malformed id means our own code sent something wrong, and a label that says so is what we want in a log.

In the player's join, it is not:

export async function joinSession(sessionId: string, tableId: string, playerName: string) {
    if (!isUuid(sessionId) || !isUuid(tableId)) {
        return { success: false, error: GameError.SESSION_NOT_FOUND }
    }
Enter fullscreen mode Exit fullscreen mode

A player with a malformed id scanned something that is not a current quiz, or followed a link that has been edited. "Invalid input" is a true statement about the bytes and a useless statement to a person holding a phone in a pub. session_not_found is what they need to read, because the next thing they should do is ask the host, not inspect a URL.

Those codes come from a single object of 19 literals:

export const GameError = {
    INVALID_INPUT: 'invalid_input',
    SESSION_NOT_FOUND: 'session_not_found',
    LATE_JOIN_DISABLED: 'late_join_disabled',
    TABLE_ALREADY_JOINED: 'table_already_joined',
    NAME_TAKEN: 'name_taken',
    RATE_LIMITED: 'rate_limited',
    QUESTION_CLOSED: 'question_closed',
    // ...
} as const

export type ErrorCode = typeof GameError[keyof typeof GameError]
Enter fullscreen mode Exit fullscreen mode

Every one of our game actions returns a result object with one of those in it and never throws. The practical payoff is that switch (result.error) in a component is exhaustive, and TypeScript tells you when you have handled 18 of 19.

The transition check is the same idea, one level up

A shape check says "this could be an id". It says nothing about whether the thing it names is in a state where your request makes sense. That is a second function, and it is a state machine written as a boolean:

const isValid =
    (currentStatus === 'setup' && targetStatus === 'active') ||
    (currentStatus === 'active' && targetStatus === 'completed')
Enter fullscreen mode Exit fullscreen mode

Three statuses, nine possible pairs, two legal ones. Everything else, including every self transition, comes back invalid_transition. A host whose phone retried a request, a WebSocket server closing a question the host already closed, a double tap on Start: all of them land here, get a code, and change nothing.

The copy I should not have made

Honesty requires a last section. The same regex exists twice in this codebase:

// utils/game/validation.ts
export function isUuid(value: unknown): value is string

// app/dashboard/packs/actions.ts
function isValidUuid(v: string): boolean
Enter fullscreen mode Exit fullscreen mode

Identical pattern, different name, and the second one takes string, so it cannot narrow an unknown and gives you nothing at compile time. There is no reason for it other than that somebody, a few weeks apart, needed the same check in a file that did not already import the first one. One of the two should be an import of the other, and it should be the one that takes unknown. Writing this paragraph is what made me notice how much worse the second signature is than the first.

Why a quiz product is full of uuids in URLs

Because the alternative is a join code, and a join code has to be short to be typed, and short codes collide, expire and get guessed. Ours are printed on the table instead, as QR codes generated from the ids, and nobody types anything. We ship the same QR generator free on the marketing site with no backend at all, which makes the same point from the other side: a URL with an id in it is the whole join flow.

The price of putting ids in URLs is that every one of them is user input. Five lines, nineteen calls, and a 500 that never happens.

Top comments (0)