Repository navigation
Bound HBase request retries instead of recursing - #18
Merged
Merged
Conversation
get() and _mutate() retried by calling themselves, so every failed attempt kept its frame, row values and built request alive. A row that never succeeded allocated ~3MB per frame and OOM-killed the container before Python's recursion limit was reached. Retry in a bounded loop and raise the last exception instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Saved exceptions retain large requests after successful retries until cyclic garbage collection runs.
1 open finding
What changed in this PR
Replaces recursive HBase request retries with bounded loops to prevent memory exhaustion from permanently failing requests.
Changes:
- Limits
get()and_mutate()to 10 attempts before raising the last exception. - Adds offline tests for retry limits and successful recovery.
| File | Description |
|---|---|
| tests/test_retry_bounds.py | Tests retry exhaustion and mutation recovery. |
| pybase/client.py | Replaces recursive retries with bounded loops. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Storing the exception in a local kept its traceback, and so the frame holding the row values, alive through a cycle the refcounter cannot break. That retained the payload after a successful retry until cyclic gc ran. Re-raise on the final attempt instead, and cover the retention with a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Why
flavour-extractorOOMKilled across the whole fleet on 2026-10-14 (32 restarts over 16 pods). Root cause is in this repo.get()and_mutate()retried a failed request by calling themselves:Python has no tail-call optimization, so each retry adds a live stack frame holding
valuesplus a freshly built protobuf request. A row that never succeeds keeps every attempt alive at once.In the extractor the document being written is 1.4 MB as JSON (markup 674 KB, content_text 365 KB, four embedding vectors), roughly 3 MB live per frame. The stock recursion limit is 1000, so the ceiling is about 3.1 GiB allocated in 3 seconds, which OOMs the container before
RecursionErrorcan unwind. Raising the container limit from 3Gi to 4Gi did not help, because the recursion is unbounded.Caught with
py-spy dumpon a live pod: hundreds of stacked_mutateframes atclient.py:265.What changed
Both retry sites become bounded loops that raise the last exception once attempts are exhausted. The loop also lets each attempt's request be collected, so memory stays flat regardless of the bound.
MAX_REQUEST_ATTEMPTS = 10is generous for the transient cases these retries exist for (region moved, splitting, server reestablished), which normally clear in one or two attempts. The behavior change is that a permanently failing row now raises instead of retrying forever. That is strictly better than exhausting memory, and callers already handle the exception: flavour catches it asHBaseWriteExceptionand requeues.Unrecoverable exceptions are unaffected, since
_handle_exceptionre-raises them itself.Tests
Added
tests/test_retry_bounds.py, which runs without a live HBase (the existing integration tests need one):_mutateandgetstop after exactlyMAX_REQUEST_ATTEMPTSand raise_mutatestill returns normally when a retry succeedsVerified these are meaningful: against the pre-fix code with the recursion limit lowered to 200,
_mutatedies withRecursionErrorafter 198 attempts. After the fix it stops at 10.flake8reports the same issue set before and after onclient.py(2 E128, 1 F821, 2 W293, all pre-existing and untouched); the new test file is clean.Note on deploy
flavour pins
393cb97inrequirements-source.txt, which is two commits behind master. That pin needs bumping separately for this to reach production.One thing this does not address: why those particular rows fail their HBase write in the first place. That is still open. This change contains the blast radius either way, turning an OOM into a logged, catchable error.