Monthly Quota Reset and Billing Boundaries
A rate limit resets continuously; a quota resets on a boundary that finance cares about. Getting that boundary wrong produces support tickets that are also invoicing disputes, which is a worse category of bug — and it is the part of tiered access and quota enforcement where the correct answer comes from the billing system rather than from the limiter.
The problem in concrete numbers
A customer subscribes on 31 January with 100,000 calls included. Their quota should reset on the anniversary of that date — but February has no 31st. Reset on 28 February and they lose three days of their period; reset on 3 March and they get three extra. Multiply across thousands of customers and the aggregate error is real revenue.
Now add time zones. A customer in Auckland sees their month roll over at 13:00 on the last day, by your servers’ clock in UTC. If your dashboard shows “resets in 4 hours” and their finance team sees an invoice dated a day earlier, both are right and neither is explicable without knowing which clock each used.
Decision matrix
| Question | Options | Recommendation |
|---|---|---|
| Period type | Anniversary / calendar | Anniversary if you invoice per subscription, calendar if you invoice everyone monthly |
| Source of the boundary | Billing system / limiter computes it | Always the billing system: one truth, no drift |
| Time zone | UTC / customer’s / account’s billing zone | The account’s billing zone, displayed with the zone named |
| Short-month handling | Clamp / skip / roll forward | Clamp to the last valid day |
| Counter reset | Delete key / new key per period | New key per period identifier |
| Mid-cycle upgrade | New allowance now / at next period | New allowance now, prorated, with the old usage carried |
| Downgrade | Immediate / at period end | At period end, to avoid an instant over-quota state |
| Overage | Hard stop / soft allow with billing | Explicit product decision, surfaced in the response |
The single most important row is the second. If the limiter computes “one month from the subscription date” independently, it will eventually disagree with the invoice — over a leap day, a plan change, a daylight-saving transition — and the customer will trust the invoice.
Step-by-step implementation
# quota.py — the period identifier comes from billing; the limiter only counts.
from datetime import datetime, timezone
from calendar import monthrange
from zoneinfo import ZoneInfo
def clamp_day(year: int, month: int, day: int) -> int:
"""31 Jan + 1 month = 28/29 Feb, not 3 March."""
return min(day, monthrange(year, month)[1])
def next_anniversary(period_start: datetime, anchor_day: int, tz: str) -> datetime:
"""Only used to VERIFY billing's boundary, never to replace it."""
local = period_start.astimezone(ZoneInfo(tz))
year, month = (local.year + (local.month == 12), local.month % 12 + 1)
return local.replace(day=clamp_day(year, month, anchor_day),
year=year, month=month).astimezone(timezone.utc)
def period_id(subscription) -> str:
"""Stable identifier for the current billing period, from billing's own fields."""
start = subscription.current_period_start # written by the billing system
return f"{subscription.id}:{start.strftime('%Y%m%dT%H%M%SZ')}"
QUOTA_LUA = """
-- KEYS[1] = quota counter for this period. ARGV: limit, cost, ttl_seconds
local limit = tonumber(ARGV[1])
local cost = tonumber(ARGV[2])
local ttl = tonumber(ARGV[3])
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
if used + cost > limit then
return { 0, used, limit - used }
end
local new = redis.call('INCRBY', KEYS[1], cost)
if new == cost then redis.call('EXPIRE', KEYS[1], ttl) end -- first write sets the TTL
return { 1, new, limit - new }
"""
def check_quota(redis, subscription, cost=1):
pid = period_id(subscription)
key = f"quota:{{{subscription.account_id}}}:{pid}"
# TTL outlives the period so a late request in the period still counts,
# and the key disappears once the period is safely closed.
ttl = int((subscription.current_period_end - datetime.now(timezone.utc)).total_seconds()) + 86_400
allowed, used, remaining = redis.eval(QUOTA_LUA, 1, key,
subscription.quota, cost, max(ttl, 60))
return {
"allowed": bool(allowed),
"used": used,
"remaining": max(0, remaining),
"reset_at": subscription.current_period_end, # the invoice's instant, not ours
}Keying by period identifier rather than resetting counters is what makes the whole scheme safe: nothing has to run at midnight, a late request lands in the period it belongs to, and last period’s usage stays readable for support and reconciliation until its TTL expires.
Gotchas and edge cases
- Recomputing the boundary in the limiter. Two systems computing “one month later” will disagree at leap days, plan changes, and daylight-saving transitions. Read it from billing.
- Scheduled reset jobs. A cron that zeroes counters at midnight will miss a run, run twice, or run while requests are in flight. Key by period instead; nothing needs to run.
- Displaying UTC to the customer. “Resets at 00:00 UTC” is meaningless to somebody in Auckland. Show their zone and name it.
- Daylight-saving transitions. A period boundary at 02:30 local time happens twice or not at all on transition days. Store instants in UTC and convert for display only.
- Losing the counter on a failover. For a quota that drives billing, an in-memory or unreplicated counter is a revenue bug. Persist it, and reconcile against your event log.
- Downgrades applied immediately. Dropping a customer from 300,000 to 50,000 mid-period when they have already used 80,000 puts them instantly over quota. Apply downgrades at the period boundary.
- Deleting old period counters immediately. Support and finance both need last period’s number. Let the TTL expire it a day or two after the period closes.
Verification and testing
# quota_boundary_test.py — the cases that actually break in production.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def test_short_month_clamps():
start = datetime(2026, 1, 31, 9, 0, tzinfo=timezone.utc)
assert next_anniversary(start, 31, "UTC").day == 28 # 2026 is not a leap year
def test_leap_day_clamps():
start = datetime(2028, 1, 31, 9, 0, tzinfo=timezone.utc)
assert next_anniversary(start, 31, "UTC").day == 29 # 2028 is
def test_period_id_changes_on_upgrade(sub_factory):
sub = sub_factory(quota=50_000)
before = period_id(sub)
sub.upgrade(plan="enterprise", quota=300_000) # billing opens a new period
assert period_id(sub) != before # new key, usage restarts
def test_usage_survives_a_late_request(redis, sub_factory):
sub = sub_factory(quota=100)
key = f"quota:{{{sub.account_id}}}:{period_id(sub)}"
check_quota(redis, sub, cost=1)
assert redis.ttl(key) > (sub.current_period_end - datetime.now(timezone.utc)).total_seconds()
def test_display_names_the_zone(client, sub_factory):
sub = sub_factory(billing_tz="Pacific/Auckland")
body = client.get("/v1/usage", account=sub.account_id).json()
assert body["reset_at_local"].endswith("NZDT") or body["reset_at_local"].endswith("NZST")Run the first two on every release. Month-boundary arithmetic is the kind of code that looks obviously right and is wrong for exactly two days a year, which is long enough for the error to reach an invoice before anybody notices.
Frequently Asked Questions
Should quotas reset on the calendar month or the subscription anniversary?
Match whatever your invoices use. Anniversary periods align usage with the amount charged and spread resets across the month; calendar periods are easier to explain and produce a usage spike on the first of each month. Mixing them — invoicing by anniversary while resetting by calendar — guarantees disputes.
What happens to usage when a customer upgrades mid-period?
The cleanest model is for billing to close the current period and open a new one at the moment of the upgrade, with a prorated allowance. The limiter then switches to a new counter key, so previous usage stays attached to the plan that was in effect and does not consume the new allowance.
Which time zone should the boundary use?
Store instants in UTC and derive the boundary from the account's billing time zone, then display it in that zone with the zone named. A customer comparing your dashboard's countdown against their invoice date needs both to refer to the same clock.
Should exceeding a quota return 429?
No — a spent quota is not a transient condition a retry can pass. Return 402 Payment Required or a 403 with a machine-readable quota_exceeded reason and the reset instant, so clients stop rather than entering a retry loop against a wall they cannot get past until the period rolls.
Related
- Tiered Access & Quota Enforcement — the parent guide on the two-layer rate and quota model.
- Billing-Critical Sliding Log Usage — exact counting where the number becomes an invoice.
- Per-Tier Quota Enforcement with Redis — the per-request path this boundary feeds.
- Redis Key Design and TTL Strategy — why keying by period beats deleting counters.