Delivery integrations look like the easy part of an e-commerce build. Create a parcel, get a tracking number, poll for status, print a label. Four endpoints.
Then you discover the API meters you in four nested windows at once, and your perfectly reasonable cron job is the thing that breaks it.
Read the headers you are already receiving
Yalidine's API returns its remaining allowance on every response:
second-quota-left: 4
minute-quota-left: 47
hour-quota-left: 892
day-quota-left: 4310
Four counters, four different windows. Most client libraries — including the good ones — surface these, and most integrations ignore them until something returns 429.
That is the mistake, because the four windows fail in completely different ways. Blowing the per-second budget is a burst problem you notice immediately in development. Blowing the per-day budget is an architecture problem you notice at three in the afternoon, in production, on the account with the most parcels — and by then you are locked out until midnight.
Treat them as a budget you spend deliberately, not an error you handle.
The cron that eats the day
Here is the naive status sync, and it is what almost everyone writes first:
# Every 10 minutes: ask about every parcel that is not delivered yet.
for parcel in Parcel.objects.exclude(status__in=TERMINAL_STATUSES):
history = yalidine.histories.find(parcel.tracking) # one request each
parcel.apply(history)Do the arithmetic before you deploy it. With 800 parcels in flight, that is 800 requests per run, 144 runs a day: 115,200 requests. There is no plausible daily quota that survives it, and the failure scales with your customer's success — the busier they are, the earlier in the day they go dark.
Worse, it is nearly all waste. A parcel's status changes a handful of times across several days. You are asking the same question about the same unchanged parcel hundreds of times.
Ask once for many, not many times for one
The list endpoints take filters and a page size. One request can answer for a whole page of parcels instead of one:
def sync_recent(since):
page = 1
while True:
batch = yalidine.parcels.list(
date_last_status_from=since, # only what actually moved
page=page,
page_size=100, # one request, one hundred answers
)
for item in batch["data"]:
Parcel.objects.filter(tracking=item["tracking"]).update(
status=item["last_status"],
status_at=item["date_last_status"],
)
if not batch.get("has_more"):
return
page += 1The same 800 parcels now cost eight requests instead of 800, and filtering on last status change means quiet days cost almost nothing. The daily budget stops being a constraint and becomes a rounding error.
Two rules follow from this, and they generalise to every third-party API I have integrated:
Never poll per-entity when a filtered list exists. If the provider gives you a "changed since" filter, that filter is the integration. Per-entity endpoints are for the detail view a human opened, not for a loop.
Never poll something that cannot change. Delivered, returned and cancelled are terminal. A parcel in a terminal state should leave the polling set permanently — most naive syncs keep asking about parcels that were delivered three weeks ago.
Spend the budget you can see
Once you are reading the headers, you can make the sync self-limiting instead of hopeful:
class Quota:
"""Tracks the provider's four windows from its own response headers."""
def __init__(self):
self.left = {}
def observe(self, headers):
for window in ("second", "minute", "hour", "day"):
value = headers.get(f"{window}-quota-left")
if value is not None:
self.left[window] = int(value)
def should_pause(self):
# Keep a reserve so interactive traffic never queues behind a sync.
if self.left.get("day", 10_000) < 500:
return "day"
if self.left.get("minute", 100) < 10:
return "minute"
return NoneThe reserve matters more than the throttle. A background sync and a customer clicking "create parcel" draw on the same allowance, and the sync must never be the reason the interactive request fails. Stop the batch job well before zero and let the human traffic through.
Do not start every tenant at :00
If you run this for several merchants, the obvious schedule — */10 * * * * — makes every account fire simultaneously. You get a spike ten times an hour, idle in between, and a per-second quota that only breaks under the exact conditions your staging environment never reproduces.
Offset each tenant deterministically instead:
# Same tenant, same slot, every time — but spread across the window.
offset = crc32(tenant.id.encode()) % 600 # seconds within a 10-minute windowA hash of the tenant id, rather than a random number, so a given account always runs in the same slot and its behaviour stays reproducible when you debug it.
When the 429 does come
It will, and the response tells you which window you exhausted. Back off to the end of that window, not by a fixed amount: a minute overrun clears in seconds, a daily overrun does not clear until midnight, and retrying a daily overrun every thirty seconds for nine hours is how you get a relationship with the provider's support team.
The pattern behind it
Nested quota windows are unusual to see spelled out in headers; the underlying constraint is not unusual at all. Every API you integrate has a shape it expects to be used in, and polling every entity on a timer is almost never that shape.
The question to ask early is not "what is the rate limit". It is "how does this provider expect me to learn that something changed?" If the answer is a webhook, take it. If it is a filtered list endpoint, build around the filter. Per-entity polling is the option you fall back to when neither exists — and if you must, poll only what can still change, and only as often as the data actually moves.
Sources: Yalidine SDK — quota headers and endpoints, Yalidine integration notes