Skip to content
Console
Browse documentation
Guide

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 object null, a zero number 0 and 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 JavaScript Number or a float silently rounds above 2^53.
  • Enums are full names in answers, such as HEALTH_DEGRADED or PRODUCT_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 2xx body 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_time and effective_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:

  1. Send the same selectors (project, window, filters, sort, page size) on every page; change only cursor.
  2. Send next_cursor back exactly as received. It is opaque: do not parse it, build it or reuse it with other selectors.
  3. Stop when has_more is false or next_cursor is empty.
  4. Bound the number of pages, and stop with an "incomplete" result if a cursor repeats.
  5. 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-Key header and logs: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 with Last-Event-ID and the same filters, with exponential back-off bounded by Retry-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 error event; terminal: true means 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.json that 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 with 400.
  • 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-Policy and frame-ancestors policy.
  • 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 postMessage your 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.