Tracing & Observability¶
This recipe shows how to add structured observability to restgdf requests
using the resilience extra and Python’s standard logging / structlog.
Prerequisites¶
pip install "restgdf[resilience]"
Enable resilience + logging¶
import logging
from restgdf import Config, ResilienceConfig
logging.basicConfig(level=logging.DEBUG)
cfg = Config(
resilience=ResilienceConfig(
enabled=True,
rate_per_service_root_per_second=10.0,
),
)
The restgdf.resilience module logs every retry attempt, 429 cooldown, and
error mapping through the restgdf.retry logger at DEBUG level:
retry scheduled: attempt=N wait=…s caused_by=…before each retried attempt. Thewaitis the scheduled pre-jitter backoff — the actual sleep adds up toResilienceConfig.wait_jitter_s(default1.0s) on top, so a loggedwait=0.500scan be a 1.4 s sleep.429 cooldown set: key=… seconds=…whenever a 429 sets a cooldown deadline (thekeyis the service root, or the host whenResilienceConfig.limiter_key="host")retry exhausted: … mapped to <ExceptionType>when retries give up and the underlying failure is mapped to arestgdf.errorstype
Each record also carries structured extra fields — limit_key (the
rate-limit/cooldown key), retry_attempt, retry_delay_s, limiter_wait_s
and exception_type — for a structured-logging handler.
Retries cover the request up to headers received. A failure raised while
the response body is read (a truncated body, aiohttp.ClientPayloadError) is
outside the retry scope and is not logged here — it surfaces raw to the
caller.
Set the logger to DEBUG to see each event (the logging.basicConfig call
above already does this if the root logger is at DEBUG; use the line below
if you only want restgdf.retry at a finer level than the rest of your app):
logging.getLogger("restgdf.retry").setLevel(logging.DEBUG)
Tracing individual requests¶
The ResilientSession wrapper is transparent — you use it exactly like a
plain aiohttp.ClientSession, but retries and rate limits happen
automatically:
import asyncio
from aiohttp import ClientSession
from restgdf.resilience import ResilientSession
async def traced_query():
async with ClientSession() as raw:
session = ResilientSession(raw, cfg.resilience)
url = "https://maps1.vcgov.org/arcgis/rest/services/Beaches/MapServer/6/query"
params = {"where": "1=1", "f": "json", "resultRecordCount": "1"}
async with session.get(url, params=params) as resp:
data = await resp.json()
print(data)
asyncio.run(traced_query())
Inspecting error attributes¶
When the retry wrapper raises, the exception carries structured attributes populated automatically:
Exception |
Attributes |
|---|---|
|
|
|
|
|
|
|
|
Example:
from restgdf.errors import RateLimitError, RestgdfTimeoutError
async def query_with_error_detail(session, url, params):
try:
async with session.get(url, params=params) as resp:
return await resp.json()
except RateLimitError as exc:
print(f"429 at {exc.url}, retry after {exc.retry_after}s")
except RestgdfTimeoutError as exc:
print(f"Timeout ({exc.timeout_kind}) for {exc.url}")
Integrating with OpenTelemetry¶
For full distributed tracing with automatic span creation, install the
restgdf[telemetry] extra — see the Observability with OpenTelemetry recipe.
The example below shows how to add manual spans around resilience-wrapped
calls using the structured error attributes:
from opentelemetry import trace
tracer = trace.get_tracer("restgdf-app")
async def otel_query(session, url, params):
with tracer.start_as_current_span("arcgis.query", attributes={"url": url}) as span:
try:
async with session.get(url, params=params) as resp:
span.set_attribute("http.status_code", resp.status)
return await resp.json()
except Exception as exc:
span.set_status(trace.StatusCode.ERROR, str(exc))
for attr in ("url", "status_code", "timeout_kind", "retry_after"):
val = getattr(exc, attr, None)
if val is not None:
span.set_attribute(f"restgdf.{attr}", val)
raise
Monitoring 429 cooldowns¶
The CooldownRegistry and LimiterRegistry are per-ResilientSession
(not process-global), so each session has independent rate-limit state.
When a 429 response is received, the retry wrapper:
Parses the
Retry-Afterheader (integer seconds or RFC 7231 HTTP-date).Clamps the value to
ResilienceConfig.respect_retry_after_max_s(default 60 s).Sets a cooldown deadline keyed by
ResilienceConfig.limiter_key(default"service_root"— subsequent requests to the sameFeatureServer/MapServerwait until the deadline passes;"host"applies the same cooldown to every service on that host).Stamina’s exponential back-off then retries the request.
The cooldown is separate from the token-bucket limiter — a 429 does
not drain AsyncLimiter tokens.