Two successful receipts do not yet tell us why the limit failed.
At 09:03, two orders apply the “FIRST-ORDER” coupon. Its capacity is one redemption. The order service says each request checked availability and succeeded. That points toward a race, but we still need to rule out duplicate accounting, a delayed dashboard, or a retry that created two receipts for one order.
Compare order IDs, request IDs, coupon redemption rows, and the capacity counter. Confirm whether the two orders are distinct and whether the database rows committed. Use a disposable database to reproduce; do not test concurrency by submitting live orders.
- Asset
- Promotion budget and the customer’s order price.
- Concurrent actors
- Two independent checkout requests, possibly served by different workers.
- Shared state
- Coupon capacity and the durable redemption records.
- Invariant
- Committed redemptions never exceed configured capacity.
Both requests may observe one slot.
Each decision can be locally correct.
The shared state must arbitrate.
The durable result must preserve the limit.
A race condition occurs when a result depends on the timing or ordering of concurrent operations. The common shape here is check then act: read capacity, decide there is room, then record the redemption. Each request can pass its check before either write becomes visible.
Establish two distinct committed orders before calling it a race.
Possible explanations include two requests reading the same old count, a retry creating a second redemption, a unique-order constraint missing from storage, or a reporting job counting an uncommitted or duplicate event. These causes need different repairs. Idempotency prevents repeating the same logical operation; an atomic capacity check prevents distinct requests from consuming more than the shared limit.
Hold the coupon and capacity constant. Compare the two order IDs and idempotency keys, then inspect the committed redemption rows and timestamps. In a disposable fixture, add a barrier after both reads so each request is known to have observed the last slot before either write proceeds.
Compare your diagnosis with the case fileReveal after choosing a discriminating check
- Observation
- Capacity is one, and two committed redemptions reference distinct orders.
- Competing causes
- Two check-then-act requests overlapped; one order was duplicated by retry; or reporting duplicated a row.
- Discriminating check
- Verify unique order IDs and persisted rows. In a fixture, coordinate two requests so both complete the availability read before either records a redemption; then count committed redemptions.
- Conclusion if confirmed
- If each request observed zero prior redemptions and both later committed distinct rows, the check and capacity change were not atomic. This confirms the limit bug for that operation, not every coupon or checkout path.
The gap between the read and write is the race window.
The intentionally flawed examples read a coupon, compare redeemed with capacity, and only then insert a redemption. Request A and request B can both
read 0 / 1, both decide to proceed, and both write. The comparison is not
wrong for either snapshot; the operation is incomplete because it does not reserve the
slot.
The database calls are represented by a store boundary in the flawed path.
// Intentionally flawed: the read, decision, and write are separate operations.
export async function redeemVulnerable(
store: CouponStore,
code: string,
orderID: string
): Promise<RedemptionResult> {
const coupon = await store.read(code);
if (!coupon || coupon.redeemed >= coupon.capacity) return 'sold-out';
await store.insertRedemption(code, orderID);
return 'redeemed';
} // Intentionally flawed: a concurrent request can pass the same read check.
type Store interface {
ReadCoupon(ctx context.Context, code string) (Coupon, bool, error)
InsertRedemption(ctx context.Context, code, orderID string) error
}
func RedeemVulnerable(ctx context.Context, store Store, code, orderID string) error {
coupon, found, err := store.ReadCoupon(ctx, code)
if err != nil {
return err
}
if !found || coupon.Redeemed >= coupon.Capacity {
return ErrSoldOut
}
return store.InsertRedemption(ctx, code, orderID)
} Capacity = 1, so A proceeds.
B sees the same available slot.
Both acted on a stale decision.
The invariant is broken.
Let the shared store decide and reserve in one operation.
Move the capacity predicate into the state change. A conditional update increments the counter only while it is below capacity. The application checks that exactly one row changed; if none did, the coupon is exhausted. Insert the redemption record in the same transaction so a failed insert rolls back the counter update.
The transaction must run against the same database connection and isolation semantics as the update. A process-local mutex cannot coordinate other servers. Dialects vary in placeholder syntax and locking behavior, so confirm the chosen database’s documented behavior and retry serialization or deadlock failures only under a bounded policy.
Go shows the transaction body; TypeScript calls a repository operation whose contract is the same atomic transaction.
export async function redeemSafely(
store: CouponStore,
code: string,
orderID: string
): Promise<RedemptionResult> {
return store.redeemAtomically(code, orderID);
} // The conditional update reserves one slot; the insert and update commit together.
func RedeemSafely(ctx context.Context, db *sql.DB, code, orderID string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx,
`UPDATE coupons
SET redeemed_count = redeemed_count + 1
WHERE code = ? AND redeemed_count < capacity`,
code,
)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return ErrSoldOut
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO coupon_redemptions (code, order_id) VALUES (?, ?)`, code, orderID,
); err != nil {
return fmt.Errorf("record redemption: %w", err)
}
return tx.Commit()
} BEGIN;
UPDATE coupons
SET redeemed_count = redeemed_count + 1
WHERE code = ? AND redeemed_count < capacity;
-- Continue only when exactly one row changed.
INSERT INTO coupon_redemptions (code, order_id) VALUES (?, ?);
COMMIT;
-- If the update changed zero rows or the insert fails, roll back. Review the complete isolated examplesTransaction code and store contract
export type Coupon = { code: string; capacity: number; redeemed: number };
export type RedemptionResult = 'redeemed' | 'sold-out';
export interface CouponStore {
read(code: string): Promise<Coupon | null>;
insertRedemption(code: string, orderID: string): Promise<void>;
/** Atomically reserves capacity and records the redemption in one transaction. */
redeemAtomically(code: string, orderID: string): Promise<RedemptionResult>;
}
// Intentionally flawed: the read, decision, and write are separate operations.
export async function redeemVulnerable(
store: CouponStore,
code: string,
orderID: string
): Promise<RedemptionResult> {
const coupon = await store.read(code);
if (!coupon || coupon.redeemed >= coupon.capacity) return 'sold-out';
await store.insertRedemption(code, orderID);
return 'redeemed';
}
export async function redeemSafely(
store: CouponStore,
code: string,
orderID: string
): Promise<RedemptionResult> {
return store.redeemAtomically(code, orderID);
}
package redemption
import (
"context"
"database/sql"
"errors"
"fmt"
)
var ErrSoldOut = errors.New("coupon sold out")
type Coupon struct {
Code string
Capacity int64
Redeemed int64
}
// Intentionally flawed: a concurrent request can pass the same read check.
type Store interface {
ReadCoupon(ctx context.Context, code string) (Coupon, bool, error)
InsertRedemption(ctx context.Context, code, orderID string) error
}
func RedeemVulnerable(ctx context.Context, store Store, code, orderID string) error {
coupon, found, err := store.ReadCoupon(ctx, code)
if err != nil {
return err
}
if !found || coupon.Redeemed >= coupon.Capacity {
return ErrSoldOut
}
return store.InsertRedemption(ctx, code, orderID)
}
// The conditional update reserves one slot; the insert and update commit together.
func RedeemSafely(ctx context.Context, db *sql.DB, code, orderID string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx,
`UPDATE coupons
SET redeemed_count = redeemed_count + 1
WHERE code = ? AND redeemed_count < capacity`,
code,
)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return ErrSoldOut
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO coupon_redemptions (code, order_id) VALUES (?, ?)`, code, orderID,
); err != nil {
return fmt.Errorf("record redemption: %w", err)
}
return tx.Commit()
}
Send competing requests and count committed state.
A sequential test can pass while the race remains. Coordinate several requests to contend for a capacity of one, then inspect the committed counter and redemption rows. Assert exactly one success, no more than one redemption, and no order with a discount but no redemption record. Repeat with capacity greater than one and with a duplicate retry of the same order.
Test the database you deploy. An in-memory mock can confirm that the handler calls a method, but it cannot prove transaction isolation or conditional-update behavior. Check how the driver reports affected rows, how conflicts surface, and whether commit failures are returned to the caller.
At most one transaction reserves the slot.
Success count never exceeds N.
One logical order is recorded once.
Counter and redemption rows stay consistent.
Choose a control that protects shared state across workers.
Two checkout workers can redeem the last slot. What should change?
Both requests can pass the current read check. The service runs three replicas, and customers may retry checkout when a response is delayed. Which control protects the coupon limit across replicas?
The example establishes a limit overrun when concurrent requests read the same available capacity and both commit a redemption. The resulting impact depends on the product: a coupon may reduce revenue, a quota may grant excess usage, inventory may oversell, and a balance bug may move value. Do not infer one of those consequences from another; trace the protected state and the operation that changes it.
OWASP’s Business Logic Security Cheat Sheet describes check-then-act races in sensitive operations. The Go documentation explains transaction boundaries and commit/rollback. Checked 2026-10-01; database isolation and SQL details remain engine-specific.
Connections to follow nextRelated lessons
Idempotency and replay resistance helps make retries of one logical action safe. Time-of-check/time-of-use authorization applies the same timing question to permission decisions.