{"slug": "polling-someone-else-s-api-politely-an-interval-that-follows-the-vehicle-s-state", "title": "Polling someone else's API politely: an interval that follows the vehicle's state, a per-user floor and one global token bucket", "summary": "The developer of ScooterLog, an independent app that logs charge and trip history for a connected electric scooter, built a roughly 180-line Python scheduler that decides when to poll the vehicle maker's upstream cloud API. The scheduler picks polling intervals from the last two stored readings (60 seconds riding, 180 charging, 300 parked, 1800 asleep), enforces a per-user minimum interval of 60 seconds, and caps all upstream traffic with a service-wide token bucket at 20 requests per second. The developer notes a fixed bug where silent upstream readings were mistaken for a parked vehicle, fixed by continuing to poll at the riding rate while the last reading is under 40 minutes old and shows the vehicle awake.", "body_md": "*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.*\n\n[ScooterLog](https://play.google.com/store/apps/details?id=com.kkfe.scooterlog&utm_source=devto&utm_medium=article&utm_campaign=2026-w45-polling) 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.\n\nThis post is the scheduler that decides *when* to ask. It is about 180 lines of Python, and most of it is four ideas.\n\nA 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.\n\nSo the next interval is chosen from the last two stored readings:\n\n```\nRIDING   = 60     # seconds\nCHARGING = 180\nPARKED   = 300\nASLEEP   = 1800\n\ndef next_interval(user_id, vehicle_id):\n    pts = store.recent_points(user_id, vehicle_id, 2)\n    if not pts:\n        return PARKED\n    last = pts[0]\n    if last.get('charging_state') in ('SLOW_CHARGING', 'FAST_CHARGING'):\n        return CHARGING\n    if len(pts) == 2 and (_moved(pts[0], pts[1]) or\n                          (pts[0].get('odometer') or 0) - (pts[1].get('odometer') or 0) >= 0.5):\n        return RIDING\n    ...\n    return PARKED\n```\n\n\"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.\n\nThe 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.\n\nThe 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.\n\n```\nAWAKE_MS = 40 * 60 * 1000\n\nif not last.get('sleep') and time.time() * 1000 - (last.get('source_ms') or 0) < AWAKE_MS:\n    return RIDING\n```\n\nThe 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.\n\nWhatever 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:\n\n```\ndue_interval = min((next_interval(u['user_id'], v['id']) for v in vs), default=PARKED)\ndue_interval = max(MIN_INTERVAL, due_interval)\nif now - (u['last_poll_ms'] or 0) >= due_interval * 1000:\n    poll_user(u['user_id'])\n```\n\nThe 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.\n\nPer-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):\n\n``` python\nclass TokenBucket:\n    def __init__(self, rate, burst=None):\n        self.rate = rate\n        self.capacity = burst or max(1.0, rate)\n        self.tokens = self.capacity\n        self.ts = time.monotonic()\n        self.lock = threading.Lock()\n\n    def take(self, timeout=30):\n        deadline = time.monotonic() + timeout\n        while True:\n            with self.lock:\n                now = time.monotonic()\n                self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate)\n                self.ts = now\n                if self.tokens >= 1:\n                    self.tokens -= 1\n                    return True\n            if time.monotonic() >= deadline:\n                return False\n            time.sleep(0.05)\n```\n\nTwo 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.\n\nTwo 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.\n\n```\nwhile not (stop and stop.is_set()):\n    if upstream.disabled():   # kill switch: no upstream calls at all\n        time.sleep(30)\n        continue\n    ...\n```\n\nReadings 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.\n\nIf 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](https://play.google.com/store/apps/details?id=com.kkfe.scooterlog&utm_source=devto&utm_medium=article&utm_campaign=2026-w45-polling).", "url": "https://wpnews.pro/news/polling-someone-else-s-api-politely-an-interval-that-follows-the-vehicle-s-state", "canonical_source": "https://dev.to/ashwar_sadh_f7abe6f82c8e9/polling-someone-elses-api-politely-an-interval-that-follows-the-vehicles-state-a-per-user-floor-16h5", "published_at": "2026-11-05 06:16:34+00:00", "updated_at": "2026-11-05 06:23:53.956315+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools"], "entities": ["ScooterLog", "Ola Electric"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/polling-someone-else-s-api-politely-an-interval-that-follows-the-vehicle-s-state", "markdown": "https://wpnews.pro/news/polling-someone-else-s-api-politely-an-interval-that-follows-the-vehicle-s-state.md", "text": "https://wpnews.pro/news/polling-someone-else-s-api-politely-an-interval-that-follows-the-vehicle-s-state.txt", "jsonld": "https://wpnews.pro/news/polling-someone-else-s-api-politely-an-interval-that-follows-the-vehicle-s-state.jsonld"}}