Changelog¶
All notable changes to restgdf are documented here. This project follows Semantic Versioning.
Unreleased¶
3.3.0 - 2026-07-24¶
Added¶
Rate-limit / cooldown granularity is now selectable.
ResilienceConfig.limiter_key(envRESTGDF_RESILIENCE_LIMITER_KEY) chooses whether the token bucket and 429 cooldown key on the ArcGIS service root ("service_root", the default — behaviour-preserving) or the host ("host"). Host granularity applies one polite rate across every service behind a single government host — the correct politeness key when many independent services share one box. The 429 cooldown always follows the same selected key, so a host-level throttle produces a host-wide cooldown (R1 / politeness decision D1). Combined with the sub-1.0 rate fix above, a politelimiter_key="host"+rate_per_service_root_per_second=0.5bulk crawl now works.Retry attempts, budget and backoff are now tunable on
ResilienceConfig. The stamina executor readsmax_attempts,retry_budget_s,wait_initial_s,wait_max_sandwait_jitter_s(envRESTGDF_RESILIENCE_MAX_ATTEMPTS,RESTGDF_RESILIENCE_RETRY_BUDGET_S,RESTGDF_RESILIENCE_WAIT_INITIAL_S,RESTGDF_RESILIENCE_WAIT_MAX_S,RESTGDF_RESILIENCE_WAIT_JITTER_S) from the config it already receives, instead of hardcoded literals. Defaults (5/60.0/0.5/10.0/1.0) preserve the historical retry policy byte-for-byte, andResilienceConfig.enabledremains the sole gate — the new knobs only tune an already-enabled session, they never turn retries off (R2). These live knobs supersede the inertRetryConfigfields (now deprecated).The resilient retry path now emits DEBUG observability logs. The
restgdf.retrylogger (previously created but never used) now records a DEBUG line for each scheduled retry (attempt number, backoff wait, and the failure that triggered it), each 429 cooldown set (limiter key — the service root, or the host underlimiter_key="host"— and seconds), and each exhaustion mapping (therestgdf.errorstype the final underlying failure is mapped to). Setlogging.getLogger("restgdf.retry")toDEBUGto trace throttling and retries during a bulk crawl — making thedocs/recipes/tracing.mdpromise true (H1-N4). The loggedwait=is the scheduled pre-jitter backoff; the actual sleep adds up towait_jitter_son top. These records carry a new structured-extrakey,limit_key(added torestgdf._logging.LOG_EXTRA_KEYS), holding the rate-limit / cooldown key — deliberately notservice_root, which would mislabel the value underlimiter_key="host".
Changed¶
Resilient retries now run through
stamina.retry_contextinstead of the@stamina.retrydecorator. The retry policy, backoff schedule and kwargs are identical (a fresh iterator per call, exactly as the decorator builds); the context form is what makes each attempt’s number and scheduled backoff available to the new DEBUG logging. One consumer-visible seam: subscribers tostamina.instrumentationnow seeRetryDetails.namereported as"<context block>"(stamina hardcodes it forretry_context) instead of the decorator-derived function name, so any metric keyed on that label — e.g. stamina’s Prometheus integration, which labels its retry counter with it — changes label value on upgrade.
Deprecated¶
RetryConfig.max_attempts/RetryConfig.max_delay_sandLimiterConfig.rate_per_hostare deprecated. They validate but have never been read by the resilience executor, and are now superseded by the liveResilienceConfigknobs added above:max_attempts→ResilienceConfig.max_attempts,max_delay_s→ResilienceConfig.retry_budget_s, andrate_per_host→ResilienceConfig.rate_per_service_root_per_secondwithResilienceConfig.limiter_key="host". TheirRESTGDF_RETRY_*/RESTGDF_LIMITER_*env keys stay wired as a back-compat seam and still emitInertConfigWarning(whose text now names the live replacements instead of claiming the executor hardcodes its policy). The deprecated fields and env keys are removed in 4.0.ResilienceConfig.backendis deprecated. It has always been dead config —"stamina"is the only implementation and the executor never reads the field. SettingRESTGDF_RESILIENCE_BACKENDnow emits aDeprecationWarning(previously it was silently accepted, the one inert resilience knob that escapedInertConfigWarning). The field stays reachable and defaults to"stamina"for back-compat; it is removed in 4.0 (R5).
Fixed¶
Sub-1.0 rate limits no longer crash. A
ResilienceConfig.rate_per_service_root_per_secondbelow1.0(e.g.0.5req/s — the natural setting for a polite bulk crawl) previously raised a raw, unmappedValueError(“Can’t acquire more than the maximum capacity”) on the very first request, because the limiter builtAsyncLimiter(rate, 1)andacquire(1)refuses any amount above a fractionalmax_rate. Sub-1 rates are now spelled as one token per1 / rateseconds (restgdf.resilience._limiter.LimiterRegistry.get), so the limiter paces at the requested rate. Rates>= 1keep their existing burst semantics exactly (H1-M1).Dispatch-time transport errors are now retried and mapped. The resilient retry path (
restgdf.resilience._retry) previously retried and typed only connect-time (ClientConnectorError→TransportError) and read-timeout (ServerTimeoutError→RestgdfTimeoutError) failures. Server disconnects and connection resets (ServerDisconnectedError,ClientOSError/ECONNRESET,ClientConnectionResetError) raised while the request is dispatched — common transient failures when crawling thousands of flaky ArcGIS hosts — leaked out raw and unretried on the first occurrence. They are now retried to exhaustion and surfaced asrestgdf.errors.TransportError, so a caller catchingTransportErrorno longer misses them. Deterministic 4xx responses still never retry (H1-M2). Known limitation (scope): the retry wrapper covers the request only up to headers received; callers read the response body afterwards. A failure raised while the body is read — the truncated/incomplete-bodyaiohttp.ClientPayloadError, or a mid-body disconnect — is therefore still neither retried nor mapped, and surfaces raw fromresponse.json(...). Callers who need to survive truncated bodies should catchaiohttp.ClientPayloadErroralongsiderestgdf.errors.TransportError. Extending retry across body consumption is a design item for a later release.429 cooldowns are no longer erased by a racing waiter.
CooldownRegistry.wait_if_cooling(restgdf.resilience._limiter) popped the stored deadline unconditionally after sleeping, so if a fresh 429 installed a longer cooldown while an older waiter was still asleep, the waking waiter erased the newer deadline — occasionally not honouring a cooldown at high crawl concurrency. It now re-reads the deadline after sleeping and leaves a concurrently-set fresher one in place for the next attempt/request to honour instead of clearing it (H1-N2). A singlewait_if_coolingcall sleeps at most until the deadline it observed on entry and never chains a second wait, so one attempt’s cooldown stays bounded byrespect_retry_after_max_showever many requests are in flight on the key — stamina evaluates the retry budget only between attempts, so an unbounded in-attempt sleep would be uninterruptible. Trade-off: the waking waiter itself proceeds, so one request per waker may dispatch while a concurrently-set fresher cooldown is still in force; the next attempt on the key waits it out.A single failing layer no longer discards a whole service’s metadata.
service_metadata(restgdf.utils.getinfo) fanned its per-layerget_metadatacalls out throughbounded_gatherwith the defaultreturn_exceptions=False, so one layer that raised — a secured / deleted / admin-disabled layer returning an ArcGIS{"error": ...}envelope (RestgdfError), a dropped connection (aiohttp.ClientError), or a timeout (TimeoutError) — cancelled its siblings and discarded the entire service’s layer inventory behind a single generic error (the exact failure mode at public multi-server crawl scale). Such a layer is now contained: its siblings are returned as before, and the failed layer stays present in the layer list annotated with alayer_errormarker ("<ExcType>: <message>") so the failure is visible rather than silently dropped. A service-root metadata failure is unchanged —safe_crawlstill records it as a whole-serviceCrawlError(H2-1). Monitoring note (behaviour change): a contained per-layer failure is not aCrawlError, so it no longer appears inCrawlReport.errorsat all — a service whose every layer failed now looks healthy if you only countlen(report.errors). Countlayer_errormarkers in the returned layer lists instead. Each contained failure also emits oneWARNINGon the newrestgdf.crawllogger ("layer metadata failed, contained: url=… error=…", withservice_root/layer_id/exception_typein the structured extra), so a crawl has an aggregate failure signal without inspecting the data. URLs in both the message and the extras are scrubbed oftoken=query values before logging. Containment is scoped to transport and response failures (RestgdfResponseError,TransportError,RestgdfTimeoutError,aiohttp.ClientConnectionError,aiohttp.ClientPayloadError,TimeoutError) — deliberately not theRestgdfError/aiohttp.ClientErrorroots, soConfigurationError,OptionalDependencyErrorandaiohttp.InvalidURLkeep failing loudly on the first service instead of degrading into thousands of identical markers.
3.2.0 - 2026-07-24¶
Added¶
FeatureLayer.from_config/Directory.from_config— opt-in classmethods that build a prepared instance from an explicitrestgdf.Config, applyingconfig.auth.tokenas the request token before delegating tofrom_url(W5-14 / CONFIG-02). They are deliberately explicit:configis a required argument, so nothing implicitly reads the process-globalget_config()at construction, and no globalConfigis threaded into the request path (CONFIG-03). The forwarded token rides the standardtoken=path (POST-forced by AUTH-01, never URL-serialized); for header-transport secured services prefer building anArcGISTokenSession.from_config(...)and passing it assession=.
Changed¶
Multi-page offset/count pagination now sends a deterministic
orderByFields. When a layer is traversed with explicitresultOffset/resultRecordCountpages (theget_gdf/get_df/stream_*common path), each batch now defaultsorderByFieldsto the layer’s resolved OID field unless the caller already supplied one — Esri’s documented remedy for reliableresultOffsetpaging, which otherwise leaves row order server-dependent and can silently duplicate or drop features across page boundaries (W4-2 / PAGINATION-02). A caller-suppliedorderByFields(any case) is never overridden, and a layer whose OID cannot be resolved degrades gracefully to today’s un-sorted batches. Wire-payload change (semver-relevant): explicit-pagination request bodies now carry anorderByFieldsmember. The OID-chunked WHERE-fallback andon_truncation='split'paths are unchanged.The GeoDataFrame path now raises on truncated responses instead of silently dropping rows.
get_gdfandstream_gdf_chunks(viachunk_generator→get_sub_gdf) now inspect each page’s parsed JSON forexceededTransferLimit=trueand raiserestgdf.errors.PaginationError(mirroring the raw-feature engine), rather than returning a GeoDataFrame that is silently missing rows when ArcGIS hits its byte/geometry transfer cap (W4-1 / PAGINATION-01, the audit’s flagship silent-data-loss fix). The flag is read from the response JSON directly because pyogrio/read_filediscards the top-level member. Behavior change: code that previously received a short-but-successful GeoDataFrame on a capped layer now gets aPaginationError; page the layer (smallerresultRecordCount, a tighterwhere, or theiter_pagesengine withon_truncation='split') to read it completely.The default
User-Agenton ArcGIS REST data requests is now sourced fromget_config().transport.user_agent(defaultrestgdf/<version>, e.g.restgdf/3.1.0) instead of the hardcoded"Mozilla/5.0"(restgdf.utils._http.default_headers, W2-10 / CONFIG-01 / AUTH-03). Wire-visible behavior change: some Esri deployments sniff theUser-Agent, so a WAF or usage policy keyed on"Mozilla/5.0"may now seerestgdf/<version>. Override it globally viaRESTGDF_TRANSPORT_USER_AGENT/TransportConfig.user_agent— the sole lever on the metadata/Directory/crawl path, which has noheaders=seam — or per request (headers={"User-Agent": ...}, still wins) on the feature/query surfaces (get_feature_count,get_object_ids, streaming/get_gdf/stats) that accept one (3.3 correction: this bullet originally implied the per-request override applies everywhere). The exportedDEFAULT_METADATA_HEADERSconstant keeps its historical value for back-compat but no longer determines the wireUser-Agent.Setting a currently-inert
RESTGDF_*knob now warns.Config.from_env(henceget_config()) emits onerestgdf._config.InertConfigWarning(aUserWarning) naming any set-but-unwiredRESTGDF_RETRY_*/RESTGDF_LIMITER_*/RESTGDF_AUTH_REFRESH_THRESHOLD_Svariable — those validate intoConfigbut no live path consumes them (the resilience executor hardcodes its retry/limiter policy; token sessions never readAuthConfig.refresh_threshold_s). The knobs and their env aliases stay wired for back-compat; real executor/session wiring is deferred (warn-now, wire-later; W2-13 / TRANSPORT-01 / AUTH-04). Silence withwarnings.filterwarnings("ignore", category=restgdf._config.InertConfigWarning).nested_count/FeatureLayer.get_nested_countnow require exactly two fields. Passing any other arity raises a clearValueErrorinstead of anIndexErrordeep in post-processing (one field) or leaving a redundant*_countcolumn with an incomplete sort (three or more) (W5-3 / API-04).Schema-drift log records now carry the originating service context (in the message and a
drift_contextextra field) so the first, non-deduped occurrence is attributable to which service drifted. The dedupe key stays the context-free(model, field, kind, type)4-tuple, preserving the anti-spam guarantee for many-layer servers (W5-13 / TELEMETRY-02).
Added¶
ArcGISTokenSession.from_config(session, credentials, config=...)andTokenSessionConfig.from_auth_config(auth_config, credentials)— opt-in factories that thread anAuthConfignamespace (itstransport,header_name,referer,token_url, andrefresh_leeway_s/clock_skew_srefresh knobs) onto a validated token session (W3-3 / W2-11, CONFIG-02 / AUTH-04). This is the only sanctioned wayAuthConfigreaches a session: it is strictly opt-in and read only at call time.ArcGISTokenSessionconstruction remains unchanged —__post_init__never reads the process-globalget_config(), so a plainArcGISTokenSession(session, credentials)keeps its dataclass defaults (e.g.token_refresh_threshold=60rather than the AuthConfig-derived150). Whenconfigis omitted,from_configreadsget_config().auth.
Fixed¶
A 4xx from
/generateTokenno longer escapes as a rawaiohttp.ClientResponseError.ArcGISTokenSession.update_tokennow maps a true-HTTP400/401/403credential rejection torestgdf.errors.InvalidCredentialsErrorand any other non-2xx torestgdf.errors.RestgdfResponseError, both under theRestgdfErrorumbrella and chaining the originating aiohttp error as__cause__(W2-2 / AUTH-02 / ERRTAX-01). The error is raised deterministically (not retried). The HTTP-200{"error": {...}}bad-credentials envelope path is unchanged (stillRestgdfResponseErrorvia the strictTokenResponsetier). TheInvalidCredentialsErrordocstring now describes the 4xx contract, andTokenRequiredErroris documented as reserved/not-raised (a 499 surfaces asAuthNotAttachedError, the single live 499 raise site).The token refresh retry filter no longer swallows deterministic auth errors. restgdf’s own exceptions co-inherit
OSErrorviaPermissionError(AuthenticationError→PermissionError→OSError), so a None-credentialsAuthenticationError(and the new W2-2InvalidCredentialsError) were being caught by theOSErrorretry bucket, retried three times, and re-raised as the wrong class (TokenRefreshFailedError). Anexcept RestgdfError: raiseguard now sits before the retryable-error handler, so deterministic restgdf errors propagate immediately with zero backoff, distinguished by real exception instance (MRO) rather than by class name. Genuine transientOSError/ConnectionError/asyncio.TimeoutErrorstill hit the retry ladder (W2-3 / ERRTAX-02).ArcGISTokenSessionreactive token refresh is now single-flight under concurrent498 Invalid Tokenresponses: it snapshots the token before the request and, inside the refresh lock, only re-mints when the token is still unchanged. Previously N concurrent 498s issued N/generateTokencalls; now they collapse onto one, and the later requests retry with the freshly minted token (W2-4 / ASYNC-01).A referer-bound
ArcGISTokenSession(built fromAGOLUserPass(referer=...)/TokenSessionConfig.referer) now attaches a matchingRefererHTTP header to its data requests, not only to the/generateTokenmint — so aclient="referer"token is honoured end-to-end instead of being rejected (498/499) on the query. Aclient="requestip"(non-referer) session attaches noRefererheader (no referer leak). Closes the “planned follow-up” limitation noted in MIGRATION.md for the 3.1 referer feature (#175 review NOTE-1).ArcGISTokenSessionnow forwards its ownverify_sslflag to token-attached data requests (not just the/generateTokenPOST), viasetdefault("ssl", self.verify_ssl)in_call_with_auth_retry— so a session built withverify_ssl=False(self-signed ArcGIS Enterprise) no longer fails TLS verification on the actual query/metadata requests. A caller-suppliedssl=still wins (W2-10 / CONFIG-01 / AUTH-03).The library-owned session that
get_gdfbuilds when called withsession=Noneis now constructed with aTCPConnectorwhosesslpolicy comes fromget_config().transport.verify_ssl(defaultTrue), soRESTGDF_TRANSPORT_VERIFY_SSL=false/TransportConfig(verify_ssl=False)is finally honored on the flagship GeoDataFrame data path — previously the bareClientSession()used the default connector and silently kept TLS verification on. A caller-supplied session is passed through untouched (its own connector owns its TLS policy). This completes the three-seam verify_ssl wiring (config source W3-1, token/_httpW2-10, getgdf connector W4-5 / CONFIG-01 / AUTH-03).FeatureLayer.get_gdf/get_unique_values/get_value_counts/get_nested_countnow return an independent copy of the cached frame/list on every call (W5-1, ASYNC-02) instead of the shared cached object by reference. Previously, mutating a returnedGeoDataFrameorDataFramein place (e.g.df.rename(..., inplace=True)) silently corrupted what every later call on the same instance returned. Cache population is unaffected and remains non-atomic under concurrentasyncio.gatherawaiters — this fix protects reads only, not writes.get_fields(types=True),_field_rows, andget_fields_frameno longer raiseKeyError/AttributeErroron a permissive-tier field entry missingnameand/ortype(W5-4, ADAPTERS-01) — a real ArcGIS server can emit such entries andFieldSpecalready declares both as optional. A field with no resolvablenameis now silently dropped (documented in the affected docstrings); a missing/Nonetypedefaults to""instead of crashing onNone.replace(...).get_fields(types=False)gained the same nameless-field guard.The
_SpanContextFilterlog-correlation filter now always stampsrecord.trace_id/record.span_id(defaulting to""outside a valid OpenTelemetry span) instead of leaving them unset (W5-12, TELEMETRY-01). Previously, anyrestgdf.*log record emitted outside afeature_layer.streamspan — e.g. theauth.refresh.startDEBUG record or the paginationexceededTransferLimitwarning — had notrace_id/span_idattributes at all, so the documented%(trace_id)slog-correlation recipe raisedValueError: Formatting field not found in record: 'trace_id'(surfaced by stdlibloggingas a swallowed stderr “— Logging error —” dump).span_context_fields()is unaffected and still returns{}outside a span.restgdf.utils.token._auth_logger(therestgdf.authlogger backing token-refresh debug logging) is now created through the library’sget_logger("auth")factory instead of a rawlogging.getLogger(...)call, so it carries the documentedNullHandlerlike every otherrestgdf.*logger (it previously silently lacked one).restgdf.resilience._errors._parse_retry_afternow rejects non-finiteRetry-Afterheader values ("nan","inf","-inf","Infinity", etc.) instead of returning them as a poisonedfloat. Previously aNaN/+Infvalue passed the existing negative-value guard unmolested and could reach the 429 cooldown deadline computation and the publicRateLimitError.retry_afterattribute.The optional-dependency gate (
require_pandas/require_geopandas/require_pyogrioand friends) now catchesImportErrorinstead of onlyModuleNotFoundError, so a present-but-broken geo dependency (e.g. a native GDAL/shapely load failure) surfaces asOptionalDependencyErrornaming therestgdf[geo]hint instead of escaping as a raw, unwrappedImportError.Removed the false
.envfile-loading claims fromdocs/configuration.rst’s precedence list anddocs/authentication.rst’s credentials recipe.restgdfhas never read.envfiles or depended onpython-dotenv/pydantic-settings—Config.from_env()resolves only from the process environment (os.environ). The docs now show how to opt in explicitly withpython-dotenvyourself (W3-5, CONFIG-04).FeatureLayer.get_value_counts/get_nested_countnow send a conservative statistics body. The instance requestdata(carryingreturnGeometry=True/outFields="*"/returnCountOnly=False) previously clobbered the stats-only flags, so the server received a geometry+all-fields query instead of the grouped statistics one. The bodies now forward onlywhere+tokenfrom the caller data (matchingget_unique_values), so the stats flags win while the instancewherefilter is preserved (W5-2 / API-01).resolve_domains(used byFeatureLayer.get_df(resolve_domains=True)) is now robust to malformed-but-real domain metadata: a non-dictdomainis skipped instead of raisingAttributeError, and coded values are mapped only when they carry bothcodeandname. A name-less code passes through unchanged rather than being silently replaced withNaN(W5-6 / ADAPTERS-03).iter_pages/stream_*’son_truncation='split'path no longer re-fetches the OID list at every recursion node (each bisected half now reuses the parent’s already-materialized slice) and no longer emits an unboundedIN (...)literal for a single oversized half — a half exceeding a new 1000-element cap (the common ArcGIS backing-store IN-predicate limit) is bisected further before being fetched, instead of being sent as one arbitrarily large literal list (W4-3, PAGINATION-03). No caller-visible contract change (same pages yielded, sameon_truncationsemantics).
3.1.0 - 2026-07-24¶
Added¶
restgdf.utils.getinfo.build_spatial_filter_payload(geometry, *, in_sr=None, spatial_rel="esriSpatialRelIntersects")— pure helper that converts an ArcGIS-JSON geometry, a GeoJSON-style mapping, or any object exposing__geo_interface__(e.g. shapely geometries) into the ArcGIS RESTgeometry/geometryType/spatialRel(+ optionalinSR) query-payload fragment. Handles points, multipoints, polylines, polygons, envelopes, curve paths / curve rings, and 3D/ZM coordinates, and stampshasZ/hasMon array-based geometries when the coordinates carry Z/M ordinates. Re-exported throughgetinfo.__all__following thebuild_pagination_planpattern (not a top-levelrestgdfexport).
Fixed¶
Static type-checkers can now resolve
restgdf.FieldDoesNotExistError. It was already exported at runtime (__all__and the lazy-export table), but missing from theTYPE_CHECKINGimport block that backs static analysis, somypy/pyrightreported an unresolved attribute even though the name worked fine at runtime.
Changed¶
The
mypytype gate now runs against real dependency types. A dedicated CI job installs every extra plusmypyand enables thepydantic.mypyplugin (via[tool.mypy] plugins), so it type-checks against actualaiohttp/pydantictypes instead of reporting green over unresolved imports;pandas/geopandasare scope-silenced withignore_missing_imports(their integration boundary is intentionally untyped). The internal type errors the un-defanged gate surfaced were fixed — drift alias-choice narrowing, the metadata field-row annotation, bounded-retry exception typing, and wideningget_gdf’ssessionparameter to theAsyncHTTPSessiontransport protocol (a runtime contract thataiohttp.ClientSessionsatisfies at runtime but, under current aiohttp stubs, not as a static subtype).Raised the supported Python floor to 3.11 (3.9 is EOL 2025-10-31; 3.10 reaches EOL 2026-10-31); CI now tests 3.11–3.14.
AGOLUserPass(referer=...)is now honoured at token-mint time.ArcGISTokenSession.__post_init__threads the credential’srefererinto the auto-builtTokenSessionConfig, sotoken_request_payloadswitches the ArcGISclientfield from"requestip"to"referer"and adds"referer": <url>to the/generateTokenPOST body. Previously this was silently ignored — the credentials-only constructor built its config without a referer, soAGOLUserPass(..., referer=...)had no effect on the minted token. SeeMIGRATION.mdfor the request-timeReferer-header limitation this interacts with.Token-mint requests now send an explicit
expirationfield.token_request_payloademitsAGOLUserPass.expiration(default 60 minutes) and the deprecated synchronousget_tokenhelper sendsexpiration=60. Behaviourally equivalent to the ArcGIS server-side default of 60 minutes; the value is now explicit on the wire.The
devextra now composesrestgdf[doc]and addsbuild+twine, sopip install -e ".[dev]"alone is enough to run every gate inCONTRIBUTING.md’s gate suite (docs build, packaging metadata sanity) without installingdocseparately.
Security¶
AUTH-01: a caller-supplied token in the request body is no longer serialized into the URL query string. On the documented
FeatureLayer(token=...)/data={"token": ...}path, a short token-bearing request was routed viaGET, leaking the ArcGIS token into the URL — where it lands in server / proxy / WAF access logs andRefererheaders — on a plainaiohttp.ClientSessionor a default header-modeArcGISTokenSession._arcgis_requestnow forcesPOST(token in the request body) whenever the outgoing body carries atokenkey, regardless of session transport. This complements the existing session-transport guard; ArcGIS/queryand metadata roots already accept form-encodedPOSTbodies, so the wire contract is unchanged for servers. Tokenless requests keep the length-basedGET/POSTrouting.
3.0.0 - 2026-05-02¶
restgdf 3.0.0 is a major, backwards-incompatible rewrite. The package
splits into a light async core (aiohttp + pydantic v2) plus three
opt-in extras — restgdf[geo] (GeoPandas/pandas/pyogrio),
restgdf[resilience] (ResilientSession/ResilienceConfig retry and
rate-limiting via stamina/aiolimiter), and restgdf[telemetry]
(RestgdfInstrumentor OpenTelemetry instrumentation) — so a base
install stays dependency-light. A typed streaming surface
(iter_pages, stream_features, stream_feature_batches,
stream_rows, stream_gdf_chunks) replaces the legacy row/feature
helpers, with query-verb selection centralized in _choose_verb/
_arcgis_request (restgdf/utils/_http.py). restgdf.Config adds
layered, frozen pydantic sub-configs resolved from
RESTGDF_<CATEGORY>_<FIELD> env vars, and restgdf.errors grows a
wider exception taxonomy including a five-member AuthenticationError
hierarchy. See MIGRATION.md for the full breaking-change list; the
detailed change set below was carried forward from the pre-release
tranche.
Changed¶
Gate-3 hardening follow-up. Three review-driven safety fixes land on top of the v3-followup tranche:
ArcGIS requests routed through
_choose_verbnow forcePOSTwhenever the effective session transport is"body"or"query"(including wrappedResilientSession(ArcGISTokenSession(...))stacks), preventing auth tokens from leaking into URL query strings on short requests.restgdf.resilience._retry._RetriedCtxnow mirrorsaiohttp’s dual request-manager shape soawait session.get(...)andasync with session.get(...)both work againstResilientSession.getgdf._advertised_max_record_count_factor()now rejectsbool,NaN, and infinity inputs so malformed vendor metadata falls back to the byte-identical pre-T9 path.Module-level
get_gdf(..., session=None)now closes the temporaryaiohttp.ClientSessionit creates internally, eliminating the unclosed-session leak on direct helper usage._iter_pages_raw(..., max_concurrent_pages=N)now keeps at mostNfetch tasks scheduled at once instead of pre-creating one task per page and only bounding execution with a semaphore, preventing pagination plans with many batches from exploding task memory.Legacy streaming helpers (
_feature_batch_generator,chunk_generator) now honor the repository-wide concurrency cap while they stream results and cancel outstanding work on early generator close, preventing abandoned page-fetch tasks from accumulating behind partially-consumed iterators.Hypothesis-backed property tests now live behind a dedicated
pytest --run-stressopt-in so the default suite remains a representative production-validation pass instead of mixing in a separate stress tier by default.
Pagination planner wiring. When an ArcGIS layer advertises
advancedQueryCapabilities.maxRecordCountFactor,get_query_data_batchesnow forwards that value tobuild_pagination_plan(advertised_factor=...). The wiring is strictly opt-in: servers that do not expose the field (or expose a non-positive / non-numeric value) get the previous byte-exact plan with noadvertised_factorkwarg. This replaces the deferred-plumbing stub.Feature-count retry delegation.
restgdf.utils.getinfo._feature_count_with_timeoutnow delegates its bounded timeout-retry loop torestgdf.resilience.bounded_retry_timeout(a new public helper) when theresilienceextra is installed, giving restgdf a single stamina-backed source of truth for retry semantics. When the extra is absent, the previous inline loop is preserved byte-for-byte as a fallback. The retryable exception set (asyncio.TimeoutError,TimeoutError,aiohttp.ServerTimeoutError) and R-69 (ClientConnectionErrorpropagates without retry) are preserved on both paths.GET/POST verb selection wiring. ArcGIS query requests now route through a single
_arcgis_requesthelper inrestgdf/utils/_http.pythat consults_choose_verb(8,192-byte threshold on URL + urlencoded body). Previously every call site was hard-codedPOST. Nine call sites acrossutils/getgdf.py,utils/getinfo.py, andutils/_query.pywere migrated. The GET path coercesbool/Nonevalues inparamsto"true"/"false"/""so yarl can serialize them; POST payloads are untouched. Zero behavior change for bodies above the threshold.Transport typing.
ArcGISTokenSessionnow exposesclose()andclosedthat delegate to its inneraiohttp.ClientSession, making it fully satisfy therestgdf._client._protocols.AsyncHTTPSessionProtocol. Internal call sites previously typedaiohttp.ClientSession | ArcGISTokenSessionwere widened toAsyncHTTPSessionacrossadapters/stream.py,directory/directory.py,featurelayer/featurelayer.py,utils/crawl.py,utils/getgdf.py,utils/getinfo.py,utils/_query.py, andutils/_stats.py. Zero runtime behavior change — widening to a superset Protocol is backwards-compatible for existing callers.
Added¶
Pagination¶
restgdf.errors.PaginationInconsistencyWarning— newUserWarningsubclass emitted by_resolve_pagewhen a batch page returns zero features but the server still setsexceededTransferLimit=true. The warning fires regardless of theon_truncationmode ("raise","ignore", or"split") so pathological server responses are always surfaced. Deliberately not included inrestgdf.errors.__all__or the top-level public API — warnings live outside theRestgdfErrortaxonomy; import viafrom restgdf.errors import PaginationInconsistencyWarning.
Domain resolution¶
FeatureLayer.get_df(resolve_domains=False)— new kwarg on the pandas-first tabular accessor. WhenTrue, coded-value domain fields are replaced in-place with their human-readable names using a single cached pass over the layer’s metadata (no per-row HTTP). Defaults toFalseso the base code path is byte-identical for existing callers.restgdf.adapters.pandas.resolve_domains(df, fields)— public helper exposing the same resolution logic for callers already holding apandas.DataFrame. Requires thegeoextra (pandas is part of that install surface).
Resilience¶
restgdf.resilience.bounded_retry_timeout— new public helper exposing a stamina-backed bounded retry loop for timeout-class exceptions. Used internally by_feature_count_with_timeoutwhen theresilienceextra is installed; safe for consumers on the same extra. The retryable exception set matches restgdf’s internal timeout policy (asyncio.TimeoutError,TimeoutError,aiohttp.ServerTimeoutError);aiohttp.ClientConnectionErrorpropagates immediately (R-69 preserved).
Streaming¶
FeatureLayer.iter_pages— low-level async generator yielding raw ArcGIS query-page envelopes withorder("request"default /"completion"),max_concurrent_pages(optional semaphore bound), andon_truncation("raise"default /"ignore"/"split"). The"split"strategy bisects the predicate’s OID list viaget_object_idsand recurses up to depth 32 before raising. Truncated pages under"ignore"log a structured warning on therestgdf.paginationlogger and continue.FeatureLayer.iter_features/FeatureLayer.stream_features— flatteniter_pagesinto individual feature dicts. Deliberate aliases:stream_featuresis the canonical public entrypoint,iter_featuresthe lower-level primitive.FeatureLayer.stream_feature_batches— yields onelist[feature_dict]per page, mirroringiter_pagesboundaries.FeatureLayer.stream_rows— yields row-shaped dicts (attributesmerged with rawgeometry). Pandas/GeoPandas-free; safe on a base install.FeatureLayer.stream_gdf_chunks— yieldsGeoDataFramechunks over the optional geo stack; each chunk inheritsattrs["spatial_reference"].iter_pagesnow emits exactly onefeature_layer.streamINTERNAL parent span wrapping the per-page loop when telemetry is enabled; no restgdf-owned per-page spans are emitted. No-op whenRESTGDF_TELEMETRY_ENABLEDis unset. Constructed insiderestgdf.utils.getgdf._iter_pages_raw.Spatial-reference propagation:
restgdf.utils.getgdf.get_gdf,FeatureLayer.get_gdf,FeatureLayer.sample_gdf,FeatureLayer.head_gdf, andchunk_generator/FeatureLayer.stream_gdf_chunksall stampgdf.attrs["spatial_reference"]with the raw dict from the layer’s metadata envelope (extent.spatialReferencepreferred, top-levelspatialReferencefallback). Normalization usesrestgdf.utils._metadata.normalize_spatial_reference.
Adapters and tabular output¶
restgdf.adapterssubpackage (lazy-loaded via PEP 562): four submodules covering dict / stream / pandas / geopandas shapes. All submodules are base-install safe at import time; pandas and geopandas are required only at call time and raiseOptionalDependencyErrorwhen missing.restgdf.adapters.dict—feature_to_row,features_to_rows, plusas_dict/as_json_dictre-exports.restgdf.adapters.stream—iter_feature_batches,iter_rows,iter_gdf_chunks.restgdf.adapters.pandas—rows_to_dataframe(sync) +arows_to_dataframe(async).restgdf.adapters.geopandas—rows_to_geodataframe+arows_to_geodataframe.
FeatureLayer.get_df()— async pandas-first tabular accessor. Sibling toget_gdf()that returns apandas.DataFramefrom the same row stream and does not require the geo extra.
Configuration¶
restgdf.Config— frozen pydantic 2.x aggregate of eight frozen sub-configs (TransportConfig,TimeoutConfig,RetryConfig,LimiterConfig,ConcurrencyConfig,AuthConfig,TelemetryConfig,ResilienceConfig) plusConfig.from_env(env=None)classmethod. Sub-configs and the aggregate are immutable at both slot and nested-field level.restgdf.get_config()— process-wide cachedConfigaccessor (functools.lru_cache(maxsize=1)).restgdf.reset_config_cache()— clears the cache; cascades bidirectionally with the existingreset_settings_cacheso tests can refresh all configuration with a single call regardless of which accessor they use.Nested env-var surface
RESTGDF_<CATEGORY>_<FIELD>wired throughConfig.from_envfor every sub-config field (RESTGDF_TRANSPORT_USER_AGENT,RESTGDF_TIMEOUT_TOTAL_S,RESTGDF_RETRY_ENABLED,RESTGDF_LIMITER_RATE_PER_HOST,RESTGDF_CONCURRENCY_MAX_CONCURRENT_REQUESTS,RESTGDF_AUTH_TOKEN_URL,RESTGDF_TELEMETRY_LOG_LEVEL, …). Invalid coercions and validator rejections raiseRestgdfResponseErrorwith the underlyingpydantic.ValidationErrorpreserved as__cause__.Settings.max_concurrent_requests: int = 8(field) +RESTGDF_MAX_CONCURRENT_REQUESTSenv-var coercion (BL-01). Default matches aiohttpTCPConnectorpool size.
Errors¶
restgdf.errorsmodule exposing the canonical exception taxonomy:RestgdfError,ConfigurationError,OptionalDependencyError,TransportError,RestgdfTimeoutError,RateLimitError,ArcGISServiceError,PaginationError,FieldDoesNotExistError,SchemaValidationError,AuthenticationError, andOutputConversionError. All re-exported from the top-levelrestgdfpackage via the lazy-import hook.PaginationError.batch_index/.page_sizeattributes carry pagination context when cursor-based iteration fails.RateLimitError.retry_afterattribute carries the optional seconds-until-retry hint, populated from the server’sRetry-Afterheader (integer seconds or RFC 7231 HTTP-date) by the resilience wrapper (Q-A12). New helper_parse_retry_afterinrestgdf.resilience._errors.Error-attribute population:
RestgdfResponseErrornow carries optionalurl,status_code, andrequest_idattributes (kw-only, defaultNone).TransportErrorgainsurlandstatus_code.RestgdfTimeoutErrorgainstimeout_kind("total","connect","read").RateLimitErrorgainsurlandstatus_codealongside existingretry_after. All new attrs are backward-compatible — existing call sites that omit them getNonedefaults.Five
AuthenticationErrorsubclasses —InvalidCredentialsError,TokenExpiredError,TokenRequiredError,TokenRefreshFailedError,AuthNotAttachedError. All carry.context,.attempt,.causeattributes withSecretStrauto-redaction.
Auth runtime¶
TokenSessionConfig.refresh_leeway_seconds(default60) +TokenSessionConfig.clock_skew_seconds(default30) — explicit integer fields (ge=0) replacing the implicit semantics of the previous singlerefresh_threshold_secondsknob (BL-04).ArcGISTokenSession.expires_at— tz-aware UTCdatetimeproperty computed from the epoch-msexpiresfield._utc_now()shim for deterministic wall-clock test control.Structured
auth.refresh.start/.success/.failurelog events at DEBUG level on therestgdf.authlogger.Bounded
/generateTokenretry — transient errors retried up to 3× with exponential backoff; deterministic errors propagate immediately. After exhaustion raisesTokenRefreshFailedError.Referer binding —
token_request_payloadhonoursconfig.refererand switches ArcGISclientto"referer"when set.
Normalization¶
restgdf._models.responses.NormalizedGeometry,NormalizedFeature, anditer_normalized_features(response, *, oid_field=None, sr=None)— typed intermediate models plus iterator overFeaturesResponse.features. Wire-levelfeatures: list[dict]stays for perf; normalization is opt-in. Geometrytypeis heuristically inferred from shape;object_idisint-coerced fromattributes[oid_field].restgdf._models.responses.AdvancedQueryCapabilities— typedPermissiveModelcompanion for the ArcGISadvancedQueryCapabilitiessub-object, with camelCase / snake_caseAliasChoiceswiring and permissiveextra="allow"preservation of unknown keys (BL-21).LayerMetadata.advanced_query_capabilities_typed: AdvancedQueryCapabilities | None— additive typed companion to the existing rawadvanced_query_capabilities: dict | Nonefield. Caller-opt-in; the raw dict stays the default representation.restgdf.utils._metadata.normalize_spatial_reference(sr)— pure helper returning(epsg_int | None, raw_dict | None)that preferslatestWkidoverwkidfor EPSG-consuming clients (R-28).concat_gdfspropagatesGeoDataFrame.attrs["spatial_reference"]across concatenation.restgdf.utils._metadata.normalize_date_fields(features, fields)— converts ArcGISesriFieldTypeDateepoch-ms integers to ISO-8601 UTC strings. Opt-in vianormalize_dates=Trueon the adapter layer.
Pagination¶
restgdf.utils._pagination.PaginationPlan(frozen dataclass) +build_pagination_plan(total_records, max_record_count, *, factor=1.0, advertised_factor=None)— pure-math pagination planner re-exported viarestgdf.utils.getinfo. Emits(resultOffset, resultRecordCount)tuples byte-identical to the previous inline arithmetic inget_query_data_batches; clampsfactor > advertised_factorwith a warning viaget_logger("pagination").get_query_data_batchesis rerouted through the planner with no public-signature change and all pinned fixtures preserved.
Observability¶
restgdf._logging.get_logger(suffix)library-wide logger factory andbuild_log_extrastandardextra=envelope helper. Existingget_drift_logger/restgdf.schema_driftcontract unchanged.restgdf[telemetry]optional extra —RestgdfInstrumentor(dynamic subclass ofAioHttpClientInstrumentor, R-58),feature_layer_stream_spanasync context manager (INTERNAL span, R-21),span_context_fieldshelper, and_SpanContextFilterauto-attached to therestgdfroot logger for trace/span log correlation.docs/recipes/tracing.md— structured observability, error-attribute inspection, and OpenTelemetry integration.docs/recipes/streaming.md— the three streaming shapes,on_truncationoptions,ordervariants, andmax_concurrent_pagesknob.
Resilience¶
restgdf.ResilienceConfig— frozen pydantic sub-config controlling the stamina-based retry wrapper and per-service-root token-bucket rate limiter. Fields:enabled,rate_per_service_root_per_second,respect_retry_after_max_s,fallback_retry_after_seconds,backend. Exposed viarestgdf.Config.resilienceand in top-level__all__. Disabled by default; opt in viaRESTGDF_RESILIENCE_ENABLED=1.restgdf.resilience.ResilientSession— retry + rate-limit adapter implementing theAsyncHTTPSessionprotocol. Stamina-based retry with 429/5xx awareness. Pure pass-through whenResilienceConfig.enabled=False. Requirespip install restgdf[resilience].[project.optional-dependencies] resilienceextra:stamina>=24.2,aiolimiter>=1.1.Per-service-root token-bucket rate limiting via
LimiterRegistryand 429-cooldown viaCooldownRegistryinrestgdf.resilience._limiter._service_root(url)derives the rate-limit key by truncating at the firstFeatureServer/MapServer/ImageServer/SceneServerpath segment.
Transport protocols and drift¶
restgdf._client._protocols.AsyncHTTPSession—@runtime_checkabletyping.Protocolcapturing theget/post/close/closedsurface restgdf transport sessions rely on; re-exported fromrestgdf._client.restgdf._models._drift.FieldSetDriftObserver— observer class that tracks attribute-key appearance / disappearance across feature-page batches and emits dedupedfield_appeared/field_disappearedrecords through the existingrestgdf.schema_driftlogger.Private
restgdf.utils._http._choose_verb(url, body=None)seam returning"POST"for/queryand/queryRelatedRecords,"GET"for bare service/layer metadata URLs, and"POST"as the conservative default. Call sites unchanged; forward-compatible stub for BL-50’s future ~1800-byte GET→POST auto-switch.
Internal helpers¶
restgdf.utils._concurrency.bounded_gather(*aws, semaphore)— caps concurrent fan-out via anasyncio.BoundedSemaphorewhile preservingasyncio.gatherresult ordering andreturn_exceptionssemantics (BL-01).restgdf.utils.getinfo._feature_count_with_timeout— inline bounded retry aroundget_feature_countwith exponential backoff. Retries only onasyncio.TimeoutError,TimeoutError, andaiohttp.ServerTimeoutError; connection-level failures (aiohttp.ClientConnectionError) and deterministic errors (RestgdfResponseError, schema mismatches) propagate on the first attempt. Exhausted timeouts raiseRestgdfTimeoutErrorwith__cause__preserved (BL-51).Directory.safe_crawlnow routes its per-layerfeature_countprobe through aBoundedSemaphoresized fromConcurrencyConfig.max_concurrent_requests(Q-A7).restgdf.__getattr__now consults a_REMOVED_EXPORTSextension point before raisingAttributeError, letting future phases register removed top-level names with aDeprecationWarning+ migration message. Mapping empty in this release (BL-57).
Tests + tooling¶
Taxonomy + observability contract tests (
tests/test_taxonomy_contract.py) assertingerrors.__all__shape and thatget_logger(suffix)emits a structured record for everyLOGGER_SUFFIXESentry (BL-37).Minimal-install contract test (
tests/test_minimal_install.py) guarding against accidental import ofpandas/geopandas/pyogriowhen users install the base package without extras (BL-38).Streaming-recipe discoverability regression test (
tests/test_streaming_recipe_discoverable.py).hypothesis,aioresponses, andopentelemetry-sdkadded to thedevextra. Newtests/test_crawl_property_hypothesis.pyscaffold andtests/_mocks/aioresponses_helpers.pyshared fixtures (BL-39, R-62 scope).bumpverpre_commit_hook(scripts/bumpver_stamp_date.py) auto-stampsCITATION.cff::date-releasedto the release date on everybumpver update, keeping the citation metadata in lock-step withversion:. Newtest_citation_cff_date_released_is_iso_8601pins the ISO-8601 date format (BL-40 follow-up).Install-combination CI matrix (R-62, v3-followup T5). New
install_combinationsjob in.github/workflows/pytest.ymlruns the test suite against six explicit pip install surfaces: base,[geo],[resilience],[telemetry],[geo,resilience,telemetry], and[dev]. Wired into theciaggregator so regressions in any single extra fail PR checks before merge.Coverage-recovery tests (v3-followup T1–T4). Four targeted test modules lift measured coverage from 96.53% to 98.16%:
tests/test_resilience_retry_coverage.py(17 tests;_retry.py79% → 99%),tests/test_telemetry_coverage.py(9 tests;_correlation.py100%,_spans.py97%),tests/test_credentials_coverage.py(6 tests;credentials.py91% → 100%), andtests/test_getgdf_coverage.py(10 tests;getgdf.py95% → 99%). Coverage floor inpyproject.toml([tool.coverage.report] fail_under) raised from 96 to 97 to match (v3-followup T11).
Changed¶
Breaking¶
Default token wire transport flipped from
"body"to"header"(BL-13). Tokens are now sent via theX-Esri-Authorizationheader. Settransport="body"inAuthConfig/TokenSessionConfigto restore the old behavior.refresh_leeway_secondsdefault raised 60 → 120 (BL-13).getgdf/_get_sub_featuresnow raiserestgdf.errors.PaginationError(notRuntimeError) onexceededTransferLimit=true.PaginationErrorcarriesbatch_indexandpage_size(BL-08).PaginationErrorno longer multi-inheritsRuntimeError(phase-3d consolidation under the BL-06 taxonomy). Callers catchingRuntimeErroraroundfeature_count/ pagination calls must widen toRestgdfErroror narrow toPaginationError/ArcGISServiceError(BL-09, R-02).
Non-breaking¶
FeatureLayer.where(new_where)now reuses the parent’s cached metadata so no second metadata GET (?f=json) is issued when the parent was already prepped viaprep()/from_url(). A single feature-count POST (returnCountOnly=true) scoped to the refinedwhereclause is still issued sorefined.countremains correct for the refined filter. The newwhere_clauseis threaded throughdata["where"]so subsequent query / streaming calls honour it (BL-46).ArcGISTokenSession.token_needs_updaterefactored to useexpires_atand_utc_now()instead of inline epoch arithmetic (BL-16).Reactive 498/499 handling in
_call_with_auth_retry— HTTP 498 triggers single-flight refresh + one retry; HTTP 499 raisesAuthNotAttachedErrorimmediately (BL-11).restgdf.utils.getinfo.service_metadata,restgdf.utils.crawl.fetch_all_data, andrestgdf.utils.crawl.safe_crawlnow route their internalasyncio.gatherfan-out throughbounded_gatherwith a per-callasyncio.BoundedSemaphore. Saturation semantics = wait (no new exception) (BL-01).ArcGISTokenSession.update_token_if_needednow collapses concurrent refresh attempts onto a single/generateTokenPOST via a lazily-initialized per-instanceasyncio.Lockwith a double-checkedtoken_needs_update()inside the lock. The new_refresh_lockfield isinit=False,repr=False,compare=False(BL-03).RestgdfResponseErrornow inherits fromrestgdf.errors.RestgdfErrorin addition toValueError. Class identity and thefrom restgdf._models._errors import RestgdfResponseErrorimport path are preserved;except ValueError:call sites keep working (BL-06).restgdf.utils._optional._optional_dependency_errornow returnsrestgdf.errors.OptionalDependencyErrorinstead of a bareModuleNotFoundError. Existingexcept ModuleNotFoundError:andexcept ImportError:handlers still catch the new exception becauseOptionalDependencyErrormulti-inheritsModuleNotFoundError(BL-07).HTTP timeouts are now plumbed through
Settings.timeout_secondsinto every library-maintainedsession.get/session.postcall site (restgdf.utils._query,restgdf.utils._stats,restgdf.utils.getgdf._get_sub_features/get_sub_gdf,ArcGISTokenSession.update_token, and theArcGISTokenSession.get/.postwrappers). The newrestgdf.utils._http.default_timeout()helper returns anaiohttp.ClientTimeoutsized fromSettings.timeout_seconds(float, default30.0, overridable viaRESTGDF_TIMEOUT_SECONDS). Callers that already passtimeout=keep precedence (BL-02).ArcGISTokenSession.__post_init__now respects a caller-suppliedconfig=TokenSessionConfig(...)instead of overwriting it, and derives theTokenSessionConfigsplit fields fromtoken_refresh_thresholdinternally (no longer via the deprecatedrefresh_threshold_secondsalias), so plain construction no longer fires aDeprecationWarning.token_refresh_thresholdis resynced from the validated config after construction.pyproject.toml::[tool.coverage.report].exclude_alsoextended withif TYPE_CHECKING:,@overload, and bare-ellipsis stub lines (standard coverage.py idioms, pydantic / httpx / attrs precedent). Thresholdfail_under=97unchanged (R-63).
Deprecated¶
FeatureLayer.row_dict_generator— useFeatureLayer.stream_rows. EmitsDeprecationWarningand continues to delegate to the module-levelrow_dict_generatorhelper for backwards compatibility with existingunittest.mock.patchcall sites.get_token()— emitsDeprecationWarningon every call (BL-14). Migrate toArcGISTokenSessionfor async token management.get_tokennow also acceptspydantic.SecretStrpasswords.restgdf.Settings/restgdf.get_settings()— userestgdf.Config/restgdf.get_config().get_settings()emits a singleDeprecationWarningon first call and constructs its return value fromget_config(); existing callers continue to work unchanged. Will be removed no earlier than restgdf 3.0 (BL-18).Six flat environment variables —
RESTGDF_TIMEOUT_SECONDS,RESTGDF_TOKEN_URL,RESTGDF_REFRESH_THRESHOLD,RESTGDF_USER_AGENT,RESTGDF_LOG_LEVEL,RESTGDF_MAX_CONCURRENT_REQUESTS— in favour of theirRESTGDF_<CATEGORY>_<FIELD>replacements. The old names continue to work but emit aDeprecationWarningwhen read viaConfig.from_env/get_config; when both old and new names are set the new one wins and the warning notes the override (BL-18).TokenSessionConfig.refresh_threshold_seconds— a read/write alias emittingDeprecationWarning. Reads returnrefresh_leeway_seconds + clock_skew_seconds; constructor writes split the supplied total intoclock_skew_seconds = min(30, total)andrefresh_leeway_seconds = total - clock_skew_seconds. Migrate to the explicit field pair.
Removed¶
restgdf.utils._metadata.FIELDDOESNOTEXISTsentinel (and its re-export viarestgdf.utils.getinfo). Call sites must nowexcept FieldDoesNotExistErrorfrom the BL-06 taxonomy. Hard break — no compat shim (BL-09, R-02).
Fixed¶
bumpverfile pattern forCITATION.cffanchored to^version: {version}$(was unanchoredversion: {version}), preventing a latent release-time defect where the regex would silently rewritecff-version: 1.2.0to the new release version on everybumpver update. Discovered viabumpver update --dryduring the CITATION auto-stamp work.ArcGISTokenSession.update_tokennow forwards the session’sverify_sslflag asssl=on the/generateTokenPOST. Previously the flag was honoured for feature / query requests but ignored during token refresh, soverify_ssl=Falsesessions could still fail TLS verification against self-signed ArcGIS Enterprise deployments.
2.0.0 - 2026-04-20¶
Major release — pydantic 2.13 integration. See
MIGRATION.md for a complete breaking-changes table and
migration recipes.
Breaking¶
Public return and attribute shapes changed from plain
dict/TypedDictto pydanticBaseModelclasses:FeatureLayer.metadata→LayerMetadataDirectory.metadata→LayerMetadataDirectory.services,Directory.services_with_feature_count, andDirectory.crawl(...)→list[CrawlServiceEntry]get_metadata(...)→LayerMetadatasafe_crawl(...)→CrawlReport
AGOLUserPass.passwordis nowpydantic.SecretStr; call.get_secret_value()at the HTTP-POST boundary.restgdf._types.*TypedDicts are replaced by lazy aliases that re-export the new pydantic models and emitDeprecationWarningon import. The shim will be removed in 3.x.
Added¶
LayerMetadata,ServiceInfo,FieldSpec,Feature,FeaturesResponse,CountResponse,ObjectIdsResponse,TokenResponse,ErrorInfo,ErrorResponse,CrawlReport,CrawlServiceEntry,CrawlError— pydantic response models.AGOLUserPass,TokenSessionConfig— pydantic credentials / session config models.Settings,get_settings— process-wide runtime configuration backed byRESTGDF_*environment variables (CHUNK_SIZE,TIMEOUT_SECONDS,USER_AGENT,LOG_LEVEL,TOKEN_URL,REFRESH_THRESHOLD,DEFAULT_HEADERS_JSON).RestgdfResponseError— typed error raised when a strict-tier response fails validation; carriesmodel_name,context, andrawpayload attributes.restgdf.compat.as_dict/restgdf.compat.as_json_dict— migration helpers that convert any returned model (or passthrough any non-model) to a plain dict.restgdf.schema_driftlogger — opt-in observability for vendor variance;NullHandlerby default.Directory.report— the fullCrawlReport(services, errors, root metadata) from the most recent.crawl()call.
Dependencies¶
Added
pydantic>=2.13.3,<3.
1.x¶
Earlier releases were not formally tracked here. See the Git tag history and PyPI release notes for pre-2.0 changes.