fix: share one HTTP retry loop and validate CLI/query input - #75
Merged
Conversation
Addresses five audit-tagged correctness issues. Retries (#68, #70, #71) — GitHubClient, JiraClient, SlackClient and SliteClient each carried their own retry loop and the copies had drifted: Jira retried once, GitHub retried once and only on primary rate limits, Slack and Slite were byte-identical duplicates. Extract one request_with_retries() into devrag/utils/http.py and move all four onto it. The loop owns the mechanics; per-service policy is injected as a retry_delay(resp, attempt) callback. - Catch httpx.TransportError, not httpx.TimeoutException. ConnectError, ReadError, WriteError and RemoteProtocolError are siblings of TimeoutException rather than subclasses, so the narrow catch let the most common network faults abort a sync on first occurrence. - Parse rate-limit headers via safe_int()/parse_retry_after() instead of bare int(). Those headers are server-controlled strings, and RFC 9110 allows Retry-After to be an HTTP-date, so int() was a reachable crash that propagated through pagination and killed the whole sync. - GitHub gets _rate_limit_retry_delay, covering all three shapes it actually uses: primary limit (403 + x-ratelimit-remaining: 0), secondary limit (429 + Retry-After with non-zero remaining, which a remaining == 0 test can never catch), and 5xx. A 403 with quota remaining is an authorization failure and stays unretried so a missing token scope surfaces immediately. - SlackClient passes before_attempt=self._throttle so retries stay inside its global rate cap instead of answering a 429 storm with a burst. - The helper returns the final response rather than raising, so each client keeps its own raise_for_status() and body-level handling (Slack's ok: false -> SlackAuthError). embed_query (#67) — raise ValueError on blank input instead of an IndexError from subscripting embed()'s empty list. A zero vector has undefined cosine similarity against every chunk, so a blank query would return arbitrary results that look like genuine hits. Batch embed() keeps substituting zero vectors: there position must be preserved and one empty file must not abort an index run. devrag search guards blank queries as a usage error. --since (#69) — replace six copies of int(since.rstrip("d")) with a shared _parse_since() raising typer.BadParameter. The old form crashed with a raw traceback on 90days/3w/abc and silently accepted 90dd as 90. Each command now calls it as its first statement, so a typo fails instantly instead of after the vector store and embedding models load. Tests: 60 new across the retry helper, all four clients, the embedder and the CLI. Suite at 426 passed, 2 skipped.
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.
Addresses the five
audit-tagged correctness issues.Closes #67
Closes #68
Closes #69
Closes #70
Closes #71
Retries — #68, #70, #71
All three issues pointed at the same root cause, so this fixes it once.
GitHubClient,JiraClient,SlackClientandSliteClienteach carried their own retry loop, and the copies had drifted apart — Jira retried once, GitHub retried once and only on primary rate limits, Slack and Slite were byte-identical duplicates. Per-client fixes never propagated.request_with_retries()indevrag/utils/http.pyis now the single implementation. It owns the mechanics (bounded attempts, sleep placement, transport-error handling); per-service policy is injected as aretry_delay(resp, attempt)callback returning seconds-to-wait orNoneto accept the response.httpx.TransportError, nothttpx.TimeoutException.ConnectError(DNS failure, connection refused),ReadError/WriteError(connection reset) andRemoteProtocolErrorare siblings ofTimeoutExceptionunderTransportError, not subclasses — so the narrow catch let the most common real-world network faults abort a sync on first occurrence.safe_int()/parse_retry_after(). Rate-limit headers are server-controlled strings; a bareint()on one raisesValueErrorthat propagates through pagination and kills the whole sync.parse_retry_afterhandles both RFC 9110 forms, so the HTTP-date that madeint()a reachable crash now works._rate_limit_retry_delay, covering all three shapes it actually uses: primary limit (403+x-ratelimit-remaining: 0, wait until reset), secondary limit (429+Retry-Afterwith a non-zero remaining, which aremaining == 0test can never fire for), and ordinary5xx. A403with quota remaining is an authorization failure and is deliberately not retried, so "token lacks this scope" surfaces immediately rather than stalling.before_attempt=self._throttle, keeping retries inside its global rate cap instead of answering a 429 storm with an unthrottled burst.raise_for_status()and body-level error handling (Slack'sok: false→SlackAuthError).embed_query()— #67Raises
ValueErroron blank input instead of anIndexErrorfrom subscriptingembed()'s empty list.The issue offered two contracts; this takes the raise branch. The zero-vector alternative is actively dangerous: a zero dense vector has undefined cosine similarity against every chunk, so a blank query would return arbitrary results that look like genuine hits. Batch
embed()keeps substituting zero vectors — there, position must be preserved so results line up with inputs, and one empty file shouldn't abort a 10k-file index run. Same blank input, different right answer, because the callers differ.Per the issue's note to check callers:
devrag searchnow rejects a blank query as a usage error.--since— #69Six copies of
int(since.rstrip("d"))replaced with a shared_parse_since()raisingtyper.BadParameter(usage error, exit 2). The old form crashed with a raw traceback on90days/3w/abc, and silently accepted90ddas 90.Each command now calls it as its first statement. Previously the parse ran after the Qdrant store and embedding models were built, so a typo cost full startup before crashing:
Notes on scope
search()left unguarded. A blank query there raises theValueError, whose message already names the cause and which FastMCP surfaces as a tool error. Guarding it would need new test infrastructure (there is notest_mcp_server.py) for marginal gain.max_retries. Both take a constructor arg defaulting to 5 (up from an effective 1).slack.max_retriesandslite.max_retriesalready had config surface; adding it for the other two wasn't in scope of any issue.Testing
Written test-first, each watched fail before implementing. 60 new tests: 18 for the retry helper, 9 GitHub, 4 Slack, 6 Jira (new
tests/test_jira_client.py), 3 embedder, 20 CLI.One pre-existing test changed:
tests/test_slite_client.pypatchedslite_client.time.sleep, and sleeping moved into the shared helper.CLAUDE.md and README.md updated; the two
max_retriescomments inconfig.pynow say "transport-error" rather than "timeout", matching what they mean.