architectureoperationsintegrations

A rate limit sounds like a property of an API client. In Strike it is a property of a whole relationship: one OSP-owned Render connection, several callers, every active ECO attached to that connection, and one allowance they all spend.

That distinction changes the architecture conversation. The question is not merely whether the poller stays under two requests per second. It is whether every request path combined can remain inside 10,000 requests per day, recover sensibly from throttling, and still show operators useful field state.

This cairn reflects the request model documented on July 28, 2026, plus the later fixes that routed dispatch fetches through the shared polling limiter, made missing instance intervals fall back to 300 seconds, and stopped the poller from retrying 429s in-request. Items identified as planned remain future work.

The Credential Is the Budget Boundary

Render enforces its limits per API credential. Each render_instances row has its own credentials, so each row gets a separate request budget.

Limit Value
Daily quota 10,000 requests/day
Rate limit 2 requests/second
Burst limit 5 concurrent requests
Page size 2,000 default and hard maximum

The daily quota is tighter than it first appears. Ten thousand requests spread across a day is one request every 8.64 seconds. Two formulas describe the current capacity model:

minimum safe interval (seconds) = requests per cycle × 8.64
maximum ECOs per instance       = interval seconds ÷ 8.64

Polling costs one request per ECO per cycle. A five-second interval consumes 17,280 requests per day for one ECO. At 60 seconds, one ECO consumes 1,440.

Interval Requests/day per ECO Approximate ECO ceiling
5 seconds 17,280 0.6
60 seconds 1,440 6
120 seconds 720 13
300 seconds 288 34
600 seconds 144 69

The Yelcot connection is configured at 60 seconds. That is reasonable for about six steady-state ECOs, not twelve. Twelve ECOs at that cadence consume 17,280 requests per day; they need at least 104 seconds, making 120 or 300 seconds the practical choices.

A bounded demo is different from steady state. Twelve ECOs polled every minute for three hours cost about 2,160 requests. The hazard is forgetting to restore the longer interval after the demo.

Four Paths Spend the Same Allowance

Four paths can contact Render. Three have meaningful ongoing volume; the admin probe is small but still spends the same credential allowance.

Caller Trigger Cadence Concurrency cap
Background poller Timer Per-instance interval 3
Dispatch trigger ECO becomes DISPATCHED Once per dispatch event Shared polling slot
GraphQL live fetch UI read needing live data Per page render/read 2
Admin task-types probe Operator clicks refresh Two requests per click None

The poller cap of three plus the GraphQL cap of two exactly equals Render’s burst limit of five. Dispatch is now inside the polling arithmetic: it queues behind the same three-slot gate rather than adding an uncounted burst. Nothing coordinates both packages through one credential-scoped limiter yet.

This is why capacity cannot be inferred from the worker-pool setting alone. A pool constrains one path. The credential sees all of them.

The Background Cycle Is Linear in ECO Count

The background loop is the steady-state request source:

pollLoopServicePostgresRender APIrender_tasks_cachenothing dueinstances dueper ECO, max 3 concurrentDISPATCHED ECO  wait max(interval, backoff)FindDispatched()  TASKED + DISPATCHED ECOswith instance controlsdueECOs()no-op, keep backoff stateGET /tasks?label_name=<job id>this ECO's tasksfingerprint + exact filterupdate render_task_countupsert seen / mark removedpublish detected changesevaluate completionpolled, reset backoffBackoff may only slow the loop,never speed it upDrop disabled instances andinstances not yet due















Filtering is per instance because the credential is the quota unit. For every due instance, Strike fetches each pollable ECO by its exact label, with the page size capped at 2,000. Exact server-side filtering keeps one ECO’s result on one page and avoids silent truncation.

TASKED ECOs are polled to fill the cache but skip change detection and completion. Their tasks may already appear complete before paging has dispatched anyone, so treating those observations as workflow completion would move the domain too early.

Fingerprinting reduces internal event noise. It does not reduce API volume: Strike must fetch the tasks before it can know whether their fingerprint changed.

Dispatch Buys Immediacy Outside the Cycle

The dispatch trigger exists because longer safe intervals create a visible delay. At a 300-second cadence, waiting for the background loop could leave a freshly dispatched ECO without task data for five minutes.

Watermill subscriptionServicePostgresRender APIrender_tasks_cacheinstance polling disabledpolling enabled  ECO status = DISPATCHEDFindPollableByID()  keyed ECOView + FormattedJobIDskipacquire shared polling worker slotGET /tasks?label_name=<job id>this ECO's taskspopulate cacheWait is capped at 30 secondsBypasses interval by design,but not the worker pool










The per-instance kill switch applies here as well as in the timer loop. The trigger deliberately does not stamp the instance’s last-poll clock: it fetched one ECO, while that clock governs every ECO on the connection. Moving the clock would defer the whole background cycle and could starve sibling ECOs whenever dispatches keep arriving.

Routing this trigger through the shared limiter has landed. It preserves the useful fast refresh without letting dispatch bursts exceed the polling package’s concurrency allowance. The wait is capped at 30 seconds so shutdown and kill-switch changes are not trapped behind an unbounded queue; if the trigger gives up, the next background cycle re-reads the controls and catches the ECO.

UI Reads Can Become a Second Poller

The list and detail screens have different request behavior:

BrowserGraphQL resolverPostgresrender_tasks_cacheRender APIECO LIST — renderTaskCountECO DETAIL — renderTaskscount presentcount missingsuccessfailure  ecos { renderTaskCount }read eco_views.render_task_count  count, no Render calllive fetch, max 2 concurrentwrite through counteco(id) { renderTasks }live label fetchtasks, Stale = falseListPresent(ecoID)cached tasks, Stale = trueOn failure, serve cache and skipwrite-through so fallback remains availableCache may be reached only aftera timeout of up to 30 seconds












The planned correction is cache-first reads with an optional, narrowly controlled live refresh. That removes an independent high-volume caller, prevents throttling from repeatedly forcing the same failed write-through path, and turns a Render outage into immediate stale data rather than a long wait followed by stale data.

The product decision is how stale data may be before a demand refresh is justified. That should be visible through an as-of timestamp, not hidden behind a resolver that silently contacts Render.

Restart Replays the First Poll

render_tasks_cache is durable. The service state that schedules and compares polls is not: instanceLastPoll, initialized, initOnce, and fingerprints live in in-memory maps.

New processServicePostgresRender APIN ECOs, max 3 concurrent  Start with empty in-memory mapsFindDispatched()  N pollable ECOsdueECOs()GET /tasks?label_name=...seed fingerprints and suppressinitial change eventsNo instance has a last-poll time,so every instance is dueInitialization also stampslast_task_change_at








This happens once per process start: deploys, pod restarts, crash loops, and scale events. ArgoCD image updates make restarts routine.

Pollable ECOs Requests per restart Cost at 5 restarts/day Daily quota share
12 12 60 0.6%
100 100 500 5%
1,000 1,000 5,000 50%

At current scale the quota cost is negligible. At target scale it is severe. The second effect matters sooner: initialization stamps last_task_change_at, resetting the 48-hour inactivity TTL for the whole pollable set. Repeated restarts can therefore keep stale ECOs alive.

The database already has the durable marker needed to avoid that reset. Per-project fetching later reduces restart cost from one request per ECO to the number of pages needed for each project.

Rate Limiting Fails on the Wrong Timescale

Current failure handling is layered, but the layers were designed around short transient outages:

ServiceCircuit breakerRender API  requestGET /tasks?label_name=...  429 Too Many Requestsrate-limit errorbackoff starts at 100 ms,doubling to 30 secondsPoller client does not retry 429s;GraphQL live fetch still doesWait is max(interval, backoff),so errors cannot accelerate pollingStop issuing more requests tothis instance after one fullworker wave is refused







Render uses a bare 429 for both per-second throttling and daily exhaustion, without a header that distinguishes “try again shortly” from “come back tomorrow.” The poller therefore treats its next cycle as the retry instead of spending extra in-request attempts against a credential that just refused work.

A single rejected ECO no longer proves the whole credential is exhausted. The cycle counts rate-limit failures per Render instance and stops issuing more requests to that instance after one full worker wave has been refused. Other instances on the same tick continue because each row has its own credential budget.

The remaining weakness is the breaker timescale. A roughly 30-second half-open probe is still tuned for transient outages, not day-scale quota exhaustion, so partial throttling and quota recovery need more deliberate behavior.

The Cache Has Two Different Kinds of Value

render_tasks_cache is a poller-maintained local replica of Render’s current state, plus history about when tasks appeared and disappeared. It is neither a read-through cache nor an event-sourced projection.

Data Nature If lost
Current tasks Disposable cache One poll cycle loses fallback, then it self-heals
first_seen_at / removed_at System of record History is permanently gone

That makes “cache” a dangerous shorthand. A projection rebuild must not clear this table. Replay cannot reproduce external observations that were never domain events.

There is also a schema hazard. The ECO projection rebuilds eco_views rows by deleting and rewriting them. An ON DELETE CASCADE relationship from the task cache could silently erase the audit history during replay, so that foreign-key action must remain deliberate.

Refresh is push-based from polling. There is no TTL to invalidate. Cache freshness is bounded by the polling interval, which is why longer quota-safe intervals need an as-of timestamp and narrow demand-refresh policy.

Operator controls apply at two scopes:

Control Scope Effect
Polling enabled Per Render instance Connection-level kill switch, including dispatch trigger
Interval seconds Per Render instance Cadence for that credential
Inactivity TTL Per Render instance Stop an ECO after no task changes for N hours
Completion grace period Per Render instance Continue watching after completion
RENDER_POLLING_ENABLED Whole process Global switch, applied at startup
RENDER_POLLING_INTERVAL Whole process Loop tick only; not the missing-instance fallback
DefaultInstancePollingInterval Code constant 300-second fallback when an instance stores no interval
RENDER_POLLING_WORKERS Whole process Poll concurrency, clamped to its burst share

The preferred emergency stop is the per-instance switch in the admin UI. For one or a few ECOs, set polling_stopped_at. The last resort is disabling polling globally and restarting the deployment.

The Honest Ceiling Is About Thirty-Four ECOs

The current design is linear: one request per ECO per cycle. At five minutes, one credential supports roughly 34 steady-state ECOs before the daily allowance is consumed. At one minute, it supports about six.

The improvements have a clear dependency order:

  1. Stop restart initialization from resetting the inactivity clock.
  2. Make UI reads cache-first, with explicit staleness and controlled refresh.
  3. Tune breaker behavior for partial throttling and day-scale quota recovery.
  4. Fetch per Render project with pagination instead of once per ECO.
  5. Extend inactivity coverage for TASKED ECOs.

The landed fixes make the current linear design safer. Per-project fetching changes its shape: cost follows project pages and task counts rather than ECO count. That is the change that lifts the roughly 34-ECO ceiling.

That correlation question is now settled. ADR-010 makes the tenant-formatted ECO job ID, carried as an exact case-sensitive Render General label, the only definition of task membership; subsector is demoted to a literal ECO constant that satisfies Render’s required categorization field. The label stops Strike consuming a customer-owned taxonomy field and keeps exact server-side filtering, so a per-project design can treat the correlation field as fixed.

The operational rule until then is plain: treat every Render interval as a capacity decision, every live fetch as shared-budget spending, and every kill switch as part of the reliability model.

Discussion Prompts

  1. What staleness window can the ECO list and detail screen tolerate before a demand refresh is worth spending shared quota?
  2. Should the admin UI show estimated daily requests from the current interval and pollable ECO count?
  3. At what connection size does per-project pagination become required rather than planned?

References

  1. docs/architecture/render-integration-request-model.md in Osprey Strike — The source architecture review covering callers, quota math, cache behavior, restart effects, controls, and planned work.
  2. docs/decisions/ADR-007-render-task-cache.md in Osprey Strike — Defines the cache as a poller-maintained replica with non-replayable audit history.
  3. Osprey Strike Render polling package — Implements the background loop, per-instance scheduling, fingerprints, cache persistence, and completion checks.
  4. Osprey Strike Render integration package — Implements task creation and the immediate post-dispatch fetch path.