Skip to content

HTTP data fetcher: support conditional requests (ETag / If-None-Match → 304) to skip redundant full refetches #990

Description

@3kd1000

Is your feature request related to a problem? Please describe.

The HTTP fetch provider always performs a full GET and the client always replaces the data in the policy store, even when the data source has not changed. With periodic_update_interval and update triggers, every client re-downloads and re-writes the full document on every cycle.

We run OPAL in production for an enterprise data platform:

  • OPAL server and client 0.9.3 (planning to move to 0.9.9+). The fetcher/updater code paths below are unchanged between 0.9.3 and current master.
  • 6–8 opal-client pods (HPA) serving ~1.5k authorization queries/s in total
  • One HTTP data source, ~5 MB JSON, periodic_update_interval 600 s

For a document that rarely changes, this is roughly:

  • ~144 full fetches per client per day → about 5.8 GB/day of transfer
  • ~1,150 full policy-store data replacements per day across the fleet

A data source that supports standard HTTP conditional requests (a strong ETag, If-None-Match → 304 Not Modified) could avoid all of this, but the fetcher never sends the header. Our data source is a PEP-side service we built, and we are adding ETag / 304 support on that server side now — we would like the OPAL side to be able to use it as well.

Code references (current master):

  • opal_common/fetcher/providers/http_fetch_provider.py — HttpFetcherConfig has headers / is_json / process_data / method / data only; no validator handling. A 304 would pass raise_for_status() and then fail in _response_to_data() on the empty body.
  • opal_client/data/updater.py — calc_hash() is computed for the report only; identical data is still written via _set_policy_data().

Describe the solution you'd like

An opt-in conditional-request mode for the HTTP fetcher (default behaviour unchanged):

  1. A new HttpFetcherConfig option (name open for discussion, e.g. conditional_request: bool = False).
  2. When enabled, after a successful 200 the client remembers the response ETag (and Last-Modified if present) for that data source entry, and sends If-None-Match / If-Modified-Since on the next fetch.
  3. On 304 Not Modified the fetcher returns a "not modified" result instead of trying to parse a body, and the updater skips the policy-store write (reported as fetched, not saved).
  4. The validator is kept outside the provider instance (providers are created per fetch), keyed by entry (url + dst_path + config), in client memory only.

Points we would like your input on:

  • Policy store resets: if the inline OPA restarts or the store is cleared while the client keeps its validators, a 304 would leave the store empty. We think validators should be cleared whenever the policy store is (re)initialised, and probably not used for the base fetch on (re)connect.
  • Whether you prefer this in the HTTP provider/config as above, or as a generic "not modified" contract in the fetcher engine that other providers could also use.

Describe alternatives you've considered

  • Client-side content hash check (skip the store write when calc_hash() matches the last saved hash). This avoids redundant writes but not the transfer. It could be a complementary change; we can propose it separately if useful.
  • Longer periodic interval / disabling periodic updates. Reduces cost but also removes the safety net for missed update events.
  • A custom fetch provider via FETCH_PROVIDER_MODULES. Works, but this seems generally useful for anyone fetching large, mostly static documents over HTTP.

Additional context

  • We are happy to implement this and open a PR with tests if the direction looks good to you.
  • We run the 0.9 line in production. Would a backport to release/0.9.x be acceptable, or should this target master (0.10.x) only?

Activity

  1. dshoen619 commented on Nov 3, 2026

    @dshoen619
    Contributor

    Thanks for the detailed proposal! Notes to plan around:

    Design

    • Opt-in flag on HttpFetcherConfig, default off, GET only.
    • A small "not modified" result type in opal_common.fetcher, returned only when the flag is on. Don't signal it with None: DataUpdater._fetch_data treats None as an error.
    • Keep the provider stateless, since a new one is created per fetch. Validators go in on the fetch event and come back with the result. DataUpdater stores them in memory, keyed by url + dst_path + a hash of the config, which can hold auth headers.

    304 handling: catch it before raise_for_status(). With aiohttp (the default) the 304 gets through, then _response_to_data() raises ContentTypeError. httpx raises on it inside _fetch_, so it gets retried. Please test both clients.

    Store resets

    • Clear validators at the top of get_base_policy_data(). It covers initial load, reconnect, inline-OPA rehydration and /data-updater/trigger.
    • External OPA doesn't trigger rehydration when it restarts. Add a max-age (a periodic unconditional fetch) or document the flag as inline-OPA only.

    Reporting: don't reuse fetched=True, saved=False (today that means the write failed). Add a defaulted field to DataEntryReport, and mark the transaction successful so /healthy isn't affected.

    Prior art: ApiPolicySource (opal_common/sources/api_policy_source.py) already does ETag/304 for policy bundles.

    Backport: target master first. release/0.9.x is on pydantic v1, so a port needs the v1 API. Until then, a custom provider that returns a cached body on 304 saves the transfer on 0.9.x.

  2. 3kd1000 commented on Nov 4, 2026

    @3kd1000
    Author

    Thanks a lot for the detailed notes — this is very helpful. I'll go with the split you described and open a PR against master:

    • HTTP provider: opt-in flag on HttpFetcherConfig (default off, GET only). Sends If-None-Match / If-Modified-Since and handles 304 before raise_for_status(), for both aiohttp and httpx (tests for both).
    • opal_common.fetcher: a small NotModified result type, returned only when the flag is on — no None signalling.
    • DataUpdater: keeps the validators in memory, keyed by url + dst_path + a hash of the config; they travel on the fetch event and come back with the result, so the provider stays stateless. Validators are cleared at the top of get_base_policy_data().
    • Reporting: a new defaulted field on DataEntryReport for "not modified", with the transaction marked successful so /healthy is unaffected.

    On external OPA: we're actually planning to move to an external OPA ourselves (to run a newer OPA than the inline one), so I'd like the flag to be safe for both inline and external rather than documenting it as inline-only. Since an external OPA restart isn't visible to the client, a max-age alone would still leave an empty store until it expires. My proposal:

    1. Before skipping on a 304, check that the policy store still has data at the entry's dst_path; if it doesn't, drop the validator and do a full fetch.
    2. Keep an optional max-age (off by default) as a backstop.

    Does that fit what you had in mind, or would you prefer a different signal for "the store was reset" (e.g. a marker written alongside the data)?

    I'll use ApiPolicySource as the reference for the ETag handling. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions