Disclosure: I'm the developer of ScooterLog, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the app's source code. ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric.

ScooterLog keeps a charge history and a trip log for a connected electric scooter. There is no push feed to subscribe to, so the server polls the vehicle maker's cloud for each rider's scooter and stores what comes back. That cloud is not ours. Every request we send is a request someone else has to serve, and the fastest way to lose access to an upstream API is to look like a load test.

This post is the scheduler that decides when to ask. It is about 180 lines of Python, and most of it is four ideas.

1. The interval follows what the vehicle is doing

A parked scooter at midnight changes nothing for hours. A scooter on the charger gains a percent every few minutes. A scooter being ridden changes position and odometer every minute. Polling all three at one fixed rate is either wasteful or too slow.

So the next interval is chosen from the last two stored readings:

RIDING = 60 # seconds CHARGING = 180 PARKED = 300 ASLEEP = 1800 def next_interval(user_id, vehicle_id): pts = store.recent_points(user_id, vehicle_id, 2) if not pts: return PARKED last = pts[0] if last.get('charging_state') in ('SLOW_CHARGING', 'FAST_CHARGING'): return CHARGING if len(pts) == 2 and (_moved(pts[0], pts[1]) or (pts[0].get('odometer') or 0) - (pts[1].get('odometer') or 0) >= 0.5): return RIDING ... return PARKED 

"Moved" is deliberately coarse: a change of more than 0.0001 degrees in latitude or longitude (roughly 11 m), or half a kilometre on the odometer. GPS jitter on a parked vehicle should not flip it into riding mode.

2. Keep watching after the data goes quiet

The first version had a blind spot. Mid-ride, the upstream often sends nothing new: the last reading stays the "awake" one from the start of the ride. With only the rules above, the scheduler saw two identical readings, decided the scooter was parked, and backed off to five minutes. The end of the ride was then caught up to five minutes late.

The fix is one extra rule: if the last reading says the vehicle was awake, and it is less than 40 minutes old, keep polling at the riding rate.

AWAKE_MS = 40 * 60 * 1000 if not last.get('sleep') and time.time() * 1000 - (last.get('source_ms') or 0) < AWAKE_MS: return RIDING 

The lesson generalises: when the source goes silent, "nothing changed" and "nothing was reported" look the same. Decide which one you are assuming, and bound how long you assume it.

3. A hard floor per user

Whatever the rules say, no user is polled more often than MIN_INTERVAL (60 seconds by default, set from an environment variable). The loop applies it after picking the fastest interval among that user's vehicles:

due_interval = min((next_interval(u['user_id'], v['id']) for v in vs), default=PARKED) due_interval = max(MIN_INTERVAL, due_interval) if now - (u['last_poll_ms'] or 0) >= due_interval * 1000: poll_user(u['user_id']) 

The floor is a separate line on purpose. The adaptive rules will change as we learn; the floor is the promise to the upstream, and it should not depend on those rules being right.

4. One token bucket for the whole service

Per-user floors do not stop a spike. If many riders start charging at 7 pm, many users become "due" in the same tick. So every upstream request also takes a token from a single, service-wide bucket (20 requests per second by default):

class TokenBucket: def __init__(self, rate, burst=None): self.rate = rate self.capacity = burst or max(1.0, rate) self.tokens = self.capacity self.ts = time.monotonic() self.lock = threading.Lock() def take(self, timeout=30): deadline = time.monotonic() + timeout while True: with self.lock: now = time.monotonic() self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate) self.ts = now if self.tokens >= 1: self.tokens -= 1 return True if time.monotonic() >= deadline: return False time.sleep(0.05) 

Two details matter. It uses time.monotonic(), so a clock change on the server cannot mint a burst of tokens. And take() gives up after a timeout and returns False; the caller then skips that user for this tick instead of queueing forever. A skipped poll is picked up on the next pass. A queue that only grows is an outage in slow motion.

5. Stop on the first "no", and keep a kill switch

Two smaller rules finish the picture. A 401 or 403 from upstream marks that user as needing to sign in again and stops polling them; retrying a rejected token on a timer helps nobody. And the main loop checks a kill switch before each pass: when it is on, the service makes no upstream calls at all and just sleeps.

while not (stop and stop.is_set()): if upstream.disabled(): # kill switch: no upstream calls at all  time.sleep(30) continue ... 

What it costs

Readings can lag by up to the current interval, so a charge alarm can arrive a few minutes after the battery reaches the level. The app says so: it only knows what the scooter reports, and readings can lag a few minutes. It does not control the scooter, and it is not meant for emergencies.

If you poll an API you do not own, the transferable parts are: choose the interval from state, keep watching for a bounded time when the data goes quiet, put a fixed floor per user under the clever rules, and put one monotonic token bucket in front of everything. The app is on Google Play.

ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric.