What calls Render, when it calls, and why one credential—not one cluster—sets the ceiling ·
~18 min read~– min read
· Suggested by Bob
engineeringoperationsproduct
Osprey Strike does not have one Render request path. Background polling, dispatch-triggered refreshes, GraphQL reads, and admin probes all spend the same credential-scoped allowance, with polling and dispatch now sharing one concurrency gate and UI detail reads normally served from the local replica. The daily quota, not the headline requests-per-second limit, determines how many ECOs one Render connection can safely watch.
Render allows 10,000 requests per day for each API credential, alongside 2 requests per second and 5 concurrent requests. Every render_instances row carries its own credentials, so capacity is per Render connection rather than per Strike cluster. The daily allowance is the binding constraint: it averages one request every 8.64 seconds.
Five Paths Spend the Same Allowance
Background polling is not alone. An ECO dispatch triggers an immediate fetch, GraphQL can still fetch live for cold or unreadable detail reads, queued ECOs can drain to Render, and an admin refresh probes task types. Polling and dispatch now share one gate of three slots, and the remaining GraphQL live path has its own two. The pair deliberately adds up to Render’s documented burst limit of five; a single credential-scoped limiter is still the cleaner end state.
The Background Cycle Is Linear in ECO Count
Each due ECO costs one request per cycle because Strike asks Render for that ECO’s exact label. Per-instance settings decide whether and when a connection is due, while the shared polling-package worker pool caps polling and dispatch-triggered task fetches at three. Fingerprints suppress unchanged events; they do not suppress requests, and they now hash an explicit task-state projection rather than the whole wire struct.
Dispatch Buys Immediacy Outside the Cycle
A newly dispatched ECO gets an immediate poll so a long interval does not leave its task list blank for minutes. That fetch respects the per-instance kill switch and now waits for the same worker slot used by the background poller. It deliberately does not move the instance-wide polling clock, because it refreshed one ECO rather than the whole connection.
UI Reads Are Replica-First
The ECO list no longer asks for Render tasks, and ECO detail reads the poller-maintained replica before it contacts Render. The remaining live detail path is for ECOs the replica cannot answer: never observed, outside the pollable set, or unreadable rows. Staleness now travels as asOf; stale means neither the replica nor live Render could provide current data.
Restart Replays the First Poll
The durable task cache survives a restart, but the service’s instance clocks, fingerprints, and initialization guards do not. Every instance therefore appears due when a process starts, producing one request per pollable ECO. That quota cost is small now but scales linearly. The old inactivity-clock reset has been fixed: fingerprint seeding no longer records task activity.
Rate Limiting Fails on the Wrong Timescale
The poller does not retry a refused request; the next cycle is the retry. A cycle stops sending to an instance once one full worker wave has been refused, and the outer backoff lengthens the next wait. The breaker now trips on a failure ratio as well as a streak, and a separate per-credential quota gate holds an hours-long cooldown so day-scale exhaustion is no longer probed every 30 seconds. GraphQL live fetches keep their retries because they have no next cycle.
The Cache Has Two Different Kinds of Value
Current task rows are replaceable, but their first_seen_at and removed_at history is not. render_tasks_cache is a poller-maintained local replica plus an audit trail—not a disposable event-sourced projection. Rebuilds must leave it alone, and a cascading foreign key could erase evidence that replay cannot restore.
The Honest Ceiling Is About Sixty-Nine ECOs
At one request per ECO per cycle, a 60-second interval supports roughly 6 ECOs per credential, 300 seconds supports 34, and the current 600-second default supports about 69. Per-project fetching with pagination is the change that breaks the linear relationship. Until then, intervals and kill switches are capacity controls, not tuning trivia.
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 later fixes that routed dispatch fetches through the shared polling limiter, moved all connections and missing-instance fallbacks to 600 seconds, made UI detail reads replica-first, corrected restart initialization, stopped the poller retrying 429s in-request, and gave quota exhaustion its own cooldown separate from the breaker. 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
Every connection now defaults to 600 seconds. Issue #1430 (landed as PR #1460) made that the create-time default, migrated existing rows, and raised the missing-instance fallback to the same value. A faster interval is still available as a deliberate per-connection override, but it inherits the arithmetic: twelve ECOs at 60 seconds consume 17,280 requests per day and exceed the quota.
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.
Five Paths Spend the Same Allowance
Five paths can contact Render. Three have meaningful ongoing volume; the queued-ECO drain is normally quiet but intentionally bypasses the polling kill switch, and the admin probe is small but still spends the same credential allowance.
Caller
Trigger
Cadence
Concurrency cap
Background poller
Timer
Per-instance interval
3 shared slots
Dispatch trigger
ECO becomes DISPATCHED
Once per dispatch event
Same 3 shared slots
GraphQL live fetch
Detail read whose replica cannot answer
Cold or unreadable detail read
2
Queued-ECO drain
ECO waits on Render dispatch
Per queued ECO, per-instance interval
None
Admin task-types probe
Operator clicks refresh
Two requests per click
None
The polling package now has three shared slots, and the GraphQL cap of two brings the known live-fetch total to Render’s documented burst limit of five. That is a cleaner split than the old shape because dispatch-triggered fetches no longer form a third, uncounted burst path: they queue behind the same semaphore as the background poller. The queued-ECO drain remains outside that concurrency cap and outside the per-instance polling kill switch because suppressing it would strand an emergency, but its volume is bounded by emergencies actually waiting on Render. Nothing coordinates all 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:
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. ADR-007 now records that the fingerprint hashes an explicit task-state projection, so adding a field to RenderTask also requires an explicit decision about whether that field should drive render.tasks.changed and polling TTL extension.
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 enabledECO 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 Are Replica-First
The list and detail screens now have different request behavior:
Surface
Normal source
Render fallback
ECO list
eco_views stored columns
None
ECO detail with rows
eco_render_tasks
None
ECO detail observed empty
eco_render_task_polls.last_polled_at
None
ECO detail never observed or unreadable
Replica cannot answer
Live exact-label fetch, capped at 2
The old list path stopped selecting Render-reaching fields, and renderTaskCount was removed from the list surface. The detail path reads eco_render_tasks first and reaches Render only when the replica cannot answer: a never-polled ECO, an ECO outside the pollable set, or an unreadable replica row. A genuinely task-free ECO no longer falls through to Render on every ten-second acceptance-window refetch because successful polls write an eco_render_task_polls observation marker.
That removes the UI as a steady-volume Render caller. The cost is freshness, now made explicit with RenderTaskResult.asOf. stale has narrowed to the failure case: the replica could not answer and the live fallback also failed. A populated or observed-empty replica reports stale: false however old it is, so the as-of cue is the age signal.
Two caveats remain. A TASKED ECO is poll-bounded while open because TASKED cache refreshes do not publish TASKS_CHANGED; #1604 tracks publishing when that cache write changes something. The tasks panel’s refresh button also re-reads the replica rather than forcing a live Render read. A thresholded demand refresh is still deferred until it can draw on a shared per-credential budget.
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 concurrentStart 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 older second cost has been removed: initialization used to stamp last_task_change_at, which could keep stale DISPATCHED ECOs alive across repeated deploys. It now seeds fingerprints without recording activity. The inactivity countdown is also anchored on COALESCE(last_task_change_at, render_integration_updated_at, created_at), so never-observed ECOs can age out. 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
Failure handling is layered, and the poller side is now tuned for the daily quota rather than only for short outages:
ServiceCircuit breakerRender APIrequestGET /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. With the current shared worker count, that wave is three requests, so a fully refused worker wave sets the instance aside and its remaining ECOs wait for the next cycle.
The breaker timescale has been fixed too (#1278, PR #1414). The breaker used to trip only on an unbroken run of failures, so a connection that refused some requests and served others never tripped, and its 30-second half-open probe would have spent a trial request every half minute against a quota that stays exhausted for hours. It now trips on either a run of more than five failures or a 50% failure ratio over at least five judged requests in its ten-second window. Rate-limit responses are kept out of the breaker entirely: a 429 means Render is working and declining, not that Render is broken. Quota exhaustion has its own per-credential gate instead. A 429 carrying a Retry-After of a minute or more arms a cooldown clamped between five minutes and six hours; because Render is not documented to send that header, a dense, unbroken run of refusals (at least five, spanning at least ten minutes) arms a flat one-hour cooldown instead. A request during a cooldown fails locally without reaching Render. What remains is expressing the concurrency cap per credential rather than per process, which is the limiter still open under #1268.
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 task-fetch kill switch, including dispatch trigger; does not stop queued-ECO drain
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_TICK
Whole process
Loop wake-up granularity; not a quota lever
DefaultInstancePollingInterval
Code constant
600-second fallback when an instance stores no interval
RENDER_POLLING_WORKERS
Whole process
Shared polling-package fetch concurrency; default 3, clamped to 3
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 Sixty-Nine ECOs
The current design is linear: one request per ECO per cycle. At ten minutes, one credential supports roughly 69 steady-state ECOs before the daily allowance is consumed. At five minutes it supports roughly 34, and at one minute it supports about six.
What remains open:
Fetch per Render project with pagination instead of once per ECO (#1270, deferred while Render lacks multi-value label_name filtering).
Publish TASKS_CHANGED for TASKED cache writes when the replica changes (#1604).
Coordinate every caller through one per-credential limiter so the cap can differ by environment (#1268).
A thresholded demand refresh for stale detail reads is recorded as deferred in the ADR-007 amendment and has no issue of its own.
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 69-ECO ceiling at the current 600-second default.
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
What staleness window can the ECO list and detail screen tolerate before a demand refresh is worth spending shared quota?
Should the admin UI show estimated daily requests from the current interval and pollable ECO count?
At what connection size does per-project pagination become required rather than planned?
References
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.
docs/decisions/ADR-007-render-task-cache.md in Osprey Strike — Defines the cache as a poller-maintained replica with non-replayable audit history.
Osprey Strike Render polling package — Implements the background loop, per-instance scheduling, fingerprints, cache persistence, and completion checks.
Osprey Strike Render integration package — Implements task creation and the immediate post-dispatch fetch path.