Nothing in this document matches .
Checkout carries the risk
The booking service is a Go service over Postgres that the rider app and the harbour office both call. Four people build it, with agents writing most of the code.
These ideas come from reading all of it: 212 files, about 31,000 lines of Go and SQL, and the last 90 days of traces and incidents. Most of the pain sits on one path, from search to a paid booking, and most incidents began with a change to that path.
Effort is agent time, not calendar time.
Mermaid source
flowchart LR
app[Rider app] --> api[Gateway]
api --> search[Search]
api --> checkout[Checkout]:::accent
search --> fares[Fares]:::violet
checkout --> fares
checkout --> holds[Seat holds]:::risk
checkout --> pay[Payments]:::risk
fares --> db[(Postgres)]
holds -.-> mem[(In-process map)]:::risk
pay -->|retries, no key| card[Card provider]Where checkout slows down
At the Friday evening peak, a booking takes 3.2 seconds from tapping Book to the confirmation, and one attempt in forty times out. Payments and fares account for most of the wait.
func (q *Quoter) Quote(ctx context.Context, legs []Leg) (Quote, error) {
var total Money
for _, leg := range legs {
fare, err := q.store.FareFor(ctx, leg.RouteID, leg.Class)
if err != nil {
return Quote{}, err
}
total = total.Add(fare.Price(leg.Party))
}
return Quote{Total: total}, nil
}When the card provider is slow, checkout retries the charge without an idempotency key. Twice in 90 days a rider paid twice for one booking. Idea 2 closes this whichever way the cycle goes.
Ideas
At a glance10 ideas
| Pick | # | Idea | Size | Effort | Impact | Depends on |
|---|---|---|---|---|---|---|
| 1 | Quote every leg in one query | minor | S | nothing | ||
| 2 | An idempotency key on every charge | minor | S | nothing | ||
| 3 | Typed errors from the payments client | minor | S | nothing | ||
| 4 | One request id in every log line | minor | S | nothing | ||
| 5 | Contract tests for the fares API | minor | M | nothing | ||
| 6 | Free seat holds when checkout gives up | minor | S | nothing | ||
| 7 | Cache quotes for thirty seconds | major | M | 1 | ||
| 8 | Seat holds in a lease table | major | L | nothing | ||
| 9 | Refunds on a queue with retries | major | L | 2, 3 | ||
| 10 | Ship checkout changes behind flags | major | M | nothing |
Effort is agent time, not calendar time: S is under an hour, M is a few hours with review, L is a day or more across sessions.
1Quote every leg in one query
Fares for a whole trip load in one query instead of one per leg, which removes most of the wait in the fares step.
Quote every leg in one query
Fares for a whole trip load in one query instead of one per leg, which removes most of the wait in the fares step.
- How it works
FareForbecomesFaresForinfares/store.go, andQuotereads from the map it returns:SELECT route_id, class, price FROM fares WHERE (route_id, class) IN (SELECT * FROM unnest($1::text[], $2::text[]))- Why
Fares is the second slowest step, and almost all of it is round trips. A return trip for a family of four makes eight queries today.
- In use
The trace for a return booking shows one fares query, and the step drops to about 90 ms.
- Unlocks
Idea 7 caches the same map.
2An idempotency key on every charge
Each checkout sends one key with its charge and every retry, so a slow card provider can never charge a rider twice.
An idempotency key on every charge
Each checkout sends one key with its charge and every retry, so a slow card provider can never charge a rider twice.
- How it works
Checkout makes one key for each payment, stores it on the booking row, and
payments/charge.gosends it asIdempotency-Keyon the first call and on every retry.- Why
Two riders were charged twice in 90 days, both after a provider timeout, and each refund took the harbour office 25 minutes.
- Unlocks
Idea 9 reuses the key to refund exactly once.
- Risk
A key kept only in memory is lost on a restart mid-checkout, which is why it lives on the booking row.
3Typed errors from the payments client
The payments client returns declined, retryable, and fatal errors as values the caller can test, not messages it has to match.
Typed errors from the payments client
The payments client returns declined, retryable, and fatal errors as values the caller can test, not messages it has to match.
- How it works
payments/client.gowraps provider codes inErrDeclined,ErrRetryable, andErrFatal. Checkout switches onerrors.Isinstead ofstrings.Contains(err.Error(), "timeout").- Why
Three incidents started when the provider reworded an error message. With types, the compiler and the tests catch that instead of riders.
- Unlocks
Ideas 2 and 9 retry only
ErrRetryable.
4One request id in every log line
A request id from the gateway reaches every log line and outgoing call, so one checkout reads as one story.
One request id in every log line
A request id from the gateway reaches every log line and outgoing call, so one checkout reads as one story.
- How it works
Middleware in
api/middleware.goputs the id in the context,internal/logadds it to every line, and the fares and payments clients forward it asX-Request-Id.- Why
Following a failed checkout today means matching timestamps across four logs. It took two hours in the last incident.
- In use
Searching the logs for one id shows the whole booking, from search to the card provider’s answer.
5Contract tests for the fares API
Recorded app requests pin what the rider app expects from the fares endpoint, so CI fails before a change breaks the app.
Contract tests for the fares API
Recorded app requests pin what the rider app expects from the fares endpoint, so CI fails before a change breaks the app.
- How it works
fares/contract_test.goreplays the app’s recorded requests fromtestdata/app-fares/against the handler and compares every response field by name and type.- Why
Two releases broke the app by renaming a fares field, and both passed every test the service had.
- Note
The app team records new requests with one command when the app changes.
6Free seat holds when checkout gives up
A failed or abandoned checkout frees its held spaces at once instead of waiting fifteen minutes for them to expire.
Free seat holds when checkout gives up
A failed or abandoned checkout frees its held spaces at once instead of waiting fifteen minutes for them to expire.
- How it works
Checkout’s error path and the app’s back button call
holds.Release. Expiry stays as the backstop for a rider who closes the app.- Why
At the peak, abandoned holds keep up to 30 car spaces off sale, and riders see a sailing as full when it is not.
7Cache quotes for thirty seconds
Search, the trip screen, and checkout share one short-lived quote, so a rider's fare is worked out once per trip.
Cache quotes for thirty seconds
Search, the trip screen, and checkout share one short-lived quote, so a rider's fare is worked out once per trip.
- How it works
fares/cache.gokeys quotes by route, date, class, and party, keeps them for 30 seconds, and drops them all when fares change by bumping a version.- Why
A rider sees the same quote on three screens, and each screen asks for it again. At the peak that is a third of all fares queries.
- Risk
A stale price must never reach payment. Checkout checks the fares version again before it charges and requotes if it moved.
- Depends on
8Seat holds in a lease table
Holds move from a map inside one process to a Postgres table, so deploys keep them and two instances can take bookings.
Seat holds in a lease table
Holds move from a map inside one process to a Postgres table, so deploys keep them and two instances can take bookings.
- How it works
A
hold_leasestable with a unique index on sailing and space. Holds are taken withINSERT ... ON CONFLICT DO NOTHINGand swept when they expire, andholds/holds.gokeeps its interface.- Why
Holds live in memory today, so the service cannot run a second instance, and every deploy drops the holds of riders mid-checkout.
- In use
A deploy at the Friday peak goes unnoticed, and the gateway spreads checkouts across two instances.
- Unlocks
A second instance at the Friday peak, which halves the queue in front of every checkout step.
- Risk
A mistake here double books car spaces. Run both stores side by side for a week and compare every hold before switching.
9Refunds on a queue with retries
Refunds leave the request path for a worker that retries with backoff and records every attempt.
Refunds on a queue with retries
Refunds leave the request path for a worker that retries with backoff and records every attempt.
- How it works
Cancelling a booking writes a
refund_jobsrow. A worker inrefunds/worker.gosends it with the charge’s key and retriesErrRetryablewith backoff.- Why
A slow refund holds the cancel screen for up to 20 seconds today, and a failed one is lost unless the rider calls.
- Risk
A stuck queue delays refunds without a sound. Alert when the oldest job is more than ten minutes old.
10Ship checkout changes behind flags
A checkout change can go out switched off, reach a share of riders, and be turned off again without a rollback.
Ship checkout changes behind flags
A checkout change can go out switched off, reach a share of riders, and be turned off again without a rollback.
- How it works
A small flag store in
internal/flags, read from Postgres every 30 seconds and checked incheckout/handler.go, plus aflagscommand for the team.- Why
Seven of the last eleven incidents began with a checkout change, and each one took a full rollback to undo.
- In use
A new payment screen reaches 5% of riders on Monday, half on Wednesday, and everyone once its error rate holds.
- Risk
Old flags pile up. Each flag gets an owner and a removal date when it is added.
Also considered
The time goes to round trips and the card provider, not to Go, so a rewrite would move none of it.
Four people run this, and more services would add the very network hops that slow checkout.
The app's calls are few and cheap, and idea 7 removes the repeated ones.
Your reply
Your choices, ready to send. Copy this reply back to your agent when you’re done.
Example speed, 2, 5, 7. Notes: 5: smaller first.
Choice: A faster checkout. Pick: ideas 2, 5, and 7. Note on idea 5: smaller first.
nothing.
Nothing decided yet.