05 - Collection: auth, pagination, discovery, retries¶
Auth¶
All auth types implement one interface:
type Authenticator interface {
// Decorate adds credentials to the request, obtaining or refreshing tokens if needed.
Decorate(ctx context.Context, req *http.Request) error
// OnAuthFailure is called on a response classified as authFailed.
// It returns true if the request should be retried once after re-authentication.
OnAuthFailure(ctx context.Context, resp *http.Response) (retry bool, err error)
}
Token requests issued by authenticators go through the budget engine with authRequest: true.
Declarative types¶
| Type | Config | Notes |
|---|---|---|
none |
- | |
header |
name, value (secret or template) |
Static API key header, e.g. X-Cisco-Meraki-API-Key |
bearer |
token (secret) |
Authorization: Bearer <token> |
basic |
username, password |
|
oauth2_client_credentials |
tokenURL, clientId, clientSecret, scopes, audience, extraParams, authStyle (header or body) |
Token cached until expires_in - skew; persisted |
session_login |
see below | Generic login flow returning a token or cookie |
session_login¶
For APIs that require posting credentials to obtain a session token (e.g. Teltonika RutOS).
auth:
type: session_login
sessionLogin:
request:
method: POST
path: /api/login
body: # JSON body, values are CEL or secret refs
username: { secret: username }
password: { secret: password }
token: "response.body.data.token" # CEL, extracts the token
expiresIn: "has(response.body.data.expires) ? duration(string(response.body.data.expires) + 's') : duration('300s')"
apply:
header: Authorization
format: "Bearer {token}" # or apply.cookie: <name>
refreshBefore: 30s
perTarget: true # one session per target (multi-target accounts)
logout: { path: /api/logout } # optional, closes replaced sessions
On authFailed, the session is invalidated and the request retried once after a new login.
Plugins (Go)¶
Reserved for flows that cannot be expressed declaratively. Registered by name in internal/auth/plugins and selected with auth.type: plugin, auth.plugin.name: <name>. The plugin receives auth.plugin.config (free-form in the core schema) and validates it at compile time.
aruba_central_classic: Aruba Central (classic) uses OAuth access tokens with a limited lifetime and rotating refresh tokens (each refresh returns a new refresh token, the previous one becomes invalid). Requirements:
- Bootstrap from an initial access token and refresh token provided as secrets (generated in the Central UI), or from client credentials plus an initial refresh token.
- Refresh proactively before expiry; persist the new token pair in the state store before using it (crash-safety: losing a rotated refresh token means manual re-bootstrap).
- Serialize refreshes per account (never two concurrent refreshes).
- If the stored token pair is newer than the bootstrap secrets, the stored pair wins. A changed bootstrap secret (detected by hash) overrides the stored pair.
- Expose its refreshes in
tapyr_auth_token_requests_total{result}and a health condition when re-bootstrap is required (tapyr_auth_reauth_required). - Verified behavior: see Implementation notes below.
Request building¶
pathis a template with{var}placeholders resolved from variables (discovery vars, account params, target params). Values are URL-path-escaped.queryvalues are Values: literal strings, CEL expressions ({ expr: "..." }) or secret references ({ secret: name }, redacted everywhere).headersmerge provider defaults, template, then job (job wins).body(for POST-based query APIs such as GraphQL) is supported: JSON with CEL-evaluated values. Tapyr remains read-only: POST is allowed only for queries, declared explicitly withreadOnly: truein the template, and never retried unlessidempotent: true.
Redirects¶
The HTTP clients never follow a redirect by themselves: a redirect followed inside the client would be a request outside the budget engine, and it would carry the credentials to the host of the Location. The executor follows a redirect (3xx with a Location, 304 excepted) as a new request of its own: a reservation, the credentials of the account, and a request counted in the run. It does so only within the origin of the request (scheme, host and port), or to a host listed by the provider in redirects.hosts (a host name, or *. and a domain), never from https to another scheme, and at most 5 times in a row. 307 and 308 keep the method and the body; the other redirects become a GET without body. Any other redirect fails the request as a client error naming the host. Token and login requests and the push outputs do not follow redirects at all.
Pagination¶
Every pagination type supports maxPages (default 100, hard safety cap) and contributes each page as one budgeted request.
| Type | Config | Stop condition |
|---|---|---|
none |
- | single request |
offset |
offsetParam, limitParam, pageSize, start (default 0), stop (CEL, optional), total (CEL, optional) |
stop true, or offset >= total, or fewer items than pageSize |
page |
pageParam, sizeParam, pageSize, start (default 1), stop, total |
same logic with page numbers |
link_header |
pageSizeParam, pageSize, rel (default next) |
no rel=next link (RFC 8288). Links are followed only when they keep the scheme, host and port of the base URL, so credentials are never sent elsewhere |
cursor |
cursor (CEL extracting next cursor from response), param (query param name) or header, pageSizeParam, pageSize |
cursor empty or null |
expression |
next (CEL returning a map {query: {...}} or null; in a conditional, write dyn(null) so both branches type-check: more ? {'query': {'from': n}} : dyn(null)) |
next returns null |
The mapping receives all pages: responses (list) and response (the current page when evaluating per page). extract.items is evaluated per page and items are concatenated.
Discovery (fan-out)¶
- A template with
role: inventoryproduces inventory items: a key and a map of fields, cached in the state store with a TTL (default: 3x the job's effective interval). - A template or inline job with
forEachruns once per inventory item of its parent (forEach.templateor, for inline jobs,forEach.job), withparent.<field>available inforEach.varsCEL expressions. - Inventory jobs can also produce metrics (
role: both). - Fan-out instances are scheduled with spread: the N child instances of a job are distributed over the interval rather than fired simultaneously.
- Inventory changes (items added or removed) trigger a replan and the creation or removal of child instances. Series of removed instances become stale.
forEach.filter(CEL onparent) selects a subset of items (e.g. only networks with wireless product type).- The cardinality of fan-out is exposed (
tapyr_job_instances) and capped byforEach.maxInstances(default 10000) to protect against runaway discovery.
Implementation notes¶
- Job instances. A job runs as one instance per target (multi-target accounts) and per inventory item of its parent (fan-out). An instance is identified by the job name, the target and its variable values:
devices[target=north-01,orgId=42]. The identity never appears in self-metric labels. - Variables. The variables of a child instance are those of its parent instance, overridden by its own
forEach.vars, so a third level can use variables of the first (/orgs/{orgId}/networks/{networkId}/devices). Target params, then account params, fill the variables not set by discovery. - Implicit parents. A
forEach.templatethat no job of the same account instantiates runs as an implicit job named<account>.<template>, with the template defaults and prioritylow. - Inventory. A successful run of an inventory job replaces the items of its instance (key and fields, cached in the state store). Items not refreshed within the TTL are dropped. Children are recomputed after every parent run, then created or removed; removed instances have their series retired.
- Before the first parent run (or after a restart), children start from the cached inventory; without cache, they wait for the parent.
- Spread. The instances of a fan-out get a phase derived from a hash of their instance key, uniformly distributed over the interval. The phase of an instance is stable across restarts and does not move when other instances come or go (an even spacing in key order would shift every phase on each inventory change).
- Multi-target accounts. Target params override account params (base URL included), target secrets override account secrets, target labels are added between account and job labels, and CEL sees
target.name,target.labels,target.params. Circuit breakers andperTargetsessions are per target. - Token auth. Token and login requests go through the budget engine with
authRequest: true. Tokens are kept untilexpires_in(orexpiresIn) minusrefreshBefore(default 30s), persisted in the state store, encrypted with AES-GCM whenTAPYR_STATE_KEYis set (a warning is logged once otherwise). - Token sessions. The jobs of an account share one token: per credential and token URL for
oauth2_client_credentials, per account (or per target withperTarget) forsession_login, whose login request uses the base URL of the target that opens the session. A changed auth configuration opens a new session. Renewals are serialized: concurrent requests wait for one token request. A token is renewedrefreshBefore(30s for OAuth2) before it expires, or halfway through its lifetime when it is shorter; withoutexpires_inorexpiresIn, it lives 1 hour unless rejected. OnauthFailed, the token is dropped (from the store too) and the request retried once with a new one. - Order. A request obtains its credentials before its own budget reservation, so that a token request never waits behind a reservation held by the request it serves. Token requests are reserved as
criticalauth requests and count inmaxConcurrentRequests. Their errors name the endpoint and the status, never the response body (it may carry tokens). -
Stored tokens. A version byte, then the token (no
TAPYR_STATE_KEY) or the nonce and AES-GCM ciphertext under SHA-256(TAPYR_STATE_KEY), authenticated with the session key. A token that cannot be opened (another key, or stored before a key was set) is ignored and replaced. -
Logout.
sessionLogin.logout(optional) declares a request (method, defaultPOST, andpathunder the base URL) sent with the token applied, when the session replaces its token and when Tapyr stops. It goes through the budget engine as an auth request; its failures are logged at debug level and never fail a run. RutOS keeps sessions open after their token expires (its memory grows), hence this request. - Rejected credentials. A token or login request answered 400, 401 or 403 is a rejection: the session sends no other token or login request for 1 minute, then 2, 4... up to 1 hour, and the runs needing it fail at once with the rejection. A successful request, or a new session (changed secrets or auth configuration, after a reload), ends the backoff. A wrong password thus never hits the API, nor locks the account, at every run.
- TLS.
Account.spec.tlsapplies to every request of the account, auth requests included:caFileadds a PEM bundle to the system roots,insecureSkipVerifydisables verification and logs a warning at every load. Accounts with TLS settings get their own HTTP transport; the others share one. - Plugins. A plugin is Go code registered in
internal/auth/plugins(an explicit registry built bycmd/tapyr, noinitside effects). It validatesplugin.configat compile time, where{secret: <name>}values name account secrets, and creates an authenticator per account (or target) from: the base URL, the resolved secrets, a sender of auth requests through the budget engine, a sealed key-value store in the state store (encrypted withTAPYR_STATE_KEYlike tokens), the clock and a logger. It reports its token requests and its re-bootstrap condition, shown bytapyr_auth_reauth_required,/readyz(as a reason, without failing readiness) and the status API. A plugin also provides its simulator side (08-simulator.md). aruba_central_classic. Verified against the Aruba developer documentation (access token management, OAuth, and the officialpycentralSDK):- config:
clientId,clientSecret,refreshToken(required) andaccessToken(optional) as secret references,tokenPath(default/oauth2/token),refreshBefore(default 10m); - refresh:
POST <baseURL>/oauth2/token?client_id=...&client_secret=...&grant_type=refresh_token&refresh_token=...(parameters in the query, as documented), answeringaccess_token,refresh_token,token_type,expires_in(7200 seconds); the access token is applied asAuthorization: Bearer; - every refresh returns a new refresh token, valid 15 days; the previous one is treated as spent. The new pair is written to the store before it is used. A crash between the answer and the write loses the pair: the refresh is the only request in flight for the account, and the README documents the re-bootstrap;
- the stored pair records a hash of the bootstrap refresh token: a changed bootstrap secret (after a reload) replaces the stored pair, an unchanged one never does;
- a refresh answered
400or401sets the re-bootstrap condition: requests of the account fail without calling the API until the bootstrap secrets change; other failures are retried at the next request; - a request answered
401triggers one refresh (serialized per account) and one retry.
Error classification¶
| Condition | Class | Action |
|---|---|---|
Matches errors.rateLimited |
rate_limited |
Penalty via budget engine, retry after penalty if within run deadline |
Matches errors.authFailed |
auth |
OnAuthFailure, single retry if it returns true |
Matches errors.retryable, network error, timeout |
transient |
Retry with backoff |
| Other 4xx | client |
No retry, job run fails, logged with response excerpt (redacted) |
| Response decoding error | decode |
No retry, run fails |
| Budget refusal | budget |
No retry in this run, run marked skipped_budget |
Retry policy¶
- Exponential backoff with full jitter:
sleep = random(0, min(maxBackoff, initialBackoff * multiplier^attempt)). maxAttempts(default 4, including the first attempt).- Every retry goes through the budget engine as a retry (retry reserve).
- A run has a deadline (default: its effective interval, capped at 10 minutes). Retries never extend a run beyond its deadline.
- Only idempotent requests are retried (GET, HEAD, and POST explicitly marked idempotent).
- Retried classes:
transient(with backoff) andrate_limited(after the penalty set by the budget engine, which the next reservation waits for).authis retried once whenOnAuthFailurereturns true (never for static auth types).client,decodeandbudgetare not retried. - Every retry and every page of a run share the run deadline; a retry that cannot complete before it is not attempted.
- Partial pagination failure: the run fails as a whole by default (no partial data emitted), unless
extract.partial: true(then emitted samples are flagged with the self-metrictapyr_job_partial_runs_total).
Circuit breaker¶
Per account (per target for multi-target accounts):
- Opens after
failureThresholdconsecutivetransientfailures (default 5). - While open, requests are refused by the budget engine (
circuit_open), so no quota is wasted. - After
cooldown(default 2m, doubling up to 30m on repeated openings), half-open: one probe request (the next scheduled one). Success closes it; failure reopens it. - State exposed as
tapyr_circuit_state{account, target}(0 closed, 1 half-open, 2 open).
Run outcome¶
Each run records: start, end, duration, requests (by class), pages, retries, items, samples produced, result (success, partial, failed, skipped_budget, skipped_overlap, skipped_circuit), error summary (redacted). Runs are kept in the state store (last 50 per job instance) and exposed in the status API.