Handling Weather API Rate Limits for Crop Models

Problem statement

Crop phenology models and yield forecasts need deterministic, high-resolution meteorological inputs to stay agronomically accurate. When an operation scales from a handful of blocks to hundreds of field polygons, a weather provider’s rate limit stops being a developer nuisance and becomes a hard constraint on data freshness. The exact edge case this page solves is a quota exhaustion that corrupts a model silently instead of failing loudly — the same asymmetric failure the parent Weather API Integration subsystem exists to prevent.

The concrete corruption looks like this. A degree-day accumulator polls hourly temperature for 400 polygons. Around polygon 260 the provider returns HTTP 429, and a naive client that treats any non-2xx as “no data” writes a null hour, coerces it to 0.0 downstream, and quietly under-counts growing degree-days for a quarter of the farm. Nothing crashes. The irrigation scheduler reads a plausible-but-wrong evapotranspiration figure, and a frost-alert module misses a night because the temperature series had a gap it never surfaced. A 429 is not a transient network blip — under RFC 6585 §4 it is a deterministic compliance signal, and it must be handled as data-integrity logic, not swallowed as an error.

The remedy has three parts, all implemented below: parse the provider’s rate-limit headers (Retry-After, X-RateLimit-Remaining, X-RateLimit-Reset) and stop dispatching before the quota hits zero; throttle concurrent coroutines with a semaphore and priority queue so irrigation and frost requests preempt long-range baselines; and normalize coordinates to a geohash cache so adjacent polygons requesting the same grid cell cost one call, not many. Backoff and jitter tuning is shared with the sibling feeds documented under Async Polling Strategies.

Quota-aware weather fetch loop: how a request moves from priority queue to emitted observation without breaching a rate limit A left-to-right dispatch spine with control branches. A priority queue holds three lanes — P0 frost and irrigation, P1 scheduled polls, P2 long-range baseline — draining lowest-priority-integer first into a semaphore that admits at most max_rps concurrent requests. Each admitted request hits a geohash cache decision: a hit returns the cached payload immediately so adjacent polygons share one call; a miss proceeds to a quota decision comparing X-RateLimit-Remaining against safety_buffer. If remaining is below the buffer the circuit breaker trips upward and pauses dispatch until X-RateLimit-Reset, then resumes; otherwise the request passes to an HTTP GET that parses Retry-After and the X-RateLimit-Remaining and Reset headers. A final 429 decision: no means emit the payload to the cache store and as a FieldObservation; yes means wait Retry-After plus jitter and requeue the record — never writing a null hour — looping the request back to the priority queue. When a 429 outage persists beyond stale_fallback_sec, a dashed control path substitutes a climatology baseline at confidence 0.65, which actuators below their confidence floor refuse. QUOTA-AWARE FETCH LOOP dispatch flow control / requeue / fallback PRIORITY QUEUE P0 · frost / irrigation P1 · scheduled poll P2 · long-range baseline Semaphore admit ≤ max_rps cache? geohash key quota? remaining vs safety_buffer HTTP GET /forecast parse Retry-After X-RateLimit-Remaining X-RateLimit-Reset 429? Emit payload → cache store → FieldObs miss pass no Cache hit return cached 1 call for adjacent polygons hit Circuit breaker pause until X-RateLimit-Reset then resume ↻ dispatch low 429 · rate limited wait Retry-After + jitter requeue — never write a null hour yes requeue after Retry-After Sustained outage > stale_fallback_sec → climatology baseline · confidence 0.65 actuators refuse < confidence floor outage persists

Parameter reference table

Every value below governs behavior at the boundary between quota compliance and model freshness. Recommended values assume a sustained provider limit expressed as requests per second, and an intermittently connected farm gateway.

Parameter Type Recommended value Effect on behavior
max_rps float 0.85 × provider_limit Sustained dispatch ceiling. The 15% headroom absorbs bursts without tipping into 429; set to 1.0 × only against a private, guaranteed quota.
safety_buffer int provider_limit // 20 Circuit-breaker trips when X-RateLimit-Remaining falls below this. Too low breaches mid-batch; too high wastes quota you paid for.
geohash_precision int 7 (≈153 m) Cache key resolution. 6 (~1.2 km) over-merges distinct microclimates; 8 (~19 m) fragments the cache and inflates call volume.
cache_ttl_sec float 900.0 Entry lifetime, matched to typical numerical-weather-model refresh cadence. Shorter re-fetches needlessly; longer serves stale hours into frost logic.
backoff_base_sec float 2.0 Base for exponential backoff (backoff_base_sec ** attempt) when no Retry-After header is present.
jitter_range tuple[float, float] (0.0, 1.0) Uniform jitter added to each backoff, breaking synchronized retry storms across workers.
stale_fallback_sec float 1800.0 Live-data outage after which the client switches to a climatological baseline rather than blocking the model.

Register the exact header names your provider emits — Open-Meteo, Tomorrow.io, and the NOAA/NWS API each spell the reset field differently, and reading the wrong key means the circuit breaker never trips. Confirm whether the limit is per-second, per-minute, or per-day before deriving max_rps; the same code enforces the wrong ceiling if the window is misread.

Runnable implementation

The client below couples a quota-aware asyncio throttle with header parsing and a geohash cache. It admits requests through a semaphore, drains a priority queue so agronomically urgent calls jump ahead, halts dispatch when remaining quota drops under safety_buffer, and honors Retry-After on a 429. It targets Python 3.10+ and is fully typed.

python
from __future__ import annotations

import asyncio
import random
import time
from dataclasses import dataclass, field

import httpx
import pygeohash  # normalizes lat/lon to a fixed-resolution cache key


@dataclass(order=True)
class WeatherRequest:
    priority: int              # 0 = frost/irrigation, 2 = long-range baseline
    lat: float = field(compare=False)
    lon: float = field(compare=False)


@dataclass
class QuotaState:
    remaining: int
    reset_at: float            # monotonic deadline when the window refills


@dataclass
class RateLimitedWeatherClient:
    base_url: str
    provider_limit: int
    max_rps: float
    safety_buffer: int
    geohash_precision: int = 7
    cache_ttl_sec: float = 900.0
    backoff_base_sec: float = 2.0
    jitter_range: tuple[float, float] = (0.0, 1.0)
    _cache: dict[str, tuple[float, dict]] = field(default_factory=dict, init=False)
    _sem: asyncio.Semaphore = field(init=False)
    _quota: QuotaState = field(init=False)

    def __post_init__(self) -> None:
        # Cap concurrency at the sustained ceiling so bursts cannot outrun the window.
        self._sem = asyncio.Semaphore(max(1, int(self.max_rps)))
        self._quota = QuotaState(remaining=self.provider_limit, reset_at=0.0)

    def _read_headers(self, resp: httpx.Response) -> None:
        # Header names vary by provider — map them once, here, not at every call site.
        remaining = resp.headers.get("X-RateLimit-Remaining")
        reset = resp.headers.get("X-RateLimit-Reset")
        if remaining is not None:
            self._quota.remaining = int(remaining)
        if reset is not None:
            self._quota.reset_at = time.monotonic() + float(reset)

    async def _await_quota(self) -> None:
        # Circuit breaker: pause the whole client until the window refills rather
        # than firing the request that would earn a 429 and a throttling penalty.
        while self._quota.remaining <= self.safety_buffer:
            sleep_for = max(0.0, self._quota.reset_at - time.monotonic())
            await asyncio.sleep(sleep_for or 1.0)
            self._quota.remaining = self.provider_limit  # assume refill after reset

    async def fetch(self, http: httpx.AsyncClient, req: WeatherRequest) -> dict:
        key = pygeohash.encode(req.lat, req.lon, precision=self.geohash_precision)
        cached = self._cache.get(key)
        if cached and time.monotonic() - cached[0] < self.cache_ttl_sec:
            return cached[1]                      # adjacent polygons share one call

        async with self._sem:
            attempt = 0
            while True:
                await self._await_quota()
                resp = await http.get(self.base_url,
                                      params={"lat": req.lat, "lon": req.lon})
                self._read_headers(resp)
                if resp.status_code == 429:
                    wait = float(resp.headers.get(
                        "Retry-After", self.backoff_base_sec ** attempt))
                    await asyncio.sleep(wait + random.uniform(*self.jitter_range))
                    attempt += 1
                    continue                      # requeue, do NOT drop the record
                resp.raise_for_status()
                payload = resp.json()
                self._cache[key] = (time.monotonic(), payload)
                return payload

    async def run(self, queue: asyncio.PriorityQueue[WeatherRequest]) -> list[dict]:
        results: list[dict] = []
        async with httpx.AsyncClient(timeout=30.0) as http:
            while not queue.empty():
                req = await queue.get()           # lowest priority int drains first
                results.append(await self.fetch(http, req))
        return results

Two choices are load-bearing. First, quota deadlines use time.monotonic(), not wall-clock time.time(), so an NTP correction on a farm gateway can never make the window look prematurely open. Second, a 429 triggers a requeue, never a write — the record is retried after Retry-After, so a rate-limit hit can never masquerade as a missing hour in a degree-day series. Field coordinates that reach this client are already validated against real polygons upstream in the Farm Data Ingestion & Field Boundary Synchronization pipeline.

Log patterns and observable signals

Every fetch cycle emits one structured line carrying the endpoint, the geohash, and the current quota so an interrupted run and any throttling hotspot are reconstructable.

Success path (cache miss served cleanly, quota healthy):

text
[INFO]  2026-06-14T14:22:01Z | wx.ok | endpoint=/v1/forecast | geohash=dqcjqcm | remaining=418 | cache=miss
[DEBUG] 2026-06-14T14:22:02Z | wx.cache_hit | geohash=dqcjqcm | ttl_remaining=873s | saved_call=true

Warning (approaching the buffer; the circuit breaker is about to pause dispatch):

text
[WARN]  2026-06-14T14:23:10Z | wx.quota_low | remaining=21 | safety_buffer=25 | action=circuit_pause | reset_in=42s

Backoff (a 429 crossed the window boundary; the request is requeued, not dropped):

text
[INFO]  2026-06-14T14:23:12Z | wx.429 | endpoint=/v1/forecast | retry_after=42 | attempt=3 | jitter=0.62 | action=requeue

Error (sustained outage; the model is switched to a climatological baseline):

text
[ERROR] 2026-06-14T14:53:40Z | wx.fallback | outage_sec=1834 | source=climatology_baseline | confidence=0.65 | frost_module=degraded

When triaging, aggregate wx.429 counts per endpoint and per geohash prefix. A burst of 429s sharing a geohash prefix points at a spatial hotspot — many adjacent polygons hammering the same grid cell — which is a cache-precision problem, not a quota problem, and is fixed by tuning geohash_precision rather than buying more quota.

Safe override protocol

Two overrides are legitimate, and both must stay auditable rather than becoming a way to skip quota safety.

  1. Climatology fallback. When live data is unavailable for longer than stale_fallback_sec, substitute a 30-day rolling climatological baseline so the model does not block. Guard it hard: every substituted record must carry data_source="climatology_fallback" and confidence_score=0.65, and any module that can actuate — automated irrigation triggers, frost-driven equipment — must refuse to act on a record below its confidence floor. Frost alerting requires 100% observed-temperature continuity and must degrade to human-in-the-loop, never auto-fire, on fallback data.
  2. Local sensor prioritization. During a sustained 429 storm, route on-farm IoT telemetry (soil-moisture probes, canopy-temperature sensors) directly into the model, bypassing the weather API. Validate that telemetry against FAO-56 reference evapotranspiration methodology before ingestion so a drifting probe cannot quietly replace a good forecast with a bad reading.
python
def apply_fallback(record: dict | None, outage_sec: float,
                   stale_fallback_sec: float) -> dict:
    # Guard: only substitute after the outage exceeds the stale threshold, and
    # always stamp provenance so downstream actuators can refuse low-confidence data.
    if record is not None:
        return record
    if outage_sec < stale_fallback_sec:
        raise RuntimeError("live data missing but outage under stale threshold")
    return {"data_source": "climatology_fallback", "confidence_score": 0.65}

Every override emits its own audit line (override=climatology_fallback or override=sensor_route) with the operator identity and timestamp, appended to the same immutable ledger the compliance layer consumes. A quota-driven data gap is an auditable deviation, never a silent exemption — which keeps the record defensible under USDA NRCS and EU CAP reporting requirements. Provider-outage routing rules are shared with the Fallback Routing Logic subsystem.

Troubleshooting

  • Degree-day totals short but every line reads wx.ok. Root cause: a 429 was swallowed and a null hour written instead of requeued. Remediation: confirm the 429 branch loops back via continue and never returns a record; a rate-limit hit must requeue, not persist.
  • wx.quota_low never fires, then the account is throttled. Root cause: the client is reading the wrong header name and remaining is never updated. Remediation: map your provider’s exact X-RateLimit-Remaining/Reset spelling in _read_headers; a stale counter means the circuit breaker is blind.
  • wx.429 in synchronized bursts across workers. Root cause: retries fire without jitter, so backed-off workers resynchronize and hammer the endpoint together. Remediation: keep jitter_range non-zero and cap the semaphore at max_rps; see Async Polling Strategies for the shared backoff discipline.
  • Cache hit rate collapses on dense farms. Root cause: geohash_precision=8 fragments adjacent polygons into distinct keys. Remediation: drop to 7 (~153 m) so neighbouring blocks share a grid cell without merging genuine microclimates.
  • Frost alert fired on climatology_fallback data. Root cause: the actuator ignored confidence_score. Remediation: enforce a per-module confidence floor so frost logic refuses any record below observed continuity, per the safe-override guard above.

Coordinate normalization, unit coercion, and schema validation of each payload happen at the ingestion boundary — see the parent Weather API Integration reference for the Pydantic contract these records must satisfy. For the non-blocking primitives behind this client, the Python asyncio documentation is authoritative, and the 429 semantics follow RFC 6585.

Up: Weather API Integration · Farm Data Ingestion & Field Boundary Synchronization