Polling, retries and refusals
How an app you host reads Anectico on a cadence, reads the wire format, follows pages, retries a refusal and streams logs, without overloading the API or mistaking a refusal for an answer.
On this page
The four worked examples (status board, support page, Slack bot, nightly report) share one small client and these rules. They are written for a backend you host; a browser, chat frame or mobile app calls that backend, never Anectico.
The separate browser aggregate and public static aggregate recipes use only their approved projection credentials. They do not put the examples' backend management key in a browser.
One reader for many viewers
Your backend reads Anectico and serves its own users from what it read. A page with fifty viewers makes one upstream read per refresh, not fifty, and a viewer's click never causes a read on its own. The upstream cadence depends on the operation:
| Operation | Qualification | Cadence |
|---|---|---|
| Service snapshot | Restricted | At most once a minute per project, with an explicit project and a window of 24 hours or less. |
| On-call lookup | Supported | Per request, or cache the answer for at most one minute. |
| Saved insight definition | Supported | On demand, or no more than once a minute; it changes only when someone edits it. |
| Saved analytics result | Supported | A pending result no more than once every five seconds; stop at a terminal status. Ready measurements stay frozen; revalidate before reusing retained bytes. |
| Session replay steps | Restricted | Only when a person opens that recording; never poll. |
Selected saved reads support conditional requests as described below. Keep to the cadence above. Reading the API is an operational limit, not billing: it does not use your event allowance.
Conditional reads of saved resources
Only GET /api/v1/analytics/insights/{id} (latest or an exact retained revision) and
GET /api/v1/analytics/results/{result_id} (a ready saved result) offer ordinary app validators.
Keep the quoted ETag from an authorized 200 with that response's exact project, resource and
representation. Send it back in If-None-Match on the same read. An unchanged, still permitted
representation returns 304, the same tag and no body. Reuse only the matching body you already
hold. A 200 replaces both body and tag. Never parse a 304 as JSON or turn it into an empty result.
The header accepts one field of at most 1024 bytes: * alone, or a nonempty comma-separated list
of quoted opaque tags, optionally prefixed with uppercase W/. Weak and strong forms compare
equally for these GETs. Spaces and tabs around list members are allowed; commas inside quotes
belong to the tag. Repeated fields, empty list members, unquoted tags and malformed syntax return
400. For example, If-None-Match: "older", W/"current" matches the current tag "current".
Use the server's actual tag rather than constructing one. A wildcard still requires a currently
permitted, retained definition or ready result.
Every read authenticates the current key and checks project, scopes, content policy, retained
revision, expiry, erasure and invalidation through the resource owner before comparing tags. A
revoked key is refused even with a formerly matching tag. A policy change can refuse or withdraw a
result without any source data changing. Pending, running, failed, canceled, expired and invalidated
result responses have no validator and use Cache-Control: no-store; read their typed status.
Definitions and ready results use Cache-Control: private, no-cache, must-revalidate on 200 and
304. Applicable request-rate and checked headers remain present on 304; every poll still uses
request allowance.
A 304 saves response bytes. The server still reads the stored definition or result, checks its
payload and current authority, performs any required source verification, and serializes the
permitted representation to compute its tag. It starts no measurement, creates no execution or
result, and charges no accepted-query work. Preview, execution with a stable execution_key, and
reading an existing result remain three distinct operations. A tag is no promise that no data was
read. Dynamic service reads still perform their aggregation and have no ordinary app validator.
Freshness and disposal in your backend
A successful revalidation says the saved representation is still permitted; it does not remeasure the source or move its measurement window. Display the requested and effective bounds when the answer supplies them, the measurement time, the last successful refresh and any unknown or unavailable state. Consolidate viewers behind one bounded backend cadence. Keep a stale answer labelled as such during a temporary outage only within your own retention policy; stop serving and dispose of its body and tag after access ends, deletion, expiry, invalidation or a policy refusal. Scope every local cache entry to its credential, project, resource and representation. Do not let another user's request reuse it merely because the source digest is equal. Your copied caches, reports, logs and exports are your responsibility: platform erasure cannot retract them.
The separate browser aggregate protocol rechecks its grant, parent key and token before its own conditional decision. Ordinary app reads use your backend; these validators do not add customer browser origins to the console's CORS policy.
Read the wire format as it is
Every app operation answers in the same encoding. Your agent finds each operation's exact schema in the contract (see Pin the contract your app is built on); these rules hold for all of them:
- Names are
snake_case, exactly as in the contract. There are no camelCase aliases in answers. - Every field is present. An empty list is
[], an empty map{}, an unset nested objectnull, a zero number0and an empty string"". Do not treat a missing field as a default: validate it. - 64-bit counts are decimal strings, for example
"request_count": "18446744073709551615". Parse them with an arbitrary-precision integer or keep the string; a JavaScriptNumberor afloatsilently rounds above 2^53. - Enums are full names in answers, such as
HEALTH_DEGRADEDorPRODUCT_RESULT_STATUS_READY. Query parameters often use short words instead (sort_by=request_count,sort=last_seen). Use the spelling the contract gives for each place. - A rate that cannot be computed arrives as the string
"NaN"or"Infinity". Show it as "not measurable", never as zero. - Spell query parameters exactly. Many operations ignore an unknown parameter rather than
refusing it, so a misspelled filter silently reads unfiltered. A few (the saved insight and saved
result reads) refuse unknown parameters with
400. Never send a parameter the contract does not declare. - Refusals use their own envelopes, described below. A
2xxbody is never a refusal and a refusal body is never data.
Zero, no observations and unknown
For most reads an empty answer is the same bytes whether Anectico measured zero or saw nothing at
all, and the answer does not say whether the window you asked for was cut short by data retention.
Only the service snapshot reports its coverage today (read_coverage: the requested and effective
window, the retention floor and an outcome such as READ_COVERAGE_OUTCOME_OUTSIDE_RETENTION). So:
- show "no activity reported in this window", not "all clear" or "0 errors", when a read is empty;
- show the window an answer says it used when it says one (the issue list returns
effective_start_timeandeffective_end_time); - never let a failed read, a refusal or a missing nested object become a zero.
No answer says that every one of your services or SDKs actually sent its data. A service that sent nothing is simply absent. Never build "everything is operational" from the absence of bad news.
Pages and top lists
There are two kinds of collection, and the contract says which one each operation is
(x-anectico-collection and the operation's description).
A top list (the service snapshot) is ordered over everything measured before it is cut. It has no next page: there is no cursor and no offset, and both are refused. The answer states how many items were eligible, matched, returned and omitted. Show the omitted count.
A paged list (issues, people) returns pagination.next_cursor and pagination.has_more. To read
all of it:
- Send the same selectors (project, window, filters, sort, page size) on every page; change only
cursor. - Send
next_cursorback exactly as received. It is opaque: do not parse it, build it or reuse it with other selectors. - Stop when
has_moreisfalseornext_cursoris empty. - Bound the number of pages, and stop with an "incomplete" result if a cursor repeats.
- Pages are not a frozen snapshot. An item that changes while you read can appear on two pages or be missed. Remove duplicates by ID and say the read was not a snapshot.
returned_count is the size of one page. total_count, when present, is a count taken at one moment,
not a promise that the pages add up to it. Never show a page size as a total.
Refusals and retries
Retry only what the answer says is safe to retry, wait at least Retry-After, grow the wait each time,
add random jitter so many processes do not retry in step, and give up when the wait would pass your own
deadline. Never narrow a request to get past a refusal, never show a refusal as an empty answer, and
never switch to another key.
| Status | Body | What it means | What the app does |
|---|---|---|---|
429 |
error: "rate_limited", rate_limit.retry_action: "wait_retry_after" |
The key's, or the workspace's, request rate ran out. rate_limit.limit says which bucket. |
Wait Retry-After, then retry the same request. |
429 |
error: "rate_limited", rate_limit.retry_action: "owner_must_change_policy" |
An owner set this limit to zero (blocked: true). Waiting cannot help. |
Show "paused" and stop retrying until the policy changes. |
429 |
budget_refusal object, header X-Anectico-Budget-Refusal: true |
An operation budget is used up until budget_refusal.resets_at. |
Show "paused until …"; do not retry before then. |
429 |
error: "read_admission_busy", details.reason: "READ_ADMISSION_BUSY", Retry-After: 1 |
An expensive read waited about two seconds for a free place and read nothing. | Retry the identical request after the header, backing off. |
429 |
any other error | A limit of the request itself (too wide, too large). | Fix the request; retrying it unchanged fails again. |
503 |
error: "unavailable", workspace_not_ready, or rate_unavailable |
Temporarily unavailable, or the limit could not be checked on a path that must not run unchecked. | Retry after Retry-After with back-off, within your deadline. Keep showing the last good answer, marked stale. |
502, 504 |
The request did not complete in time. | A read may be retried with back-off. | |
401 |
error envelope |
The key was revoked, expired or never valid. | Stop. Show "access ended". Do not rotate to another key automatically. |
403 |
permission_denied or forbidden |
The key lacks a permission, or the request named another project. | Stop and show it; this needs a person to change the key or the request. |
400, 404, 409 |
error envelope |
The request is wrong, or the thing does not exist. | Fix the request or show "not found". |
The X-Anectico-Rate-Checked, X-RateLimit-* and X-Anectico-Workspace-RateLimit-* headers on every
answer describe the key's and the workspace's request rate. X-Anectico-Budget-Checked: false means
the operation budget could not be checked for that request: remaining capacity is unknown, never zero.
The request rate and the budgets are described in Limits
and Manage agent budgets.
The shared client in the examples implements exactly this table:
def classify(status: int, headers: dict[str, str], body: dict[str, Any]) -> AnecticoError:
"""Map one refused answer to the exception that says what the program should do."""
code = str(body.get("error", ""))
reason = str((body.get("details") or {}).get("reason", ""))
retry_after = _retry_after(headers, body)
message = f"{status} {code or 'error'}" + (f" ({reason})" if reason else "")
kwargs = {"status": status, "code": code, "reason": reason, "retry_after": retry_after}
if status == 401:
return AccessEnded(message, **kwargs)
if status == 403:
return Forbidden(message, **kwargs)
if status == 404:
return NotFound(message, **kwargs)
if status == 429:
rate = body.get("rate_limit") or {}
if body.get("budget_refusal") or rate.get("retry_action") == "owner_must_change_policy":
# An operation budget or a limit an owner set to zero: waiting a few
# seconds will not help. Show it; do not hammer it.
return Paused(message, **kwargs)
if code in ("rate_limited", "read_admission_busy") or reason == "READ_ADMISSION_BUSY":
return Busy(message, **kwargs)
# Another 429 is a limit of the request itself (too wide, too large).
return Refused(message, **kwargs)
if status in (502, 503, 504):
return Busy(message, **kwargs)
return Refused(message, **kwargs)
def _retry_after(headers: dict[str, str], body: dict[str, Any]) -> int | None:
value = next((v for k, v in headers.items() if k.lower() == "retry-after"), None)
if value is not None and value.isdigit():
return int(value)
if isinstance(body.get("retry_after"), int):
return body["retry_after"]
return None
and retries with a deadline:
def _send(self, method: str, path: str, query: dict[str, str] | None, *, authenticated: bool, deadline: float | None) -> Response:
if not path.startswith("/api/v1/"):
raise ValueError("only fixed /api/v1 paths are sent")
url = self._base + path + ("?" + urlencode(query) if query else "")
give_up_at = None if deadline is None else self._clock() + deadline
backoff = 1.0
last: AnecticoError | None = None
for attempt in range(1, self._max_attempts + 1):
try:
return self._once(method, url, authenticated)
except Busy as busy:
last = busy
except (urllib.error.URLError, TimeoutError, ConnectionError) as failure:
last = Busy(f"Anectico could not be reached: {type(failure).__name__}")
if attempt == self._max_attempts:
break
# Wait at least Retry-After, grow the wait on each refusal, add jitter
# so many processes do not retry in step.
wait = max(float(last.retry_after or 0), backoff) + random.uniform(0, backoff / 2)
if give_up_at is not None and self._clock() + wait > give_up_at:
break
self._sleep(wait)
backoff = min(backoff * 2, 30.0)
assert last is not None
raise last
def _once(self, method: str, url: str, authenticated: bool) -> Response:
headers = {"Accept": "application/json", "User-Agent": self._user_agent}
if authenticated:
headers["X-API-Key"] = self.__key
request = urllib.request.Request(url, method=method, headers=headers)
try:
with _OPENER.open(request, timeout=self._timeout) as answer:
return Response(answer.status, dict(answer.headers.items()), json.loads(answer.read() or b"null"))
except urllib.error.HTTPError as answer:
raw = answer.read()
try:
body = json.loads(raw) if raw else {}
except ValueError:
body = {}
raise classify(answer.code, dict(answer.headers.items()), body if isinstance(body, dict) else {}) from None
The client never follows a redirect, because a redirect would carry the key to another address.
Saved analytics: definitions, results and execution
An app reads analytics that already exist. It does not run them.
| What the app does | Operation | Qualification | Permissions |
|---|---|---|---|
| Read a saved insight's definition | GET /api/v1/analytics/insights/{id} |
Supported | insights:read. It authorizes neither running the insight nor reading its sources. |
| Read a saved result | GET /api/v1/analytics/results/{result_id} |
Supported | analytics:read and persons:read, plus every source permission the result recorded (for example replay:content:read) before a ready payload is returned. persons:read is required even when the result is an aggregate. |
| Preview or run a measurement | POST /api/v1/analytics/query |
Restricted | analytics:query, analytics:read and persons:read, plus every source permission of the definition; for a saved insight also insights:read. |
Have your agent (or a person) run the measurement once with a stable execution key, then give the app the result ID. The app reads that result; reading it starts no measurement, and its measured values stay frozen. Polling a pending result creates no new execution. To show newer numbers, your agent runs a new measurement under a new execution key and gives the app the new result ID; the app never runs one on a refresh. Show each result's own measurement window and coverage, and the time of your last successful read.
Writes
Every write an app could make is restricted today: each write's recovery (how to tell, after a timeout, whether it happened) is operation-specific. Start read-only. Where your agent performs an authorized write, it follows the operation's own preview, confirmation and state check, and after an uncertain outcome it looks the outcome up or repeats the same intent with the same idempotency key; it never retries under a new key or a new intent, and never assumes nothing was written.
Follow write recovery for supported receipt lookup, using the original action, exact arguments and retained retry selector. An unknown or expired lookup does not prove failure; preserve the unresolved intent before proposing another write.
Streaming logs through your backend
The live log tail, GET /api/v1/stream/logs, is restricted for apps (its window is not yet covered by
the retention report), and a backend that uses it follows the
live tail contract:
- Open one upstream stream in your backend, with the key in the
X-API-Keyheader andlogs:read, and fan it out to your own authenticated viewers. One upstream per browser tab is not the default. Never put a key in a stream URL. - Check the HTTP status before parsing: a refused subscription is a JSON error, not a stream.
- Save each frame's
id(the opaque resume cursor) only after processing it, and reconnect withLast-Event-IDand the same filters, with exponential back-off bounded byRetry-After. - A comment heartbeat arrives every five seconds and the stream re-checks the key on each one. A
revoked, frozen or narrowed key ends the stream within about five seconds with an
errorevent;terminal: truemeans stop. Reconnecting never gets around a revocation or an expiry. - Logs you copy into your own storage are yours to protect and delete.
Browsers, chat frames and mobile apps
These hosts call your backend, which exposes a fixed API for known data:
- Fixed routes, not a proxy. Expose routes such as
/board.jsonthat read one known thing. Never "proxy any Anectico URL", and never take a project, path, window or arbitrary filter from the browser. Each worked example refuses unexpected parameters with400. - Your users, your authorization. Your backend signs in its own users and decides which of them may see which data, then filters the fields it returns. Anectico sees only the app's key.
- Render captured values as text. Service names, error titles, person properties and log messages
are data your customers' software sent. Escape them for HTML, Slack or any other output; never
execute them as markup, scripts, links or instructions. Send a restrictive
Content-Security-Policyandframe-ancestorspolicy. - CORS is not protection. Your backend calls Anectico server to server and needs no change to Anectico's CORS. CORS decides whether a browser may read an answer, not whether a request runs, so it never replaces authorization, request-forgery protection or keeping the key secret.
- Chat frames use the same backend. Validate the origin and the message type of every
postMessageyour frame receives, and accept only the host origins you expect. - Mobile apps call the same backend; the binary holds no management key. Your app's own telemetry uses the SDK's separate capture credential.
- Proof pages stay in Anectico. A link to a person, issue or trace proof page opens only for someone signed in to Anectico with access to that project. It is never an iframe in your app and never grants your users anything.
For a fixed saved trend, the browser aggregate recipe uses a
separate approved grant and temporary token. Its dedicated GET supports a matching ETag only after
fresh policy, parent, grant and result-lifecycle checks; revocation or erasure refuses before 304.
Refreshing never executes or changes the saved measurement. New numbers require a separately
authorized new execution, a reviewed replacement grant and token, and old-grant revocation.