Skip to content

fix(retain): a zero retry budget must still perform the initial fact-extraction request#3

Open
chethanuk wants to merge 3 commits into
mainfrom
fix/issue-2731-fixretain-a-zero-retry-budget-must
Open

fix(retain): a zero retry budget must still perform the initial fact-extraction request#3
chethanuk wants to merge 3 commits into
mainfrom
fix/issue-2731-fixretain-a-zero-retry-budget-must

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 16, 2026

Copy link
Copy Markdown
Owner

User description

Problem

A retry budget of zero must mean one initial request and no retries. Today retain_llm_max_retries=0 performs zero fact-extraction requests and raises Fact extraction failed after 0 attempts — blocking trusted-gateway deployments where the operator deliberately sets the budget to 0 because the upstream gateway owns transport retries.

Fixes vectorize-io#2731

Root cause

Two nested layers read the same knob with different meanings:

  • OpenAICompatibleLLM.call(max_retries=N) correctly loops range(N + 1) — N retries after the initial request (same in the Anthropic and Gemini providers).
  • _extract_facts_from_chunk used the same value as an outer attempt count: for attempt in range(llm_max_retries)range(0) → zero requests.
flowchart LR
    A["retain_llm_max_retries = N"] --> B["outer loop (content-quality re-prompts)<br/>OLD: range(N) → N=0 ⇒ 0 attempts ❌<br/>NEW: range(max(1, N)) → N=0 ⇒ 1 attempt ✅"]
    B --> C["llm_config.call(max_retries=N)<br/>provider transport retries: range(N+1)<br/>(unchanged — provider owns transport)"]
    C --> D["LLM"]
Loading

Fix

Floor the outer attempt count at 1 while forwarding the raw budget to the provider unchanged:

outer_attempts = max(1, llm_max_retries)   # N=0 → 1 attempt; N>=1 → identical to today
llm_config.call(..., max_retries=llm_max_retries)  # raw transport budget, provider does N+1

max(1, N) == N for every N ≥ 1, so existing deployments are byte-for-byte unchanged (budget 3 still means 3 outer × 4 transport worst-case). A review suggestion to use N + 1 outer attempts was deliberately not taken: it would silently multiply LLM spend for every configured deployment (budget 3: 12 → 16 worst-case requests) — the comment now spells out the legacy semantics so the two meanings can't be conflated again.

Every attempt-count reference is also corrected — retry-decision conditions, warning logs, and both error messages — so a malformed response with budget 0 reports after 1 attempts, never after 0 attempts.

How this differs from vectorize-io#2741

vectorize-io#2741 lands the same max(1, N) floor, and for N ≥ 1 the two patches are behaviorally identical. This PR additionally fixes the error path that actually fires on a malformed response with budget 0: under vectorize-io#2741 the primary raise still reports "after 0 attempts" (only the rarely-reached final fallback message was updated). Coverage is also broader: table-driven tests across N = 0/1/3/unset — including the global-fallback path and the malformed-response contract — versus a single N=0 case; the N=0 malformed-path assertion here fails against vectorize-io#2741's diff as written.

How to test

cd hindsight-api-slim
uv run pytest tests/test_fact_extraction_retry.py -v
uv run ruff check hindsight_api/engine/retain/fact_extraction.py tests/test_fact_extraction_retry.py
uv run ruff format --check hindsight_api/engine/retain/fact_extraction.py tests/test_fact_extraction_retry.py
uv run ty check hindsight_api/engine/retain/fact_extraction.py

Test evidence

Run post-rebase on upstream main (eca0fd5a2), squashed branch:

Check Result
pytest tests/test_fact_extraction_retry.py 20 passed (incl. new direct _extract_facts_from_chunk N=0 case)
ruff check / ruff format --check on both touched files clean
ty check hindsight_api/engine/retain/fact_extraction.py clean

Test plan

  • Budget 0 → exactly one extraction request, max_retries=0 forwarded to provider
  • Budget 0 + malformed response → one attempt then raise (never "after 0 attempts")
  • Budget 1 / 3 / unset (falls back to global llm_max_retries) → behavior unchanged
  • Pre-existing non-dict / retain-override / none-event_date tests still green

CodeAnt-AI Description

Keep one fact-extraction attempt when the retry budget is zero

What Changed

  • A retry budget of 0 now still performs the initial fact-extraction request instead of skipping the request entirely.
  • The zero budget is still passed through to the provider, so transport retries remain owned by the upstream client.
  • Malformed responses now fail after the correct number of attempts, with error messages that match the actual retry budget.
  • Added tests for zero, default, and non-zero retry budgets, plus the malformed-response failure path.

Impact

✅ Fact extraction works with zero retry budgets
✅ Fewer blocked deployments behind trusted gateways
✅ Clearer retry-failure messages

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@codeant-ai

codeant-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.

@codeant-ai

codeant-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chethanuk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31c68a53-9e67-4511-bc41-c193d45abcff

📥 Commits

Reviewing files that changed from the base of the PR and between 9c39a21 and 661be5a.

📒 Files selected for processing (2)
  • hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py
  • hindsight-api-slim/tests/test_fact_extraction_retry.py
📝 Walkthrough

Walkthrough

Changes

Fact extraction retry handling

Layer / File(s) Summary
Retry attempt semantics
hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py
Retry handling derives an effective attempt count of at least one, applies it to malformed-response and malformed-fact retries, and reports it in terminal errors.
Retry regression coverage
hindsight-api-slim/tests/test_fact_extraction_retry.py
Parametrized tests record provider calls, validate forwarded retry settings, and verify malformed responses fail after the expected number of attempts.
Estimated code review effort: 3 (Moderate) ~20 minutes

Suggested reviewers: nicoloboschi

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main fix for zero retry budgets in retain fact extraction.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-2731-fixretain-a-zero-retry-budget-must

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Jul 16, 2026

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py (1)

1307-1313: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align the retry documentation with the preserved outer-loop semantics.

The implementation treats positive N as N outer content-validation attempts, while the comments define N as retries after an initial request (N+1 attempts).

  • hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py#L1307-L1313: describe outer_attempts as the legacy outer attempt count, floored at one.
  • hindsight-api-slim/tests/test_fact_extraction_retry.py#L262-L271: distinguish outer content-validation attempts from provider transport retries.
  • hindsight-api-slim/tests/test_fact_extraction_retry.py#L351-L360: clarify why budget 3 expects three outer calls rather than four.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py` around
lines 1307 - 1313, Align the retry documentation without changing behavior: in
hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py:1307-1313,
describe outer_attempts as the legacy outer content-validation attempt count
floored at one, while llm_max_retries remains the provider transport-retry
setting. In hindsight-api-slim/tests/test_fact_extraction_retry.py:262-271,
distinguish outer validation attempts from provider retries; in
hindsight-api-slim/tests/test_fact_extraction_retry.py:351-360, clarify that
budget 3 produces three outer calls rather than four.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py`:
- Around line 1307-1313: Align the retry documentation without changing
behavior: in
hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py:1307-1313,
describe outer_attempts as the legacy outer content-validation attempt count
floored at one, while llm_max_retries remains the provider transport-retry
setting. In hindsight-api-slim/tests/test_fact_extraction_retry.py:262-271,
distinguish outer validation attempts from provider retries; in
hindsight-api-slim/tests/test_fact_extraction_retry.py:351-360, clarify that
budget 3 produces three outer calls rather than four.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db0fcf28-96c3-4869-b76c-4a637536e375

📥 Commits

Reviewing files that changed from the base of the PR and between 016b5f0 and 9c39a21.

📒 Files selected for processing (2)
  • hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py
  • hindsight-api-slim/tests/test_fact_extraction_retry.py

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request addresses an issue where a retry budget of 0 resulted in zero extraction requests, ensuring that at least one attempt is always performed. It introduces the outer_attempts variable to manage this logic and adds comprehensive tests to verify retry behavior across various budget configurations. The reviewer provided critical feedback suggesting that the current outer_attempts logic may be inconsistent for budgets greater than 0 and recommended using llm_max_retries + 1 to ensure the outer loop correctly reflects the total number of attempts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1307 to +1313
# A retry budget of N means N retries *after* the initial request (the meaning
# the providers implement: `range(max_retries + 1)`). This loop counts attempts,
# so it must floor at 1 — a budget of 0, set when an upstream gateway owns
# transport retries, still has to perform one extraction request. See #2731.
# llm_max_retries itself is still forwarded to llm_config.call() below so the
# provider remains the single owner of transport retries.
outer_attempts = max(1, llm_max_retries)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

While this change correctly fixes the issue for a retry budget of 0, the logic for budgets greater than 0 appears to be incorrect and inconsistent.

The variable llm_max_retries is described as a "retry budget", meaning N retries after an initial attempt. This implies a total of N + 1 attempts. The inner llm_config.call() correctly interprets it this way.

However, the proposed logic outer_attempts = max(1, llm_max_retries) results in:

  • llm_max_retries = 1 -> outer_attempts = 1 -> 1 total attempt (should be 2)
  • llm_max_retries = 3 -> outer_attempts = 3 -> 3 total attempts (should be 4)

This is also reflected in the new test test_malformed_response_still_attempts_then_fails_loudly, where a budget of 1 expects only 1 call.

To make the behavior consistent with the definition of a retry budget, the number of attempts for the outer loop should be llm_max_retries + 1. This would correctly handle all cases, including a budget of 0.

I suggest changing the logic to outer_attempts = llm_max_retries + 1. This will also require updating the expected_calls in test_malformed_response_still_attempts_then_fails_loudly to 2 for a budget of 1, and 4 for a budget of 3.

Suggested change
# A retry budget of N means N retries *after* the initial request (the meaning
# the providers implement: `range(max_retries + 1)`). This loop counts attempts,
# so it must floor at 1 — a budget of 0, set when an upstream gateway owns
# transport retries, still has to perform one extraction request. See #2731.
# llm_max_retries itself is still forwarded to llm_config.call() below so the
# provider remains the single owner of transport retries.
outer_attempts = max(1, llm_max_retries)
# A retry budget of N means N retries *after* the initial request.
# This loop is for retrying on content errors (e.g. malformed JSON),
# and it counts total attempts, so it must run `llm_max_retries + 1` times.
# This makes its behavior consistent with the inner `llm_config.call()`
# which handles transport-level retries.
# llm_max_retries itself is still forwarded to llm_config.call() below so the
# provider remains the single owner of transport retries.
outer_attempts = llm_max_retries + 1

@codeant-ai

codeant-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

@chethanuk
chethanuk force-pushed the fix/issue-2731-fixretain-a-zero-retry-budget-must branch from 9c39a21 to 4b7c369 Compare July 17, 2026 15:19
@codeant-ai

codeant-ai Bot commented Jul 17, 2026

Copy link
Copy Markdown

CodeAnt AI is running Incremental review

@codeant-ai

codeant-ai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Jul 17, 2026
@codeant-ai

codeant-ai Bot commented Jul 17, 2026

Copy link
Copy Markdown

CodeAnt AI Incremental review completed.

@codeant-ai

codeant-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Incremental review completed a5457a7 Jul 20, 2026 · 16:57 16:58

Updated in place by CodeAnt AI · last 5 reviews

@codeant-ai

codeant-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Jul 20, 2026
… retry convention

Review feedback on vectorize-io#2779: llm_max_retries=N means N retries *after* the
initial request, so N=1 must give 2 total outer attempts. The previous
max(1, N) floor under-counted (N=1 -> 1 attempt). Every provider already
loops range(max_retries + 1); the outer content-validation loop now follows
the same convention, and a zero budget still performs one request (vectorize-io#2731).
The raw budget is still forwarded unchanged to llm_config.call().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(retain): a zero retry budget must still perform the initial fact-extraction request

1 participant