Skip to content
gohacks.dev
APIs

Quotas and Usage Metering in Go APIs Done Correctly

Meter API usage and enforce quotas in Go: an atomic conditional UPDATE, idempotent events, weighted routes, refunds for 5xx, UTC periods and reconciliation.

18 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Rate limiting and quotas are often confused, and the difference decides the design. A rate limit protects your servers: it says how fast a client may call, in requests per second, and it can forget everything after a minute. A quota is a commercial promise: how much a customer may use in a billing period, a count that must be accurate, durable and explainable. Customers see it on an invoice. When a customer disputes a number, "the in-memory counter said so" is not an acceptable answer.

Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)

This article builds the quota half in Go, with a store that survives restarts, atomic enforcement under concurrency, idempotent charging, weighted routes, refunds for failures, and a log that can prove the counter is right. It sits beside rate limiting and uses the key ids from API keys. Everything ran on Go 1.25.6 against modernc.org/sqlite v1.50.0; the SQL is portable to PostgreSQL with small changes noted below.

Three tables

The design has a small schema with distinct jobs.

  • plans holds the quota for each key. In a real system this comes from the customer's subscription.
  • usage_period has one row per key per period and a used counter. This is the hot row: every billable request touches it.
  • usage_events is an append-only log with one row per charge: an event id, the key, the period, the route, the units, the time, and a refunded flag. It is the evidence.

A counter alone is fast and cannot be audited. A log alone is auditable and slow to total on every request. Keeping both and checking that they agree gets you speed and proof.

meter.goGo
package main

import (
	"database/sql"
	"errors"
	"fmt"
	"sync/atomic"
	"time"

	_ "modernc.org/sqlite"
)

var (
	ErrQuotaExceeded = errors.New("quota exceeded")
	ErrUnknownKey    = errors.New("unknown key")
)

// Meter counts billable units per key per calendar month (UTC) and enforces the plan's quota atomically.
type Meter struct {
	db  *sql.DB
	now func() time.Time
}

var dbs atomic.Int64

func Open(now func() time.Time) *Meter {
	db, err := sql.Open("sqlite", fmt.Sprintf("file:meter%d?mode=memory&cache=shared", dbs.Add(1)))
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	for _, q := range []string{
		`CREATE TABLE plans (key_id TEXT PRIMARY KEY, quota INTEGER NOT NULL)`,
		`CREATE TABLE usage_period (key_id TEXT NOT NULL, period TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (key_id, period))`,
		`CREATE TABLE usage_events (
			event_id TEXT PRIMARY KEY, key_id TEXT NOT NULL, period TEXT NOT NULL, route TEXT NOT NULL,
			units INTEGER NOT NULL, at INTEGER NOT NULL, refunded INTEGER NOT NULL DEFAULT 0)`,
	} {
		if _, err := db.Exec(q); err != nil {
			panic(err)
		}
	}
	return &Meter{db, now}
}

func (m *Meter) SetQuota(keyID string, quota int) {
	m.db.Exec(`INSERT INTO plans (key_id, quota) VALUES (?, ?) ON CONFLICT(key_id) DO UPDATE SET quota = excluded.quota`, keyID, quota)
}

// Period names the billing month a time belongs to. Everything is UTC so a customer's "month" is the same on every server.
func Period(t time.Time) string { return t.UTC().Format("2006-01") }

// Reset is the instant the current period ends.
func (m *Meter) Reset() time.Time {
	n := m.now().UTC()
	return time.Date(n.Year(), n.Month()+1, 1, 0, 0, 0, 0, time.UTC)
}

type Result struct {
	Used, Quota int
	Replayed    bool // the event id was seen before; nothing was charged again
}

// Consume charges units for one event. The event id makes it idempotent: a retried request is not billed twice.
// The check and the increment are one UPDATE, so concurrent requests cannot both squeeze under the limit.
func (m *Meter) Consume(keyID, eventID, route string, units int) (Result, error) {
	tx, err := m.db.Begin()
	if err != nil {
		return Result{}, err
	}
	defer tx.Rollback()

	var quota int
	if err := tx.QueryRow(`SELECT quota FROM plans WHERE key_id = ?`, keyID).Scan(&quota); errors.Is(err, sql.ErrNoRows) {
		return Result{}, ErrUnknownKey
	} else if err != nil {
		return Result{}, err
	}
	period := Period(m.now())
	res, err := tx.Exec(`INSERT OR IGNORE INTO usage_events (event_id, key_id, period, route, units, at) VALUES (?, ?, ?, ?, ?, ?)`,
		eventID, keyID, period, route, units, m.now().Unix())
	if err != nil {
		return Result{}, err
	}
	if n, _ := res.RowsAffected(); n == 0 { // seen before: report the current state and charge nothing
		var used int
		tx.QueryRow(`SELECT used FROM usage_period WHERE key_id = ? AND period = ?`, keyID, period).Scan(&used)
		return Result{used, quota, true}, nil
	}
	tx.Exec(`INSERT OR IGNORE INTO usage_period (key_id, period) VALUES (?, ?)`, keyID, period)
	res, err = tx.Exec(`UPDATE usage_period SET used = used + ? WHERE key_id = ? AND period = ? AND used + ? <= ?`, units, keyID, period, units, quota)
	if err != nil {
		return Result{}, err
	}
	var used int
	tx.QueryRow(`SELECT used FROM usage_period WHERE key_id = ? AND period = ?`, keyID, period).Scan(&used)
	if n, _ := res.RowsAffected(); n == 0 {
		return Result{used, quota, false}, ErrQuotaExceeded // the deferred Rollback also discards the event row
	}
	return Result{used, quota, false}, tx.Commit()
}

// Refund returns the units of an event, once, for example after a server error the customer should not pay for.
func (m *Meter) Refund(eventID string) error {
	tx, err := m.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var keyID, period string
	var units int
	if err := tx.QueryRow(`SELECT key_id, period, units FROM usage_events WHERE event_id = ? AND refunded = 0`, eventID).Scan(&keyID, &period, &units); err != nil {
		return err
	}
	tx.Exec(`UPDATE usage_events SET refunded = 1 WHERE event_id = ?`, eventID)
	tx.Exec(`UPDATE usage_period SET used = used - ? WHERE key_id = ? AND period = ?`, units, keyID, period)
	return tx.Commit()
}

type RouteUsage struct {
	Route string
	Units int
}

// Report lists what a key used this period by route, from the event log.
func (m *Meter) Report(keyID string) (used int, byRoute []RouteUsage) {
	m.db.QueryRow(`SELECT used FROM usage_period WHERE key_id = ? AND period = ?`, keyID, Period(m.now())).Scan(&used)
	rows, _ := m.db.Query(`SELECT route, SUM(units) FROM usage_events WHERE key_id = ? AND period = ? AND refunded = 0 GROUP BY route ORDER BY 2 DESC`, keyID, Period(m.now()))
	defer rows.Close()
	for rows.Next() {
		var r RouteUsage
		rows.Scan(&r.Route, &r.Units)
		byRoute = append(byRoute, r)
	}
	return
}

// Reconcile compares the fast counter with the sum of the event log; they must agree.
func (m *Meter) Reconcile(keyID string) (counter, events int) {
	p := Period(m.now())
	m.db.QueryRow(`SELECT used FROM usage_period WHERE key_id = ? AND period = ?`, keyID, p).Scan(&counter)
	m.db.QueryRow(`SELECT COALESCE(SUM(units), 0) FROM usage_events WHERE key_id = ? AND period = ? AND refunded = 0`, keyID, p).Scan(&events)
	return
}

Enforcing the quota without a race

The most common implementation of a quota is also the most common bug: read the current usage, compare it with the limit, then write the new total. Two requests that read 19 of 20 at the same time both pass the check and both write, and the customer is at 21. At scale this is not a rare event, it is whatever your concurrency happens to be.

The fix is to make the check and the write one atomic statement:

SQL
UPDATE usage_period SET used = used + ?
WHERE key_id = ? AND period = ? AND used + ? <= ?

If the condition fails, the update touches no rows and RowsAffected is 0, which is the "quota exceeded" signal. The database serialises updates to a row, so no interleaving of requests can overshoot. This form works in SQLite, PostgreSQL and MySQL with no special isolation level; it needs only that your driver reports rows affected. A test fires 100 goroutines at a quota of 20 with one unit each and requires exactly 20 successes and a counter of exactly 20, under the race detector.

Idempotent events

Clients retry. A request that times out on the client side may have been charged on the server, and the retry charges again. The remedy is the same idea as in idempotency keys: every charge carries an event id, and the id is a primary key.

Consume first does INSERT OR IGNORE INTO usage_events. If the insert affected a row, this is a new event and it proceeds to charge. If it affected none, the event was already recorded, and the function returns the current state with Replayed: true and charges nothing. In PostgreSQL the same is INSERT ... ON CONFLICT DO NOTHING. The client supplies the id through an Idempotency-Key header; without one, the server generates an id per request, which protects nothing against retries but keeps the log complete.

The whole operation runs in a transaction, so a refused charge rolls back the event row with it. That is the second test: after a request that was too big for the remaining quota, the same event id can be used again, and the counter and the log still agree.

Weighted routes

Not every call costs the same to serve. The middleware has a table of weights: a read is one unit, a write is two, an export is twenty-five. The weights are product decisions, written in one place and documented in the API reference; changing them changes what customers pay, so version them. The alternative is one unit per request, which is simple to explain and lets a customer run a hundred expensive exports for the price of a hundred reads.

middleware.goGo
package main

import (
	"net/http"
	"strconv"
	"sync/atomic"
	"time"
)

var seq atomic.Int64

var weights = map[string]int{"GET /v1/tasks": 1, "POST /v1/tasks": 2, "POST /v1/exports": 25}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }

// Metered charges before the handler runs, refuses with 429 when the quota is spent, and refunds server errors.
func Metered(m *Meter, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		keyID := r.Header.Get("X-Key-Id") // set by the authentication middleware in a real service
		route := r.Method + " " + r.URL.Path
		units := weights[route]
		if units == 0 {
			units = 1
		}
		// The client's own request id makes a retry the same event; otherwise each request is its own.
		// Scoped by key: two customers who both pick "retry-1" must not be treated as the same event.
		event := keyID + "/" + r.Header.Get("Idempotency-Key")
		if r.Header.Get("Idempotency-Key") == "" {
			event = keyID + "/auto-" + strconv.FormatInt(seq.Add(1), 36)
		}
		res, err := m.Consume(keyID, event, route, units)
		reset := m.Reset()
		w.Header().Set("X-Quota-Limit", strconv.Itoa(res.Quota))
		w.Header().Set("X-Quota-Remaining", strconv.Itoa(max(res.Quota-res.Used, 0)))
		w.Header().Set("X-Quota-Reset", reset.Format(time.RFC3339))
		switch err {
		case nil:
		case ErrQuotaExceeded:
			w.Header().Set("Retry-After", strconv.Itoa(int(reset.Sub(m.now()).Seconds())))
			w.Header().Set("Content-Type", "application/problem+json")
			w.WriteHeader(http.StatusTooManyRequests)
			w.Write([]byte(`{"type":"https://api.example.com/problems/quota-exceeded","title":"Monthly quota exceeded","status":429}` + "\n"))
			return
		default:
			http.Error(w, "unknown key", http.StatusUnauthorized)
			return
		}
		rec := &statusRecorder{ResponseWriter: w, status: 200}
		next.ServeHTTP(rec, r)
		if rec.status >= 500 {
			m.Refund(event) // a failure on our side is not billable
		}
	})
}

The middleware charges before running the handler, because charging afterwards lets a burst of requests run past the limit before the first one finishes. It sets X-Quota-Limit, X-Quota-Remaining and X-Quota-Reset on every response (these are not a standard; use whatever names your API reference documents). When the quota is spent it answers 429 with a problem document of type quota-exceeded and Retry-After set to the seconds until the period ends.

Which status code? 429 is used by many APIs for quotas as well as rates, and it is defensible, since the request is refused because of how much the client has sent. Some prefer 403 or 402 to separate "slow down" from "you have used your plan". Pick one, make the problem type distinct from a rate-limit 429, and document it, so clients can decide between backing off and upgrading.

After the handler returns, the middleware checks the status. A 5xx means the failure was on your side, and the customer should not pay for it, so it calls Refund with the event id. The refund marks the event refunded and subtracts its units, once, inside a transaction.

What happened

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	clock := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
	m := Open(func() time.Time { return clock })
	m.SetQuota("free-1", 60)
	m.SetQuota("pro-1", 100000)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/tasks", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
	mux.HandleFunc("POST /v1/tasks", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "created") })
	mux.HandleFunc("POST /v1/exports", func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Fail") != "" {
			http.Error(w, "exporter down", 500)
			return
		}
		fmt.Fprintln(w, "export started")
	})
	h := Metered(m, mux)

	call := func(key, method, path string, hdr ...string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(method, path, nil)
		req.Header.Set("X-Key-Id", key)
		for i := 0; i+1 < len(hdr); i += 2 {
			req.Header.Set(hdr[i], hdr[i+1])
		}
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		return rec
	}
	show := func(label string, rec *httptest.ResponseRecorder) {
		extra := ""
		if rec.Code == 429 {
			extra = " Retry-After=" + rec.Header().Get("Retry-After") + "s"
		}
		fmt.Printf("%-40s %d  remaining=%s%s\n", label, rec.Code, rec.Header().Get("X-Quota-Remaining"), extra)
	}

	fmt.Println("== a free key with a quota of 60 units")
	show("GET /v1/tasks (1 unit)", call("free-1", "GET", "/v1/tasks"))
	show("POST /v1/tasks (2 units)", call("free-1", "POST", "/v1/tasks"))
	show("POST /v1/exports (25 units)", call("free-1", "POST", "/v1/exports"))
	show("POST /v1/exports again (25 units)", call("free-1", "POST", "/v1/exports"))
	show("POST /v1/exports a third time", call("free-1", "POST", "/v1/exports"))
	show("GET /v1/tasks still fits", call("free-1", "GET", "/v1/tasks"))
	used, by := m.Report("free-1")
	fmt.Printf("report: used=%d by route=%v\n", used, by)

	fmt.Println("\n== a retry with the same Idempotency-Key is not billed twice")
	show("POST /v1/tasks, key r-1", call("free-1", "POST", "/v1/tasks", "Idempotency-Key", "r-1"))
	show("the same request retried", call("free-1", "POST", "/v1/tasks", "Idempotency-Key", "r-1"))

	fmt.Println("\n== server errors are refunded")
	m.SetQuota("free-2", 30)
	show("export fails with a 500", call("free-2", "POST", "/v1/exports", "X-Fail", "1"))
	c, e := m.Reconcile("free-2")
	fmt.Printf("counter=%d, events=%d (the 25 units were returned)\n", c, e)

	fmt.Println("\n== the period rolls over at the start of the month")
	clock = time.Date(2026, 7, 1, 0, 0, 1, 0, time.UTC)
	show("GET /v1/tasks on 1 July", call("free-1", "GET", "/v1/tasks"))

	fmt.Println("\n== the counter and the event log agree")
	c, e = m.Reconcile("free-1")
	fmt.Printf("free-1 counter=%d events=%d\n", c, e)
	show("unknown key", call("nobody", "GET", "/v1/tasks"))
}
Text
== a free key with a quota of 60 units
GET /v1/tasks (1 unit)                   200  remaining=59
POST /v1/tasks (2 units)                 200  remaining=57
POST /v1/exports (25 units)              200  remaining=32
POST /v1/exports again (25 units)        200  remaining=7
POST /v1/exports a third time            429  remaining=7 Retry-After=216000s
GET /v1/tasks still fits                 200  remaining=6
report: used=54 by route=[{POST /v1/exports 50} {POST /v1/tasks 2} {GET /v1/tasks 2}]

== a retry with the same Idempotency-Key is not billed twice
POST /v1/tasks, key r-1                  200  remaining=4
the same request retried                 200  remaining=4

== server errors are refunded
export fails with a 500                  500  remaining=5
counter=0, events=0 (the 25 units were returned)

== the period rolls over at the start of the month
GET /v1/tasks on 1 July                  200  remaining=59

== the counter and the event log agree
free-1 counter=1 events=1
unknown key                              401  remaining=0

Quota and weights. A free key with 60 units made a read (59 left), a write (57 left), an export of 25 (32 left) and a second export (7 left). The third export would cost 25 against 7 remaining, so it was refused with a 429 and Retry-After=216000s, which is two and a half days: it was 28 June, and the period ends on 1 July. A one-unit read still fit, leaving 6. The report groups the log by route: 50 units of exports, 2 of writes, 2 of reads, 54 in all, the same number the counter holds.

Idempotent retry. A write with Idempotency-Key: r-1 charged two units and left 4. The same request retried charged nothing and still showed 4 remaining. Note what this does and does not do: it prevents double billing, not double execution. The handler ran again. To make the effect idempotent too, combine it with the response replay in the idempotency article.

Refund. An export that failed with a 500 was refunded: the counter and the log both read 0 afterwards, and the Reconcile helper confirms it.

Period rollover. After the clock passed midnight on 1 July, the same key was at 59 remaining. Periods are named 2026-06 and 2026-07, computed in UTC, so the boundary is the same on every server and in every timezone. A customer's own timezone is a display concern. Whatever you choose, state it in the documentation, because the first support ticket will be about the 31st.

Tests and cost

meter_test.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func newMeter() (*Meter, *time.Time) {
	clock := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
	m := Open(func() time.Time { return clock })
	m.SetQuota("k", 20)
	return m, &clock
}

func TestConcurrentRequestsCannotExceedTheQuota(t *testing.T) {
	m, _ := newMeter()
	var ok atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if _, err := m.Consume("k", fmt.Sprint("e", i), "GET /x", 1); err == nil {
				ok.Add(1)
			}
		}()
	}
	wg.Wait()
	if ok.Load() != 20 {
		t.Fatalf("%d requests were billed, the quota is 20", ok.Load())
	}
	if c, e := m.Reconcile("k"); c != 20 || e != 20 {
		t.Fatalf("counter=%d events=%d", c, e)
	}
}

func TestRefusedRequestsLeaveNoTrace(t *testing.T) {
	m, _ := newMeter()
	m.Consume("k", "big", "POST /big", 18)
	if _, err := m.Consume("k", "too-much", "POST /big", 5); err != ErrQuotaExceeded {
		t.Fatalf("got %v", err)
	}
	if _, err := m.Consume("k", "too-much", "POST /big", 2); err != nil {
		t.Fatalf("the refused event id should be reusable, got %v", err)
	}
	if c, e := m.Reconcile("k"); c != 20 || e != 20 {
		t.Fatalf("counter=%d events=%d", c, e)
	}
}

func TestRetriesWithTheSameEventAreFree(t *testing.T) {
	m, _ := newMeter()
	for i := 0; i < 5; i++ {
		m.Consume("k", "same", "POST /x", 3)
	}
	if c, _ := m.Reconcile("k"); c != 3 {
		t.Fatalf("charged %d for one event", c)
	}
}

func TestRefundHappensOnce(t *testing.T) {
	m, _ := newMeter()
	m.Consume("k", "e1", "POST /x", 7)
	m.Refund("e1")
	m.Refund("e1")
	if c, e := m.Reconcile("k"); c != 0 || e != 0 {
		t.Fatalf("counter=%d events=%d", c, e)
	}
}

func TestANewMonthStartsAtZero(t *testing.T) {
	m, clock := newMeter()
	m.Consume("k", "a", "GET /x", 20)
	*clock = time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
	if _, err := m.Consume("k", "b", "GET /x", 20); err != nil {
		t.Fatalf("a new month should reset: %v", err)
	}
}
Text
=== RUN   TestConcurrentRequestsCannotExceedTheQuota
--- PASS: TestConcurrentRequestsCannotExceedTheQuota (0.12s)
=== RUN   TestRefusedRequestsLeaveNoTrace
--- PASS: TestRefusedRequestsLeaveNoTrace (0.01s)
=== RUN   TestRetriesWithTheSameEventAreFree
--- PASS: TestRetriesWithTheSameEventAreFree (0.01s)
=== RUN   TestRefundHappensOnce
--- PASS: TestRefundHappensOnce (0.01s)
=== RUN   TestANewMonthStartsAtZero
--- PASS: TestANewMonthStartsAtZero (0.01s)
PASS
ok  	example.test/lab	1.517s

The five tests encode the guarantees: no overshoot under concurrency, with the counter and log agreeing; refused requests leave no event, so their ids are reusable; retries with the same event id are free; a refund happens once; and a new month starts at zero.

bench_test.goGo
package main

import (
	"fmt"
	"testing"
)

func BenchmarkConsume(b *testing.B) {
	m, _ := newMeter()
	m.SetQuota("k", 1<<40)
	b.ReportAllocs()
	i := 0
	for b.Loop() {
		m.Consume("k", fmt.Sprint("e", i), "GET /x", 1)
		i++
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkConsume-10    	   36441	     31990 ns/op	    2875 B/op	      90 allocs/op
PASS
ok  	example.test/lab	1.466s

A full Consume took about 32 µs and 90 allocations on an Apple M1 Max against an in-memory SQLite database, which is three statements in a transaction. Against a networked PostgreSQL the round trips dominate, typically a millisecond or more, and a counter row hit by every request of one busy key becomes a hotspot.

When the counter becomes the bottleneck

The design above puts a database write on every billable request. That is correct and simple, and it is fine for most APIs. If a single key does thousands of requests per second, there are three standard moves, in increasing order of risk.

Reserve in blocks. Each server instance claims a block of units, say 100, from the database in one update, spends them from memory, and claims another when they run out. Writes drop by the block size. The cost is that unspent blocks look used until returned, so quotas have slack of up to one block per instance, and a crash loses the unspent part.

Count in Redis, settle in the database. Increment an atomic counter in Redis for the fast decision and write events to the database asynchronously. Fast, and you now have two systems that can disagree; the reconciliation job becomes essential.

Accept approximate enforcement. For free-tier abuse prevention, being off by a few percent is acceptable and the event log still produces the exact bill after the fact. For hard commercial caps, do not.

Whichever you choose, keep the event log as the source of truth and run a reconciliation that compares totals per key and period with the counter, alerting on any difference. The helper in the lab shows the query.

Showing usage to customers

A quota that customers cannot see becomes a support ticket. Expose it three ways. The response headers show the remaining units on every call. A GET /v1/usage endpoint returns the counter, the quota, the reset time and the breakdown by route, which the lab's Report function computes from the log. And alerts at 80 and 100 percent go out by email or webhook (webhook design), once per period each, which needs a column or a table recording that the alert was sent.

Build the usage endpoint from the same tables that enforce the quota. A dashboard computed from a separate pipeline will, sooner or later, show a number that differs from the one that blocked a request, and the customer will believe the one that blocked them.

Failure modes to design for

Event id collisions. The event id is a primary key, so two customers who both send Idempotency-Key: retry-1 must not collide. The middleware prefixes the key id, so the stored id is free-1/retry-1. Without the prefix, the second customer's first request is treated as a replay of the first customer's and is never billed, which is a revenue bug and a data leak in one.

Clock changes. Periods come from the server clock. Time that jumps backwards after a bad NTP sync can place events in the previous month. Read time from one source, store timestamps from the same clock, and alert on events whose time is earlier than the latest one for the key.

Partial failure. Charging before the handler means a crash between the charge and the response leaves the customer billed for work that may not have happened. The refund path handles failures the handler reports; for crashes, a periodic job can refund events with no matching completed request in your logs. Alternatively charge after success and accept a small overshoot, a trade-off to choose deliberately.

Backfill and disputes. When an invoice is disputed, you need to answer "which calls made up this number?". The event log answers it, with route and time per event. Partition it by period so old months can be archived without touching the hot table.

Plan changes mid-period. If a customer upgrades on the 15th, the quota in plans changes and the counter keeps its value, so the new limit applies at once. Downgrades below current usage should not refund or erase usage; they block further charges.

Soft versus hard limits. A hard limit refuses at the quota; a soft limit lets usage continue and bills overage. Soft limits are friendlier and need alerts at 80 and 100 percent, which are another query on the counter.

What counts. Count what you can explain: successful calls, a measure of work (rows returned, bytes processed), or compute time. Do not bill for 4xx errors the client could not avoid, and do bill consistently for the ones they cause.

Time. Never calculate the period boundary with local time. Store UTC and format at the edge.

Money is not a float. If units convert to currency, store integers (cents or micro-units) and round once, at invoice time.

Privacy. The event log records who called what and when. Keep it as long as billing disputes require and no longer, and keep request bodies out of it (redacting logs).

The database/sql package that carries all of this is documented at pkg.go.dev/database/sql, and the conditional-update pattern is general SQL that the SQLite documentation and every other database describe.

Also inDatabases
Taggedsecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.