argus-code-review 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/CHANGELOG.md +9 -1
  2. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/PKG-INFO +1 -1
  3. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/gemini_runner.py +210 -5
  4. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/runners.py +3 -0
  5. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_gemini_runner.py +240 -1
  6. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/.gitignore +0 -0
  7. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/LICENSE +0 -0
  8. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/README.md +0 -0
  9. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/__init__.py +0 -0
  10. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/bench.py +0 -0
  11. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/bench_default.toml +0 -0
  12. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/cli.py +0 -0
  13. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/config.py +0 -0
  14. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/coverage.py +0 -0
  15. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/dotenv_utils.py +0 -0
  16. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/gemini_cache.py +0 -0
  17. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/github_client.py +0 -0
  18. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/graph.py +0 -0
  19. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/helpers.py +0 -0
  20. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/models.py +0 -0
  21. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/output_models.py +0 -0
  22. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/pricing.py +0 -0
  23. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/usage.py +0 -0
  24. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/models.py +0 -0
  25. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/openai_client.py +0 -0
  26. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/openai_runner.py +0 -0
  27. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/pipeline_models.py +0 -0
  28. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/__init__.py +0 -0
  29. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/actions_scanner.py +0 -0
  30. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/engine.py +0 -0
  31. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/.gitignore +0 -0
  32. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/README.md +0 -0
  33. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
  34. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package-lock.json +0 -0
  35. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package.json +0 -0
  36. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/js_scanner.py +0 -0
  37. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/migration_scanner.py +0 -0
  38. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/rules/README.md +0 -0
  39. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/sarif.py +0 -0
  40. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/scanner_utils.py +0 -0
  41. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/secrets_scanner.py +0 -0
  42. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/shadow.py +0 -0
  43. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/terraform_scanner.py +0 -0
  44. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/workflow_lint_scanner.py +0 -0
  45. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/__init__.py +0 -0
  46. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-blocking-validator.md +0 -0
  47. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-coverage-check.md +0 -0
  48. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-cross-cutting.md +0 -0
  49. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-feedback-verifier.md +0 -0
  50. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-lite.md +0 -0
  51. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-planner.md +0 -0
  52. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-preflight-router.md +0 -0
  53. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-prior-art.md +0 -0
  54. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-scope.md +0 -0
  55. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-deployment.md +0 -0
  56. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-frontend.md +0 -0
  57. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-infra.md +0 -0
  58. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
  59. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-observability.md +0 -0
  60. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
  61. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-security.md +0 -0
  62. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
  63. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-sql.md +0 -0
  64. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-subagent.md +0 -0
  65. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-tests-and-docs.md +0 -0
  66. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-writer.md +0 -0
  67. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts_runtime.py +0 -0
  68. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/repo_provision.py +0 -0
  69. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/review_tools.py +0 -0
  70. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/scope.py +0 -0
  71. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/__init__.py +0 -0
  72. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/http.py +0 -0
  73. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/models.py +0 -0
  74. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/precheck.py +0 -0
  75. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/resolver.py +0 -0
  76. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/session.py +0 -0
  77. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/sql.py +0 -0
  78. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/sqlite.py +0 -0
  79. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/pyproject.toml +0 -0
  80. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/008_add_code_reviews.sql +0 -0
  81. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/009_add_reviewer_version.sql +0 -0
  82. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/010_add_review_patterns.sql +0 -0
  83. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/011_add_review_progress_columns.sql +0 -0
  84. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/015_create_agent_runs.sql +0 -0
  85. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/016_add_agent_runs_failure_reason.sql +0 -0
  86. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/017_add_precheck_rules.sql +0 -0
  87. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
  88. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/__init__.py +0 -0
  89. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/conftest.py +0 -0
  90. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/golden/review_response.schema.json +0 -0
  91. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/__init__.py +0 -0
  92. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_backend_contract.py +0 -0
  93. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_http.py +0 -0
  94. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_models.py +0 -0
  95. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_resolver.py +0 -0
  96. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_session.py +0 -0
  97. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_sql.py +0 -0
  98. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_sqlite_backend.py +0 -0
  99. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_actions_scanner.py +0 -0
  100. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_argus_review_local.py +0 -0
  101. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_bench.py +0 -0
  102. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_bench_config_guard.py +0 -0
  103. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_catchup_gate.py +0 -0
  104. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_args.py +0 -0
  105. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_output_contract.py +0 -0
  106. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_post_review.py +0 -0
  107. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_preflight.py +0 -0
  108. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_prompts.py +0 -0
  109. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_config.py +0 -0
  110. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_conftest.py +0 -0
  111. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_dotenv_utils.py +0 -0
  112. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_gemini_cache.py +0 -0
  113. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_github_client_checks_signal.py +0 -0
  114. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_github_client_write.py +0 -0
  115. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_fetch_diff.py +0 -0
  116. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_get_llm_temperature.py +0 -0
  117. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_http_guards.py +0 -0
  118. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_precheck.py +0 -0
  119. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_preflight_image_bump.py +0 -0
  120. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_progress.py +0 -0
  121. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_storage_resolution.py +0 -0
  122. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_timeout_surfacing.py +0 -0
  123. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_js_scanner.py +0 -0
  124. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_llm_models.py +0 -0
  125. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_llm_models_override.py +0 -0
  126. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_llm_pricing.py +0 -0
  127. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_migration_scanner.py +0 -0
  128. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_models.py +0 -0
  129. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_multi_round.py +0 -0
  130. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_openai_client.py +0 -0
  131. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_openai_runner.py +0 -0
  132. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_output_models.py +0 -0
  133. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_packaging.py +0 -0
  134. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_plan_review.py +0 -0
  135. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_engine.py +0 -0
  136. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_engine_integration.py +0 -0
  137. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_sarif.py +0 -0
  138. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_shadow.py +0 -0
  139. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_shadow_integration.py +0 -0
  140. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_prompts.py +0 -0
  141. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_repo_provision.py +0 -0
  142. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_review_patterns_integration.py +0 -0
  143. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_review_tools.py +0 -0
  144. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_context7.py +0 -0
  145. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_context_usage.py +0 -0
  146. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_helpers.py +0 -0
  147. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_new.py +0 -0
  148. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_turn_budget.py +0 -0
  149. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_scanner_utils.py +0 -0
  150. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_scope.py +0 -0
  151. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_scope_gate.py +0 -0
  152. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_sdk_bundled_cli_version.py +0 -0
  153. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_secrets_scanner.py +0 -0
  154. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_specialist_validation.py +0 -0
  155. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_stage_costs.py +0 -0
  156. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_storage_precheck.py +0 -0
  157. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_structured_output_methods.py +0 -0
  158. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_terraform_scanner.py +0 -0
  159. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_terraform_scanner_integration.py +0 -0
  160. {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_workflow_lint_scanner.py +0 -0
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3] - 2026-10-22
11
+
12
+ ### Changed
13
+
14
+ - Gemini reviewer sessions now retry ONCE on a transient failure (HTTP 429/500/502/503/504, `RESOURCE_EXHAUSTED`/`UNAVAILABLE`/`DEADLINE_EXCEEDED`/`INTERNAL`, or an httpx timeout/connect/read/remote-protocol error) that happens before the model returned any response. The cooldown depends on the error: 45-60 s jitter for 429/`RESOURCE_EXHAUSTED` (per-minute quota windows), 5-10 s for 5xx and network errors; a server `Retry-After`/`retryDelay` >= 5 s is honored; never more than 60 s. A daily-quota 429 (`PerDay` quota or a retry delay over 120 s) and auth, permission, bad-request and not-found errors are never retried. Overridable via `ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S`, `ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S` and `ARGUS_GEMINI_RETRY_MAX_WAIT_S`.
15
+ - A failed Gemini session now logs a WARNING with the exception type, HTTP code/status and a scrubbed, 300-character-truncated message, and `SessionResult.failure_detail` carries a short label such as `429 RESOURCE_EXHAUSTED`. `failure_reason` stays `worker_crashed`; degraded-coverage note text is unchanged. The existing ERROR log for a failed `generate_content` call is also scrubbed of key-shaped strings.
16
+
10
17
  ## [0.3.2] - 2026-10-21
11
18
 
12
19
  ### Added
@@ -441,7 +448,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
441
448
  packaged set.
442
449
  - `argus --version`, `argus prompts list`, and `argus prompts export`.
443
450
 
444
- [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...HEAD
451
+ [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...HEAD
452
+ [0.3.3]: https://github.com/redesignhealth/argus-review/compare/v0.3.2...v0.3.3
445
453
  [0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
446
454
  [0.3.1]: https://github.com/redesignhealth/argus-review/compare/v0.3.0...v0.3.1
447
455
  [0.3.0]: https://github.com/redesignhealth/argus-review/compare/v0.2.11...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-code-review
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Self-orchestrated PR review agent using LangGraph + Claude Agent SDK
5
5
  Project-URL: Repository, https://github.com/redesignhealth/argus-review
6
6
  Project-URL: Issues, https://github.com/redesignhealth/argus-review/issues
@@ -144,7 +144,10 @@ from __future__ import annotations
144
144
  import asyncio
145
145
  import json
146
146
  import logging
147
+ import os
148
+ import random
147
149
  import re
150
+ from dataclasses import dataclass
148
151
  from datetime import datetime, timezone
149
152
  from typing import Any
150
153
 
@@ -208,6 +211,171 @@ _FORCE_TOOL_CONFIG = types.ToolConfig(
208
211
  # uncaught.
209
212
  _HTTP_TIMEOUT_FRACTION = 0.8
210
213
 
214
+ # ---------------------------------------------------------------------------
215
+ # One-shot retry of transient Gemini failures
216
+ # ---------------------------------------------------------------------------
217
+ # A session is retried AT MOST ONCE, and only when it failed with a transient
218
+ # error (HTTP 429/500/502/503/504, RESOURCE_EXHAUSTED/UNAVAILABLE/
219
+ # DEADLINE_EXCEEDED/INTERNAL, or an httpx timeout/connect/read/remote-protocol
220
+ # error) before the model returned ANY response (i.e. on turn 0), so the retry
221
+ # restarts the session cleanly from the beginning. Later failures are not
222
+ # retried. The cooldown depends on the error kind (see `_retry_decision`):
223
+ # 45-60 s jitter for 429/RESOURCE_EXHAUSTED (per-minute quota windows), 5-10 s
224
+ # for 5xx/network errors; a server Retry-After/retryDelay >= 5 s is honored;
225
+ # never more than 60 s. Daily-quota 429s are not retried. Overridable via
226
+ # ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S, ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S
227
+ # and ARGUS_GEMINI_RETRY_MAX_WAIT_S (read per call, so tests can set them to 0).
228
+ # The 60 s worst case is far inside ARGUS_SESSION_TIMEOUT (default 1500 s), and
229
+ # the retry is skipped if <25% of that timeout remains.
230
+ _RETRY_RATE_LIMIT_MIN_S = 45.0 # 429 / RESOURCE_EXHAUSTED: per-minute windows
231
+ _RETRY_RATE_LIMIT_MAX_S = 60.0
232
+ _RETRY_SHORT_MIN_S = 5.0 # 5xx / UNAVAILABLE / DEADLINE_EXCEEDED / network
233
+ _RETRY_SHORT_MAX_S = 10.0
234
+ _RETRY_MAX_WAIT_S = 60.0 # hard cap on any single wait, incl. a server Retry-After
235
+ _RETRY_AFTER_MIN_S = 5.0 # a server delay below this is ignored (jitter used)
236
+ # A server-suggested delay above this means the quota window is not a
237
+ # per-minute one (minutes/hours): fail fast rather than retry.
238
+ _RETRY_GIVEUP_DELAY_S = 120.0
239
+ # Do not retry when less than this fraction of the session timeout remains
240
+ # (e.g. the first attempt already burned its HTTP timeout).
241
+ _RETRY_MIN_REMAINING_FRACTION = 0.25
242
+ _TRANSIENT_HTTP_CODES = frozenset({429, 500, 502, 503, 504})
243
+ _TRANSIENT_STATUSES = frozenset(
244
+ {"RESOURCE_EXHAUSTED", "UNAVAILABLE", "DEADLINE_EXCEEDED", "INTERNAL"}
245
+ )
246
+ _TRANSIENT_HTTPX = (
247
+ httpx.TimeoutException,
248
+ httpx.ConnectError,
249
+ httpx.ReadError,
250
+ httpx.RemoteProtocolError,
251
+ )
252
+ _DETAIL_MAX_CHARS = 300
253
+ _SECRET_RE = re.compile(
254
+ r"(AIza[0-9A-Za-z_\-]{20,}|(?i:(?:api[_-]?key|key|token)=)[^&\s\"']+|"
255
+ r"(?i:bearer\s+)[A-Za-z0-9._\-]+|sk-[A-Za-z0-9_\-]{16,})"
256
+ )
257
+
258
+
259
+ def _env_float(name: str, default: float) -> float:
260
+ raw = os.environ.get(name)
261
+ if raw is None or raw == "":
262
+ return default
263
+ try:
264
+ return max(0.0, float(raw))
265
+ except ValueError:
266
+ return default
267
+
268
+
269
+ @dataclass
270
+ class _Progress:
271
+ """Mutable per-session state shared between attempts and the failure handler."""
272
+
273
+ responses: int = 0 # successful generate_content responses received
274
+ retried: bool = False
275
+ retry_wait_s: float = 0.0
276
+
277
+
278
+ def _scrub(text: str) -> str:
279
+ """Redact anything key-shaped and truncate for logging."""
280
+ text = _SECRET_RE.sub("***", text).replace("\n", " ")
281
+ return text[:_DETAIL_MAX_CHARS]
282
+
283
+
284
+ def _error_code_status(exc: BaseException) -> tuple[int | None, str | None]:
285
+ """Best-effort (HTTP code, status string) for an SDK/transport exception."""
286
+ if isinstance(exc, genai_errors.APIError):
287
+ code = getattr(exc, "code", None)
288
+ status = getattr(exc, "status", None)
289
+ return (
290
+ code if isinstance(code, int) else None,
291
+ status if isinstance(status, str) else None,
292
+ )
293
+ if isinstance(exc, httpx.HTTPStatusError):
294
+ return exc.response.status_code, None
295
+ return None, None
296
+
297
+
298
+ def _is_transient_error(exc: BaseException) -> bool:
299
+ code, status = _error_code_status(exc)
300
+ if code in _TRANSIENT_HTTP_CODES or (status or "").upper() in _TRANSIENT_STATUSES:
301
+ return True
302
+ if isinstance(exc, (genai_errors.APIError, httpx.HTTPStatusError)):
303
+ return False # a definite API answer that is not transient (400/401/403/404...)
304
+ return isinstance(exc, _TRANSIENT_HTTPX)
305
+
306
+
307
+ def _retry_after_seconds(exc: BaseException) -> float | None:
308
+ """Server-suggested delay from a Retry-After header or RetryInfo.retryDelay."""
309
+ response = getattr(exc, "response", None)
310
+ headers = getattr(response, "headers", None)
311
+ try:
312
+ raw = headers.get("retry-after") if headers is not None else None
313
+ if raw is not None:
314
+ return max(0.0, float(raw))
315
+ except (TypeError, ValueError, AttributeError):
316
+ pass
317
+ details = getattr(exc, "details", None)
318
+ err = details.get("error", details) if isinstance(details, dict) else None
319
+ items = err.get("details") if isinstance(err, dict) else None
320
+ for item in items if isinstance(items, list) else []:
321
+ delay = item.get("retryDelay") if isinstance(item, dict) else None
322
+ if isinstance(delay, str) and delay.endswith("s"):
323
+ try:
324
+ return max(0.0, float(delay[:-1]))
325
+ except ValueError:
326
+ pass
327
+ return None
328
+
329
+
330
+ def _is_daily_quota(exc: BaseException, server_delay: float | None) -> bool:
331
+ """True when a 429 reports a per-day quota or a many-minutes retry delay."""
332
+ if server_delay is not None and server_delay > _RETRY_GIVEUP_DELAY_S:
333
+ return True
334
+ return bool(re.search(r"per\s*day|perday", str(exc), re.IGNORECASE))
335
+
336
+
337
+ def _retry_decision(exc: BaseException) -> tuple[str, float] | None:
338
+ """``(kind, wait_seconds)`` if ``exc`` should be retried once, else ``None``.
339
+
340
+ Kinds: ``rate_limit`` (429/RESOURCE_EXHAUSTED, 45-60 s jitter),
341
+ ``server`` (5xx/UNAVAILABLE/DEADLINE_EXCEEDED/INTERNAL) and ``network``
342
+ (5-10 s jitter). A server Retry-After/retryDelay >= 5 s replaces the
343
+ jittered value; the wait never exceeds 60 s. A daily-quota 429 is never
344
+ retried.
345
+ """
346
+ if not _is_transient_error(exc):
347
+ return None
348
+ code, status = _error_code_status(exc)
349
+ server_delay = _retry_after_seconds(exc)
350
+ if code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED":
351
+ if _is_daily_quota(exc, server_delay):
352
+ return None
353
+ kind = "rate_limit"
354
+ lo = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MIN_S", _RETRY_RATE_LIMIT_MIN_S)
355
+ hi = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MAX_S", _RETRY_RATE_LIMIT_MAX_S)
356
+ else:
357
+ kind = "server" if code is not None or status else "network"
358
+ lo = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MIN_S", _RETRY_SHORT_MIN_S)
359
+ hi = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MAX_S", _RETRY_SHORT_MAX_S)
360
+ cap = _env_float("ARGUS_GEMINI_RETRY_MAX_WAIT_S", _RETRY_MAX_WAIT_S)
361
+ wait = random.uniform(lo, max(lo, hi)) # noqa: S311 - jitter, not security
362
+ if server_delay is not None and server_delay >= _RETRY_AFTER_MIN_S:
363
+ wait = server_delay
364
+ return kind, min(wait, cap)
365
+
366
+
367
+ def _failure_detail(exc: BaseException) -> str:
368
+ """Short, key-free label such as ``429 RESOURCE_EXHAUSTED`` or ``ConnectError``."""
369
+ code, status = _error_code_status(exc)
370
+ detail = " ".join(p for p in (str(code) if code is not None else "", status or "") if p)
371
+ detail = detail or type(exc).__name__
372
+ if (code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED") and _is_daily_quota(
373
+ exc, _retry_after_seconds(exc)
374
+ ):
375
+ detail += " (daily quota)"
376
+ return detail
377
+
378
+
211
379
  # Matches the fenced-json extraction argus.helpers.parse_review_result
212
380
  # already applies for the Claude path.
213
381
  _FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
@@ -599,6 +767,7 @@ async def _run_turns(
599
767
  repo_root: str,
600
768
  timeout_s: float,
601
769
  started_at: datetime,
770
+ progress: _Progress | None = None,
602
771
  ) -> SessionResult:
603
772
  """The actual tool-calling loop. Returns ``failure_reason="turn_budget_exhausted"``
604
773
  when the loop ran out of turns without a ``finish_review`` call, else
@@ -736,8 +905,7 @@ async def _run_turns(
736
905
  entry.role if entry else "unknown",
737
906
  model,
738
907
  _turn,
739
- exc,
740
- exc_info=True,
908
+ _scrub(str(exc)),
741
909
  )
742
910
  raise
743
911
  logger.warning(
@@ -760,6 +928,8 @@ async def _run_turns(
760
928
  model=model, contents=contents, config=config
761
929
  )
762
930
 
931
+ if progress is not None:
932
+ progress.responses += 1
763
933
  usage = response.usage_metadata
764
934
  if usage is not None:
765
935
  usage_prompt_total += usage.prompt_token_count or 0
@@ -922,6 +1092,35 @@ async def _run_turns(
922
1092
  )
923
1093
 
924
1094
 
1095
+ async def _run_turns_with_retry(
1096
+ *, progress: _Progress, timeout_s: float, **kwargs: Any
1097
+ ) -> SessionResult:
1098
+ """``_run_turns`` with at most one retry of a transient turn-0 failure."""
1099
+ label = kwargs.get("label") or "unlabeled"
1100
+ t0 = asyncio.get_running_loop().time()
1101
+ try:
1102
+ return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
1103
+ except (genai_errors.APIError, httpx.HTTPError) as exc:
1104
+ remaining = timeout_s - (asyncio.get_running_loop().time() - t0)
1105
+ decision = _retry_decision(exc) if progress.responses == 0 else None
1106
+ if decision is None or remaining < timeout_s * _RETRY_MIN_REMAINING_FRACTION:
1107
+ raise
1108
+ kind, wait = decision
1109
+ if wait >= remaining:
1110
+ raise
1111
+ progress.retried = True
1112
+ progress.retry_wait_s = wait
1113
+ logger.info(
1114
+ "gemini session [%s] retrying after %s (kind=%s) in %.1fs",
1115
+ label,
1116
+ _failure_detail(exc),
1117
+ kind,
1118
+ wait,
1119
+ )
1120
+ await asyncio.sleep(wait)
1121
+ return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
1122
+
1123
+
925
1124
  def _redact_gemini_inputs(inputs: dict[str, Any], **_: Any) -> dict[str, Any]:
926
1125
  """LangSmith ``process_inputs`` hook: redact credentials from Settings."""
927
1126
  if not isinstance(inputs, dict):
@@ -988,9 +1187,11 @@ async def run_session_gemini(
988
1187
  )
989
1188
 
990
1189
  started_at = datetime.now(timezone.utc)
1190
+ progress = _Progress()
991
1191
  try:
992
1192
  return await asyncio.wait_for(
993
- _run_turns(
1193
+ _run_turns_with_retry(
1194
+ progress=progress,
994
1195
  entry=entry,
995
1196
  model=model,
996
1197
  system_prompt=system_prompt,
@@ -1057,12 +1258,15 @@ async def run_session_gemini(
1057
1258
  # identically here, even though this runner has no literal
1058
1259
  # subprocess "worker" the way the Claude Agent SDK path does.
1059
1260
  finished_at = datetime.now(timezone.utc)
1261
+ detail = _failure_detail(exc)
1060
1262
  logger.warning(
1061
- "Gemini session [%s] failed with a non-timeout error after %.1fs (%s: %s)",
1263
+ "Gemini session [%s] failed after %.1fs (%s, %s%s): %s",
1062
1264
  label or "unlabeled",
1063
1265
  (finished_at - started_at).total_seconds(),
1064
1266
  type(exc).__name__,
1065
- exc,
1267
+ detail,
1268
+ ", after one retry" if progress.retried else "",
1269
+ _scrub(str(exc)),
1066
1270
  )
1067
1271
  return SessionResult(
1068
1272
  result_text="",
@@ -1075,4 +1279,5 @@ async def run_session_gemini(
1075
1279
  context7_call_count=0,
1076
1280
  model=model,
1077
1281
  failure_reason="worker_crashed",
1282
+ failure_detail=detail,
1078
1283
  )
@@ -572,6 +572,9 @@ class SessionResult:
572
572
  model: str | None = None
573
573
  failure_reason: Literal["timeout", "worker_crashed", "turn_budget_exhausted"] | None = None
574
574
  timed_out: bool = False
575
+ # Short, key-free reason detail (e.g. "429 RESOURCE_EXHAUSTED") for logs and
576
+ # diagnostics; not persisted and does not affect failure_reason semantics.
577
+ failure_detail: str | None = None
575
578
 
576
579
  def __post_init__(self) -> None:
577
580
  self.timed_out, self.failure_reason = _sync_timed_out_and_failure_reason(
@@ -64,6 +64,10 @@ def _isolate_gemini_cache_dir(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) ->
64
64
  from argus.config import clear_cache as clear_settings_cache
65
65
 
66
66
  monkeypatch.setenv("ARGUS_GEMINI_CACHE_DIR", str(tmp_path / "gemini-cache"))
67
+ # Existing tests must not sleep through the real retry cooldown.
68
+ for name in ("RATE_LIMIT", "COOLDOWN"):
69
+ monkeypatch.setenv(f"ARGUS_GEMINI_RETRY_{name}_MIN_S", "0")
70
+ monkeypatch.setenv(f"ARGUS_GEMINI_RETRY_{name}_MAX_S", "0")
67
71
  clear_settings_cache()
68
72
  yield
69
73
  clear_settings_cache()
@@ -149,7 +153,7 @@ def _make_response(
149
153
  return response
150
154
 
151
155
 
152
- def _make_fake_client(responses: list[MagicMock], cache_name: str | None = None) -> MagicMock:
156
+ def _make_fake_client(responses: list[Any], cache_name: str | None = None) -> MagicMock:
153
157
  """Build a fake genai.Client with .aio.models.generate_content and
154
158
  .caches.create wired up for a test.
155
159
 
@@ -1712,3 +1716,238 @@ class TestGoogleBaseUrlOverride:
1712
1716
  assert all(
1713
1717
  ho.base_url == "https://proxy.example.com/v1beta" for ho in constructed_http_options
1714
1718
  )
1719
+
1720
+
1721
+ # ---------------------------------------------------------------------------
1722
+ # One-shot retry of transient failures
1723
+ # ---------------------------------------------------------------------------
1724
+
1725
+
1726
+ def _api_error(code: int, status: str, cls: type = genai_errors.ClientError) -> Any:
1727
+ return cls(code, {"message": f"boom {status}", "status": status})
1728
+
1729
+
1730
+ async def _run(fake_client: MagicMock, label: str = "retry-test") -> Any:
1731
+ with patch("argus.gemini_runner.genai.Client", return_value=fake_client):
1732
+ return await run_session_gemini(
1733
+ entry=_make_entry(caching="off"),
1734
+ system_prompt="sys",
1735
+ user_message="msg",
1736
+ settings=_make_settings(),
1737
+ label=label,
1738
+ repo_root="/tmp/does-not-need-to-exist",
1739
+ )
1740
+
1741
+
1742
+ class TestTransientRetry:
1743
+ async def test_429_then_success_retries_once(self, caplog: pytest.LogCaptureFixture) -> None:
1744
+ ok = _make_response(calls=[], usage=_make_usage(1, 1), text="fine")
1745
+ client = _make_fake_client([_api_error(429, "RESOURCE_EXHAUSTED"), ok])
1746
+ with caplog.at_level("INFO", logger="argus.gemini_runner"):
1747
+ result = await _run(client)
1748
+ assert result.failure_reason is None
1749
+ assert client.aio.models.generate_content.await_count == 2
1750
+ assert any(
1751
+ "retrying after 429 RESOURCE_EXHAUSTED (kind=rate_limit) in" in r.message
1752
+ for r in caplog.records
1753
+ )
1754
+
1755
+ async def test_503_then_503_only_one_retry_with_detail(
1756
+ self, caplog: pytest.LogCaptureFixture
1757
+ ) -> None:
1758
+ err = _api_error(503, "UNAVAILABLE", genai_errors.ServerError)
1759
+ client = _make_fake_client([err, err, err])
1760
+ with caplog.at_level("INFO", logger="argus.gemini_runner"):
1761
+ result = await _run(client)
1762
+ assert client.aio.models.generate_content.await_count == 2
1763
+ assert result.failure_reason == "worker_crashed"
1764
+ assert result.failure_detail == "503 UNAVAILABLE"
1765
+ warnings = [r for r in caplog.records if r.levelname == "WARNING"]
1766
+ assert any("after one retry" in r.getMessage() for r in warnings)
1767
+
1768
+ @pytest.mark.parametrize(
1769
+ ("code", "status"),
1770
+ [
1771
+ (401, "UNAUTHENTICATED"),
1772
+ (403, "PERMISSION_DENIED"),
1773
+ (400, "INVALID_ARGUMENT"),
1774
+ (404, "NOT_FOUND"),
1775
+ ],
1776
+ )
1777
+ async def test_non_transient_not_retried(self, code: int, status: str) -> None:
1778
+ client = _make_fake_client([_api_error(code, status)])
1779
+ result = await _run(client)
1780
+ assert client.aio.models.generate_content.await_count == 1
1781
+ assert result.failure_reason == "worker_crashed"
1782
+ assert result.failure_detail == f"{code} {status}"
1783
+
1784
+ async def test_httpx_connect_error_is_retried(self) -> None:
1785
+ ok = _make_response(calls=[], usage=_make_usage(1, 1), text="fine")
1786
+ client = _make_fake_client([httpx.ConnectError("reset"), ok])
1787
+ result = await _run(client)
1788
+ assert result.failure_reason is None
1789
+ assert client.aio.models.generate_content.await_count == 2
1790
+
1791
+ async def test_no_retry_after_model_output_already_produced(self) -> None:
1792
+ first = _make_response(calls=[("record_finding", {})], usage=_make_usage(1, 1))
1793
+ client = _make_fake_client(
1794
+ [first, _api_error(503, "UNAVAILABLE", genai_errors.ServerError)]
1795
+ )
1796
+ result = await _run(client)
1797
+ assert client.aio.models.generate_content.await_count == 2 # no third call
1798
+ assert result.failure_reason == "worker_crashed"
1799
+
1800
+ async def test_429_cooldown_jitter_45_to_60(self, monkeypatch: pytest.MonkeyPatch) -> None:
1801
+ from argus.gemini_runner import _retry_decision
1802
+
1803
+ for n in ("MIN", "MAX"):
1804
+ monkeypatch.delenv(f"ARGUS_GEMINI_RETRY_RATE_LIMIT_{n}_S")
1805
+ waits = []
1806
+ for _ in range(200):
1807
+ decision = _retry_decision(_api_error(429, "RESOURCE_EXHAUSTED"))
1808
+ assert decision is not None
1809
+ assert decision[0] == "rate_limit"
1810
+ waits.append(decision[1])
1811
+ assert all(45.0 <= w <= 60.0 for w in waits)
1812
+ assert len(set(waits)) > 1
1813
+
1814
+ async def test_503_and_network_short_cooldown(self, monkeypatch: pytest.MonkeyPatch) -> None:
1815
+ from argus.gemini_runner import _retry_decision
1816
+
1817
+ for n in ("MIN", "MAX"):
1818
+ monkeypatch.delenv(f"ARGUS_GEMINI_RETRY_COOLDOWN_{n}_S")
1819
+ for exc, kind in [
1820
+ (_api_error(503, "UNAVAILABLE", genai_errors.ServerError), "server"),
1821
+ (_api_error(500, "INTERNAL", genai_errors.ServerError), "server"),
1822
+ (httpx.ConnectError("reset"), "network"),
1823
+ ]:
1824
+ for _ in range(50):
1825
+ decision = _retry_decision(exc)
1826
+ assert decision is not None
1827
+ assert decision[0] == kind
1828
+ assert 5.0 <= decision[1] <= 10.0
1829
+
1830
+ async def test_retry_after_honored_and_capped_at_60(
1831
+ self, monkeypatch: pytest.MonkeyPatch
1832
+ ) -> None:
1833
+ for n in ("MIN", "MAX"):
1834
+ monkeypatch.delenv(f"ARGUS_GEMINI_RETRY_RATE_LIMIT_{n}_S")
1835
+ sleeps: list[float] = []
1836
+
1837
+ async def fake_sleep(d: float) -> None:
1838
+ sleeps.append(d)
1839
+
1840
+ monkeypatch.setattr("argus.gemini_runner.asyncio.sleep", fake_sleep)
1841
+ ok = _make_response(calls=[], usage=_make_usage(1, 1), text="fine")
1842
+
1843
+ def err_with_retry_after(value: str) -> Any:
1844
+ e = _api_error(429, "RESOURCE_EXHAUSTED")
1845
+ e.response = MagicMock(headers={"retry-after": value})
1846
+ return e
1847
+
1848
+ await _run(_make_fake_client([err_with_retry_after("10"), ok]))
1849
+ await _run(_make_fake_client([err_with_retry_after("100"), ok]))
1850
+ # < 5 s is ignored in favour of the 45-60 s jitter
1851
+ await _run(_make_fake_client([err_with_retry_after("2"), ok]))
1852
+ assert sleeps[0] == 10.0
1853
+ assert sleeps[1] == 60.0
1854
+ assert 45.0 <= sleeps[2] <= 60.0
1855
+
1856
+ async def test_retry_delay_in_error_details_is_used(
1857
+ self, monkeypatch: pytest.MonkeyPatch
1858
+ ) -> None:
1859
+ sleeps: list[float] = []
1860
+
1861
+ async def fake_sleep(d: float) -> None:
1862
+ sleeps.append(d)
1863
+
1864
+ monkeypatch.setattr("argus.gemini_runner.asyncio.sleep", fake_sleep)
1865
+ err = genai_errors.ClientError(
1866
+ 429,
1867
+ {
1868
+ "error": {
1869
+ "message": "slow down",
1870
+ "status": "RESOURCE_EXHAUSTED",
1871
+ "details": [
1872
+ {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "17s"}
1873
+ ],
1874
+ }
1875
+ },
1876
+ )
1877
+ ok = _make_response(calls=[], usage=_make_usage(1, 1), text="fine")
1878
+ await _run(_make_fake_client([err, ok]))
1879
+ assert sleeps == [17.0]
1880
+
1881
+ @pytest.mark.parametrize(
1882
+ "payload",
1883
+ [
1884
+ {
1885
+ "message": "Quota exceeded for metric ... PerDayPerProject",
1886
+ "status": "RESOURCE_EXHAUSTED",
1887
+ },
1888
+ {
1889
+ "message": "exceeded your daily quota (requests per day)",
1890
+ "status": "RESOURCE_EXHAUSTED",
1891
+ },
1892
+ ],
1893
+ )
1894
+ async def test_daily_quota_429_not_retried(
1895
+ self, payload: dict[str, str], monkeypatch: pytest.MonkeyPatch
1896
+ ) -> None:
1897
+ slept: list[float] = []
1898
+
1899
+ async def fake_sleep(d: float) -> None:
1900
+ slept.append(d)
1901
+
1902
+ monkeypatch.setattr("argus.gemini_runner.asyncio.sleep", fake_sleep)
1903
+ client = _make_fake_client([genai_errors.ClientError(429, payload)])
1904
+ result = await _run(client)
1905
+ assert client.aio.models.generate_content.await_count == 1
1906
+ assert slept == []
1907
+ assert result.failure_reason == "worker_crashed"
1908
+ assert result.failure_detail == "429 RESOURCE_EXHAUSTED (daily quota)"
1909
+
1910
+ async def test_429_with_long_retry_delay_not_retried(self) -> None:
1911
+ err = _api_error(429, "RESOURCE_EXHAUSTED")
1912
+ err.response = MagicMock(headers={"retry-after": "3600"})
1913
+ client = _make_fake_client([err])
1914
+ result = await _run(client)
1915
+ assert client.aio.models.generate_content.await_count == 1
1916
+ assert "daily quota" in (result.failure_detail or "")
1917
+
1918
+ async def test_only_one_retry_sleeps_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
1919
+ slept: list[float] = []
1920
+
1921
+ async def fake_sleep(d: float) -> None:
1922
+ slept.append(d)
1923
+
1924
+ monkeypatch.setattr("argus.gemini_runner.asyncio.sleep", fake_sleep)
1925
+ err = _api_error(429, "RESOURCE_EXHAUSTED")
1926
+ client = _make_fake_client([err, err, err])
1927
+ result = await _run(client)
1928
+ assert client.aio.models.generate_content.await_count == 2
1929
+ assert len(slept) == 1
1930
+ assert result.failure_detail == "429 RESOURCE_EXHAUSTED"
1931
+
1932
+ async def test_retry_info_log_has_kind_and_cooldown(
1933
+ self, caplog: pytest.LogCaptureFixture
1934
+ ) -> None:
1935
+ ok = _make_response(calls=[], usage=_make_usage(1, 1), text="fine")
1936
+ client = _make_fake_client([_api_error(429, "RESOURCE_EXHAUSTED"), ok])
1937
+ with caplog.at_level("INFO", logger="argus.gemini_runner"):
1938
+ await _run(client)
1939
+ assert any("(kind=rate_limit) in" in r.getMessage() for r in caplog.records)
1940
+
1941
+ async def test_failure_log_redacts_keys_and_truncates(
1942
+ self, caplog: pytest.LogCaptureFixture
1943
+ ) -> None:
1944
+ secret = "AIza" + "x" * 35
1945
+ err = genai_errors.ClientError(
1946
+ 401, {"message": f"bad key={secret} " + "y" * 1000, "status": "UNAUTHENTICATED"}
1947
+ )
1948
+ client = _make_fake_client([err])
1949
+ with caplog.at_level("WARNING", logger="argus.gemini_runner"):
1950
+ await _run(client)
1951
+ text = " ".join(r.getMessage() for r in caplog.records)
1952
+ assert secret not in text
1953
+ assert len(text) < 1200