DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Polling someone else's API politely: an interval that follows the vehicle's state, a per-user floor and one global token bucket

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
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

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'])
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
    ...
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)