Skip to content

feat(distance): use plugin store for cache, budget - #79

Merged
uniqueg merged 5 commits into
devfrom
feat/21-distance-store
Nov 14, 2026
Merged

uniqueg merged 5 commits into
devfrom
feat/21-distance-store

Conversation

@uniqueg

@uniqueg uniqueg commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Summary

tes.routing.distance with its opt-in dbip provider:

  • keeps the locations of addresses (also misses and failures of services) in FOCA's plugin store (ctx.store), shared by all processes and replicas, instead of the per-process TtlCache (the offline provider keeps the per-process cache);
  • limits each caller's uncached lookups to dbip_user_daily_lookups (default 100, null for no limit) per day: a fixed-window counter (ctx.store.increment(key, ttl=86400, limit=n)) keyed by ctx.store.key_for() of the principal's subject_type and subject, never the raw identifier;
  • charges the budget only for lookups that may have sent a request: LookupFailed.sent is false when the provider is closed, backed off, or out of time before any request, and such lookups are given back (and not cached as failures in the shared store); giving back leaves expired counters alone;
  • never fails a call because of the store: the cache then counts addresses as not cached, and the budget fails closed (no lookups); a full namespace (FOCA 0.23's StoreFullError, a StoreError) counts as a failure;
  • declares its entries in data_stored; README (Data stored section with a key-secret recommendation, configuration table, cache and limits) and CHANGELOG (breaking: new default limit, stored data) updated.

All plugins now require FOCA 0.19.0, the first release with the plugin store: lower bounds, the meta package, the CI matrix (0.18.0 dropped, coverage on 0.19.0) and the docs. Breaking for generic.routing.random too (CHANGELOG), as the repository tests one lowest FOCA version for all plugins.

Plugin abstraction: the store-backed cache and budget are specific to this plugin's DB-IP use; a reusable abstraction (e.g., a generic per-user budget helper) can follow once a second plugin needs one.

Verification

  • ruff check ., ruff format --check .: pass.
  • python -m scripts.packages mypy plugins/tes/routing/distance: pass.
  • coverage run --source plugins/tes/routing/distance/src -m pytest plugins/tes/routing/distance: all pass, 100% coverage, with FOCA 0.22.0 (lock), 0.19.0, and FOCA dev plus feat(plugins): limit store namespace size foca#552 (namespace size limit) (the earlier commit; re-run after the review fixes with 0.22.0: 174 passed).
  • Tests of generic.routing.random and tes.data.crypt4gh pass with FOCA 0.19.0 and 0.22.0; pytest tests: 142 passed.
  • mkdocs build --strict: pass.
  • pre-commit run --all-files couldn't run locally (the sandbox proxy blocks fetching hook repositories); CI's lint job runs it.
  • Two review rounds (adversarial and quality reviewers); fixes in the second and third commits.
  • CI at 3408b15 (after the Sourcery fixes): all 84 checks pass; Sourcery threads fixed or answered and resolved.

Unrelated fixes

  • build(deps): bump dnspython to 2.9.2: CVE-2026-128089 failed the dependency audit of every test job.

Declined review findings

  • Extract the provider-specific wiring of route() into a helper, and build the per-process TtlCache only for offline: the block is short and readable; the unused cache under dbip costs nothing.
  • Deduplicate the docstrings of the LocationCache protocol and StoreCache: repo convention is full Google docstrings on every method.
  • Rewrap long lines in docs/writing-plugins.md and ci.yml outside the lines this PR changes: pre-existing.
  • Add a CHANGELOG note for tes.data.crypt4gh: it was never released; its first release starts with the new bound.

Open questions

None.

Closes #21

🤖 Generated with Claude Code

uniqueg and others added 3 commits November 13, 2026 23:10
With provider dbip, tes.routing.distance keeps the locations of
addresses (also misses and failures) in FOCA's plugin store
(ctx.store), shared by all processes and replicas, instead of the
per-process TtlCache, and limits the uncached lookups of each caller
to dbip_user_daily_lookups (default 100) per day: a fixed-window
counter keyed by ctx.store.key_for() of the principal, never the raw
identifier. Store failures never fail a call: the cache then counts
addresses as not cached, and the budget fails closed. Transient lookup
failures, which send no request, give the lookup back.

The plugin declares its entries (data_stored). All plugins now need
FOCA 0.19.0, the first release with the plugin store (CI matrix, meta
package, docs); FOCA 0.18 is dropped.

Closes #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes. LookupFailed gets "sent": whether the lookup may have
sent a request. The DB-IP provider sets it to false when closed, backed
off or out of time before any request, and the budget gives such
lookups back, also non-transient ones (no more charges while DB-IP is
backed off); transient failures after requests count. give_back()
leaves expired counters alone, so that no window starts at -1.

The store tests move to test_store.py. The README recommends FOCA's
key secret with dbip, and the CHANGELOG notes the new default limit and
the stored data as breaking changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-2 review fixes. Failed lookups that sent no request (e.g., this
process's DB-IP back-off, or a closed provider) are no longer cached
as unlocated, as the cache in the plugin store is shared with other
processes and replicas. give_back() checks counters with a simpler
guard, and the budget test also checks what is cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Data structures

Add the store-backed location cache and per-caller budget, including hashed keys, validation, expiry, failure handling, and request-count refunds.

  • plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/cache.py L1-10, L16-18, L24-59
  • plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py L1-238

Define whether a provider failure may have sent a request so callers can charge or refund the lookup budget correctly.

  • plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/providers.py L44-54, L303-306 (+6 small edits)

API

Expose the daily lookup limit, stored-data declaration, store requirement, and provider-specific wiring for shared cache and budget use.

  • plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/__init__.py L37-44, L319-325, L402-421 (+12 small edits)

Configuration

Raise the minimum FOCA version to the first release with the plugin store across package metadata and the CI compatibility matrix.

  • .github/workflows/ci.yml L151-153, L166-171
  • meta/pyproject.toml L34
  • plugins/generic/example/noop/pyproject.toml L49-51
  • plugins/generic/routing/random/pyproject.toml L40-42
  • plugins/tes/data/crypt4gh/pyproject.toml L40-42
  • plugins/tes/routing/distance/pyproject.toml L40-42

Documentation

Document the new FOCA support floor for the project and plugins.

  • README.md L43-45
  • docs/writing-plugins.md L141-145
  • plugins/generic/example/noop/README.md L20
  • plugins/generic/routing/random/README.md L19
  • plugins/tes/data/crypt4gh/README.md L24

Explain the distance plugin’s shared DB-IP cache, daily budget, stored personal data, retention, and FOCA 0.18 breaking change.

  • plugins/generic/routing/random/CHANGELOG.md L10-16
  • plugins/tes/routing/distance/CHANGELOG.md L10-33
  • plugins/tes/routing/distance/README.md L66-81, L201-215, L236-254 (+4 small edits)

Tests

Test shared cache behavior, caller budgets and refunds, store failures, provider request tracking, and the new store implementation.

  • plugins/tes/routing/distance/tests/test_distance.py L20-32, L172-177, L911-1189 (+3 small edits)
  • plugins/tes/routing/distance/tests/test_providers.py L488-494, L602-622 (+3 small edits)
  • plugins/tes/routing/distance/tests/test_store.py L1-249

Lock files and vendored code

Update the dependency lockfile for the dependency and FOCA version changes.

  • uv.lock L1181-1185 (+5 small edits)

Other changes

Files not grouped.

  • plugins/generic/routing/random/src/foca_plugins/generic/routing/random/__init__.py L70
  • plugins/tes/data/crypt4gh/src/foca_plugins/tes/data/crypt4gh/__init__.py L158

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@read-the-docs-community

read-the-docs-community Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 foca-plugins | 🛠️ Build #35881743 | 📁 Comparing 3408b15 against latest (103aeed)

  🔍 Preview build  

2 files changed
± index.html
± writing-plugins/index.html

@codecov

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (103aeed) to head (3408b15).

Additional details and impacted files
@@            Coverage Diff             @@
##               dev       #79    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           10        11     +1     
  Lines          886      1001   +115     
  Branches       125       143    +18     
==========================================
+ Hits           886      1001   +115     
Flag Coverage Δ
foca-plugin-generic-example-noop 100.00% <ø> (ø)
foca-plugin-generic-routing-random 100.00% <ø> (ø)
foca-plugin-tes-data-crypt4gh 100.00% <ø> (ø)
foca-plugin-tes-routing-distance 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Fixes CVE-2026-128089, which fails the dependency audit of the test
jobs (pip-audit). Unrelated to #21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@uniqueg
uniqueg marked this pull request as ready for review November 13, 2026 23:30
@sourcery-ai

sourcery-ai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Linked issues

Checked at commit 3408b15

#21 feat(distance): use plugin store for cache, budget · 5 of 5 met

# Requirement Status Notes
1 Use ctx.store for the DB-IP geolocation cache, shared across processes and replicas. ✅ Met Distance.on_route() uses StoreCache for dbip and retains the per-process cache for offline; tests cover sharing through a common store.
2 Add a configurable per-user daily lookup budget, using a fixed-window store counter keyed by a hash of the principal rather than the raw identifier. ✅ Met UserBudget uses store.increment(..., ttl=86400, limit=...) and store.key_for() with the principal’s type and subject; the setting defaults to 100 and accepts null to disable the budget.
3 Declare data_stored and update the plugin documentation. ✅ Met The distance plugin declares its stored entries, and the README and changelog describe the cache, budget, retained data, and key-secret recommendation.
4 Raise the FOCA requirement to the release containing the plugin store. ✅ Met Plugin and meta-package bounds, the CI matrix, and the supporting documentation now use FOCA 0.19.0 as the minimum.
5 Test the shared-store cache and budget, with 100% coverage. ✅ Met New tests cover shared caching, caller budgets, refunds, store failures, and cache behavior; the PR verification reports 100% coverage.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 9 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="122" />
<code_context>
+        if not self.enabled or self.failed:
+            return False, None
+        try:
+            value = self.store.get(self.store.key_for(LOCATION_KEY, key))
+        except StoreError as exc:
+            self._failure(exc)
</code_context>
<issue_to_address>
**Stored addresses are enumerable**

When an operator enables `provider: dbip` without configuring FOCA's store key secret and someone obtains a database or backup read, `StoreCache.get()` and `UserBudget.__init__()` call `store.key_for()` without requiring a secret, so with the default unkeyed hashes a reader of the database or a backup can brute-force public IPv4 addresses and associate them with stored locations; caller identifiers can also be guessed. The README acknowledges this exposure but only recommends setting a secret.

Require a configured secret for DB-IP storage, or use a keyed derivation that is available by default.

Also at `plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py:198`.
</issue_to_address>

### Comment 2
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="54" />
<code_context>
+        if (
+            isinstance(number, bool)
+            or not isinstance(number, int | float)
+            or not math.isfinite(number)
+        ):
+            return False, None
</code_context>
<issue_to_address>
**Malformed coordinates break routing**

When the plugin store contains a location list with an integer too large for float conversion, `_location` passes arbitrarily large integers to `math.isfinite`, which raises `OverflowError` when the integer cannot be converted to a float. `StoreCache.get` does not catch it, so reading that entry aborts routing instead of treating the entry as invalid.

Catch `OverflowError` while validating stored numbers, or convert them to floats with the same guarded validation used by `coordinates`.
</issue_to_address>

### Comment 3
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="214" />
<code_context>
+        if self.exhausted or self.failed:
+            return False
+        try:
+            count = self.store.increment(self.key, ttl=BUDGET_WINDOW, limit=self.limit)
+        except StoreError as exc:
+            self.failed = True
</code_context>
<issue_to_address>
**Daily lookup limits expire early**

When the plugin store's `max_ttl` is shorter than 86,400 seconds, `UserBudget.take()` requests a one-day TTL, but the store caps it at `max_ttl`, so the counter expires early and the caller can start another budget window before a day has passed, exceeding the configured daily limit.

Ensure budget counters remain valid for the full one-day window, including when the store's `max_ttl` is shorter.
</issue_to_address>

### Comment 4
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="235-238" />
<code_context>
+        if self.failed:
+            return
+        try:
+            count = self.store.get(self.key)
+            if not isinstance(count, int) or count <= 0:
+                return
+            self.store.increment(self.key, -1, ttl=BUDGET_WINDOW)
+        except StoreError as exc:
+            self.failed = True
</code_context>
<issue_to_address>
**Refunds reduce a new window's count**

When a budget counter expires between `give_back()`'s read and decrement, and the decrement runs after expiry, `UserBudget.give_back()` decrements the key without verifying it is still the window that was charged; the decrement can reduce a new window's count or recreate the counter below zero, allowing extra lookups against that window's limit.

Make refunds atomic with the counter-window check so a refund cannot change a different window's count.
</issue_to_address>

### Comment 5
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/__init__.py" line_range="258" />
<code_context>
+        if (
+            self.ctx.remaining() <= 0
+            or (self.max_lookups is not None and self.batch_lookups >= self.max_lookups)
+            or (self.budget is not None and not self.budget.take())
         ):
             self.skipped_lookups += 1
</code_context>
<issue_to_address>
**No-request attempts consume lookup budget**

When the shared cache misses but the provider serves a process-local cache hit, or the DB-IP provider has no HTTP client, `_Locator._lookup()` takes a budget slot before `DbipProvider.locate()` and retains it on a successful provider-cache return; when `ctx.http` is absent, `PluginError` also escapes without the `LookupFailed(sent=False)` refund. These no-request attempts consume the caller's allowance and can cause later lookups to be skipped.

Refund the budget slot whenever the provider completes without sending a request, including cache hits and errors before a request is sent.

Also at `plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/__init__.py:259`.
</issue_to_address>

### Comment 6
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="220" />
<code_context>
+            self.log.warning("Plugin store unavailable for the budget: %s", exc)
+            return False
+        if count is None:
+            self.exhausted = True
+            self.log.info("The caller's daily budget of lookups is used up")
+            return False
</code_context>
<issue_to_address>
**Refund leaves budget exhausted**

When one call observes an exhausted budget and another call subsequently refunds a lookup that sent no request, a caller's `UserBudget` object remembers an exhausted count even after another concurrent call refunds an unused lookup. Later lookups in the first call keep getting skipped although the shared counter has room, so the caller gets fewer locations than its remaining budget allows.

Do not memoize exhaustion while concurrent refunds can free capacity; recheck the shared counter for later lookups or use a design that makes exhaustion and refunds visible to all budget instances.
</issue_to_address>

### Comment 7
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="198" />
<code_context>
+        """
+        self.store = store
+        self.key = store.key_for(
+            BUDGET_KEY, principal.subject_type or "", principal.subject or ""
+        )
+        self.limit = limit
</code_context>
<issue_to_address>
**Anonymous callers share an exhaustible budget**

When the gateway accepts anonymous requests and the DB-IP provider is enabled, `UserBudget` maps every principal with missing identity fields to the same key, so one unauthenticated caller can consume the shared daily allowance and prevent other anonymous callers from using DB-IP location lookups; their routing falls back without the distance information.

Require an authenticated principal for this budget or define an explicit anonymous policy that does not let one caller exhaust every anonymous caller's allowance.
</issue_to_address>

### Comment 8
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="157-159" />
<code_context>
+        """
+        if not self.enabled or ttl <= 0:
+            return
+        cached, _ = self.get(key)
+        if not cached:
+            self.put(key, value, ttl)
+
+
</code_context>
<issue_to_address>
**Failed lookups overwrite cached locations**

When a service failure and a successful lookup for the same address overlap, `StoreCache.add()` checks for an existing entry and writes the failure marker in separate operations, so the failure can overwrite the successful location; later calls treat the address as unlocated until the failure entry expires.

Make the failure-marker write conditional and atomic with checking that no entry exists.
</issue_to_address>

### Comment 9
<location path="plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py" line_range="122" />
<code_context>
+        if not self.enabled or self.failed:
+            return False, None
+        try:
+            value = self.store.get(self.store.key_for(LOCATION_KEY, key))
+        except StoreError as exc:
+            self._failure(exc)
</code_context>
<issue_to_address>
**Concurrent misses send duplicate requests**

When multiple callers miss the shared cache for the same address before any lookup completes, `StoreCache.get()` allows both callers to observe a miss before either writes a result, so both invoke the provider and send requests, consuming upstream quota and each caller's budget.

Coordinate in-flight lookups for the same address so only one caller invokes the provider.

Also at `plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py:140`, `plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/__init__.py:409`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 9 findings to address first, and with provider: dbip, the plugin now persists geolocations and caller lookup counts in FOCA’s store under hashed keys; without the configured key secret, IPv4 addresses can be recovered by hashing candidate addresses. Reverting stops new writes but does not remove existing entries, which remain until their TTL expires or the store is cleared.

Blocking findings: plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py:122, plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py:54, plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py:214, plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/store.py:238, plugins/tes/routing/distance/src/foca_plugins/tes/routing/distance/__init__.py:258, and 4 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Automated review fixes. Stored coordinates are checked by range only,
which also rejects NaN, infinities and integers too large for floats
(math.isfinite() raised OverflowError on them). The README says that
the budget window is capped by the store's max_ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@uniqueg
uniqueg merged commit eea8b72 into dev Nov 14, 2026
220 checks passed
@uniqueg
uniqueg deleted the feat/21-distance-store branch November 14, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(distance): use plugin store for cache, budget

1 participant