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.
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/CHANGELOG.md +9 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/PKG-INFO +1 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/gemini_runner.py +210 -5
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/runners.py +3 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_gemini_runner.py +240 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/.gitignore +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/LICENSE +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/README.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/bench.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/bench_default.toml +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/cli.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/config.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/coverage.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/dotenv_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/gemini_cache.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/github_client.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/graph.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/helpers.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/output_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/pricing.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/llm/usage.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/openai_client.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/openai_runner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/pipeline_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/actions_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/engine.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/.gitignore +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/README.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package-lock.json +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package.json +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/js_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/migration_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/rules/README.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/sarif.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/scanner_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/secrets_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/shadow.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/terraform_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/workflow_lint_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-blocking-validator.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-coverage-check.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-cross-cutting.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-feedback-verifier.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-lite.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-planner.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-preflight-router.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-prior-art.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-scope.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-deployment.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-frontend.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-infra.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-observability.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-security.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-sql.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-subagent.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-tests-and-docs.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-writer.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts_runtime.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/repo_provision.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/review_tools.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/scope.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/http.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/precheck.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/resolver.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/session.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/sql.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/storage/sqlite.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/pyproject.toml +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/008_add_code_reviews.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/009_add_reviewer_version.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/010_add_review_patterns.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/011_add_review_progress_columns.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/015_create_agent_runs.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/016_add_agent_runs_failure_reason.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/017_add_precheck_rules.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/conftest.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/golden/review_response.schema.json +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_backend_contract.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_http.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_resolver.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_session.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_sql.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/storage/test_sqlite_backend.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_actions_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_argus_review_local.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_bench.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_bench_config_guard.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_catchup_gate.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_args.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_output_contract.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_post_review.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_preflight.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_cli_prompts.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_config.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_conftest.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_dotenv_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_gemini_cache.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_github_client_checks_signal.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_github_client_write.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_fetch_diff.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_get_llm_temperature.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_http_guards.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_precheck.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_preflight_image_bump.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_progress.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_storage_resolution.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_timeout_surfacing.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_js_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_llm_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_llm_models_override.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_llm_pricing.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_migration_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_multi_round.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_openai_client.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_openai_runner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_output_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_packaging.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_plan_review.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_engine.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_engine_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_sarif.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_shadow.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_shadow_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_prompts.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_repo_provision.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_review_patterns_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_review_tools.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_context7.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_context_usage.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_helpers.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_new.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_runners_turn_budget.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_scanner_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_scope.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_scope_gate.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_sdk_bundled_cli_version.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_secrets_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_specialist_validation.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_stage_costs.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_storage_precheck.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_structured_output_methods.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_terraform_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_terraform_scanner_integration.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/eslint.config.js
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package-lock.json
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-blocking-validator.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-coverage-check.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-cross-cutting.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-feedback-verifier.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-preflight-router.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-deployment.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-frontend.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-infra.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-security.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-slackbot.md
RENAMED
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-sql.md
RENAMED
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/argus/prompts/pr-review-tests-and-docs.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/011_add_review_progress_columns.sql
RENAMED
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/016_add_agent_runs_failure_reason.sql
RENAMED
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/schema/018_widen_agent_runs_failure_reason.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/golden/review_response.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_github_client_checks_signal.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_graph_preflight_image_bump.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_engine_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_precheck_shadow_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_review_patterns_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{argus_code_review-0.3.2 → argus_code_review-0.3.3}/tests/test_terraform_scanner_integration.py
RENAMED
|
File without changes
|
|
File without changes
|