Skip to content

Commit da6a0ad

Browse files
authored
Add Rust native retry policy (#54)
Signed-off-by: lucarlig <luca.carlig@ibm.com>
1 parent 2caa2a7 commit da6a0ad

11 files changed

Lines changed: 213 additions & 20 deletions

File tree

.github/workflows/ci-install-built-wheel.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,6 @@ jobs:
4747
needs: test
4848
uses: ./.github/workflows/release-rust-python-package.yaml
4949
with:
50-
tag: retry-with-backoff-v0.1.1
50+
tag: retry-with-backoff-v0.2.0
5151
repository: testpypi
5252
publish_enabled: false

.github/workflows/ci-rust-python-package.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,6 @@ jobs:
247247
id-token: write
248248
uses: ./.github/workflows/release-rust-python-package.yaml
249249
with:
250-
tag: retry-with-backoff-v0.1.1
250+
tag: retry-with-backoff-v0.2.0
251251
repository: testpypi
252252
publish_enabled: false

.github/workflows/release-rust-python-package.yaml

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ jobs:
4242
runs-on: ubuntu-latest
4343
outputs:
4444
plugin: ${{ steps.resolve.outputs.plugin }}
45+
slug: ${{ steps.resolve.outputs.plugin }}
4546
plugin_path: ${{ steps.resolve.outputs.plugin_path }}
4647
wheel_matrix: ${{ steps.resolve.outputs.wheel_matrix }}
4748
publish_env: ${{ steps.resolve.outputs.publish_env }}
49+
publish_enabled: ${{ steps.resolve.outputs.publish_enabled }}
4850
checkout_ref: ${{ steps.resolve.outputs.checkout_ref }}
4951
tag_on_main: ${{ steps.resolve.outputs.tag_on_main }}
5052
steps:
@@ -62,16 +64,25 @@ jobs:
6264
env:
6365
TAG_INPUT: ${{ inputs.tag }}
6466
REPOSITORY_INPUT: ${{ inputs.repository }}
67+
PUBLISH_ENABLED: ${{ inputs.publish_enabled }}
6568
run: |
6669
set -euo pipefail
6770
git fetch --force origin "refs/heads/main:refs/remotes/origin/main"
6871
if [[ -n "${TAG_INPUT}" ]]; then
6972
tag="${TAG_INPUT}"
7073
repository="${REPOSITORY_INPUT}"
71-
git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}"
72-
git show-ref --verify --quiet "refs/tags/${tag}"
73-
checkout_ref="refs/tags/${tag}"
74-
tag_ref="refs/tags/${tag}"
74+
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
75+
git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}"
76+
git show-ref --verify --quiet "refs/tags/${tag}"
77+
checkout_ref="refs/tags/${tag}"
78+
tag_ref="refs/tags/${tag}"
79+
elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" && "${PUBLISH_ENABLED}" == "false" ]]; then
80+
checkout_ref="${GITHUB_SHA}"
81+
tag_ref="${GITHUB_SHA}"
82+
else
83+
echo "Release tag ${tag} does not exist" >&2
84+
exit 1
85+
fi
7586
else
7687
tag="${GITHUB_REF_NAME}"
7788
repository="pypi"
@@ -100,6 +111,11 @@ jobs:
100111
echo "wheel_matrix=${wheel_matrix}"
101112
echo "checkout_ref=${checkout_ref}"
102113
echo "tag_on_main=${tag_on_main}"
114+
if [[ "${PUBLISH_ENABLED}" == "false" ]]; then
115+
echo "publish_enabled=false"
116+
else
117+
echo "publish_enabled=true"
118+
fi
103119
if [[ "${repository}" == "testpypi" ]]; then
104120
echo "publish_env=testpypi"
105121
else
@@ -265,7 +281,7 @@ jobs:
265281
fi
266282
267283
publish:
268-
if: ${{ (github.event_name != 'workflow_call' || inputs.publish_enabled) && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}
284+
if: ${{ needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}
269285
needs: [resolve, build-wheel, build-sdist]
270286
runs-on: ubuntu-latest
271287
environment: ${{ needs.resolve.outputs.publish_env }}

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/rust/python-package/retry_with_backoff/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "retry_with_backoff"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
edition.workspace = true
55
authors.workspace = true
66
license.workspace = true

plugins/rust/python-package/retry_with_backoff/Makefile

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@ help:
55
PACKAGE_NAME := cpex-retry-with-backoff
66
WHEEL_PREFIX := cpex_retry_with_backoff
77
CARGO := cargo
8+
CARGO_PACKAGE := retry_with_backoff
9+
PLUGIN_SLUG := retry_with_backoff
10+
REPO_ROOT := ../../../..
811
STUB_FILES := cpex_retry_with_backoff/__init__.pyi cpex_retry_with_backoff/retry_with_backoff_rust/__init__.pyi
912
WHEEL_DIR := ../../../../target/wheels
13+
COVERAGE_REPORT := coverage/cobertura.xml
14+
COVERAGE_MIN := 90.00
15+
CARGO_LLVM_COV_VERSION := 0.8.4
1016

1117
GREEN := \033[0;32m
1218
YELLOW := \033[0;33m
@@ -101,8 +107,9 @@ clean-all: clean
101107
# help: verify - Verify plugin installation
102108
# help: check-all - Run fmt-check + clippy + Rust tests
103109
# help: ci-build - Run CI build/static verification without integration tests
110+
# help: coverage - Generate Rust coverage and enforce the 90% floor
104111
# help: ci - Run the full CI-equivalent plugin verification flow
105-
.PHONY: verify check-all ci-build ci pre-commit
112+
.PHONY: verify check-all ci-build coverage ci pre-commit
106113

107114
verify:
108115
@uv run python -c "from cpex_retry_with_backoff import retry_with_backoff_rust; print('retry_with_backoff_rust available')" || echo "retry_with_backoff_rust not installed — run: make install"
@@ -112,7 +119,24 @@ check-all: fmt-check clippy test-unit
112119

113120
ci-build: check-all verify-stubs build install-wheel
114121

115-
ci: ci-build test-integration
122+
coverage:
123+
@echo "$(GREEN)Generating Rust coverage...$(NC)"
124+
mkdir -p $(REPO_ROOT)/coverage
125+
rustup component add llvm-tools-preview
126+
cargo llvm-cov --version >/dev/null 2>&1 || cargo install cargo-llvm-cov --version $(CARGO_LLVM_COV_VERSION) --locked
127+
cargo llvm-cov clean --workspace
128+
eval "$$(cargo llvm-cov show-env --sh)" && \
129+
export CARGO_TARGET_DIR="$${CARGO_LLVM_COV_TARGET_DIR}/llvm-cov-target" && \
130+
export CARGO_LLVM_COV_BUILD_DIR="$${CARGO_TARGET_DIR}" && \
131+
export LLVM_PROFILE_FILE="$${CARGO_TARGET_DIR}/cpex-plugins-%p-%10m.profraw" && \
132+
mkdir -p "$${CARGO_TARGET_DIR}" && \
133+
uv run maturin develop && \
134+
$(CARGO) test -p $(CARGO_PACKAGE) && \
135+
$(MAKE) test-integration && \
136+
env -u CARGO_TARGET_DIR -u CARGO_LLVM_COV_BUILD_DIR -u CARGO_LLVM_COV_TARGET_DIR -u LLVM_PROFILE_FILE cargo llvm-cov report -p $(CARGO_PACKAGE) --cobertura --output-path $(REPO_ROOT)/$(COVERAGE_REPORT)
137+
python3 $(REPO_ROOT)/tools/plugin_catalog.py coverage-check $(REPO_ROOT) $(COVERAGE_REPORT) $(COVERAGE_MIN) '["$(PLUGIN_SLUG)"]'
138+
139+
ci: ci-build test-integration coverage
116140
@echo "$(GREEN)CI verification passed$(NC)"
117141

118142
pre-commit: check-all

plugins/rust/python-package/retry_with_backoff/cpex_retry_with_backoff/plugin-manifest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
description: "High-performance retry policy engine with exponential backoff, jitter, per-tool overrides, and retry metadata for transient tool and resource failures"
22
author: "ContextForge Contributors"
3-
version: "0.1.1"
3+
version: "0.2.0"
44
kind: "cpex_retry_with_backoff.retry_with_backoff.RetryWithBackoffPlugin"
55
available_hooks:
66
- "tool_post_invoke"

plugins/rust/python-package/retry_with_backoff/cpex_retry_with_backoff/retry_with_backoff.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import random
1010
import time
1111
from dataclasses import dataclass
12-
from typing import Any
12+
from typing import Any, Optional
1313

1414
from pydantic import BaseModel, Field
1515

@@ -168,6 +168,24 @@ def __init__(self, config: PluginConfig) -> None:
168168
for tool_name, overrides in self._cfg.tool_overrides.items()
169169
}
170170

171+
def to_rust_native_policy(self, tool_name: str, ceiling: int) -> Optional[dict[str, Any]]:
172+
raw_cfg = RetryConfig(**(self.config.config or {}))
173+
cfg = _cfg_for(raw_cfg, tool_name)
174+
if cfg.max_retries > ceiling:
175+
cfg = cfg.model_copy(update={"max_retries": ceiling})
176+
177+
if cfg.check_text_content:
178+
return None
179+
180+
return {
181+
"kind": "retry_with_backoff",
182+
"maxRetries": int(cfg.max_retries),
183+
"backoffBaseMs": int(cfg.backoff_base_ms),
184+
"maxBackoffMs": int(cfg.max_backoff_ms),
185+
"retryOnStatus": list(cfg.retry_on_status),
186+
"jitter": bool(cfg.jitter),
187+
}
188+
171189
async def tool_post_invoke(
172190
self,
173191
payload: ToolPostInvokePayload,

plugins/tests/retry_with_backoff/test_integration.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,82 @@ def test_other_tool_not_affected_by_override(self):
164164
assert result.max_retries == 3
165165

166166

167+
class TestRustNativePolicy:
168+
def test_simple_config_returns_wire_policy(self):
169+
plugin = make_plugin(
170+
{
171+
"max_retries": 3,
172+
"backoff_base_ms": 150,
173+
"max_backoff_ms": 3000,
174+
"retry_on_status": [500, 503],
175+
"jitter": False,
176+
}
177+
)
178+
179+
assert plugin.to_rust_native_policy("tool_a", ceiling=10) == {
180+
"kind": "retry_with_backoff",
181+
"maxRetries": 3,
182+
"backoffBaseMs": 150,
183+
"maxBackoffMs": 3000,
184+
"retryOnStatus": [500, 503],
185+
"jitter": False,
186+
}
187+
188+
def test_ceiling_clamps_max_retries(self):
189+
plugin = make_plugin({"max_retries": 3})
190+
191+
policy = plugin.to_rust_native_policy("tool_a", ceiling=1)
192+
193+
assert policy is not None
194+
assert policy["maxRetries"] == 1
195+
196+
def test_per_tool_override_is_merged(self):
197+
plugin = make_plugin(
198+
{
199+
"max_retries": 3,
200+
"backoff_base_ms": 200,
201+
"max_backoff_ms": 5000,
202+
"retry_on_status": [500],
203+
"jitter": True,
204+
"tool_overrides": {
205+
"slow_api": {
206+
"max_retries": 2,
207+
"backoff_base_ms": 750,
208+
"retry_on_status": [429, 503],
209+
"jitter": False,
210+
}
211+
},
212+
}
213+
)
214+
215+
assert plugin.to_rust_native_policy("slow_api", ceiling=10) == {
216+
"kind": "retry_with_backoff",
217+
"maxRetries": 2,
218+
"backoffBaseMs": 750,
219+
"maxBackoffMs": 5000,
220+
"retryOnStatus": [429, 503],
221+
"jitter": False,
222+
}
223+
224+
def test_per_tool_override_max_retries_is_clamped(self):
225+
plugin = make_plugin(
226+
{
227+
"max_retries": 3,
228+
"tool_overrides": {"slow_api": {"max_retries": 8}},
229+
}
230+
)
231+
232+
policy = plugin.to_rust_native_policy("slow_api", ceiling=2)
233+
234+
assert policy is not None
235+
assert policy["maxRetries"] == 2
236+
237+
def test_check_text_content_returns_none(self):
238+
plugin = make_plugin({"check_text_content": True})
239+
240+
assert plugin.to_rust_native_policy("tool_a", ceiling=10) is None
241+
242+
167243
class TestPluginInit:
168244
def test_max_retries_clamped_to_gateway_ceiling(self):
169245
with patch("cpex_retry_with_backoff.retry_with_backoff.get_settings") as mock_settings:

tests/test_plugin_catalog.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,7 +2127,7 @@ def test_ci_workflow_uses_make_targets_for_plugin_checks(self) -> None:
21272127
self.assertIn("working-directory: plugins/rust/python-package/${{ matrix.plugin }}", workflow)
21282128
self.assertIn("release-validation:", workflow)
21292129
self.assertIn("uses: ./.github/workflows/release-rust-python-package.yaml", workflow)
2130-
self.assertIn("tag: retry-with-backoff-v0.1.1", workflow)
2130+
self.assertIn("tag: retry-with-backoff-v0.2.0", workflow)
21312131
self.assertIn("repository: testpypi", workflow)
21322132
self.assertIn("publish_enabled: false", workflow)
21332133
self.assertNotIn("tools/plugin_catalog.py ci-selection-field", workflow)
@@ -2438,6 +2438,42 @@ def test_coverage_check_reports_per_plugin_percentages(self) -> None:
24382438
self.assertEqual(payload["plugins"]["alpha"]["line_rate"], 50.0)
24392439
self.assertEqual(payload["plugins"]["beta"]["line_rate"], 75.0)
24402440

2441+
def test_coverage_check_accepts_windows_paths(self) -> None:
2442+
with tempfile.TemporaryDirectory() as tmpdir:
2443+
root = Path(tmpdir)
2444+
(root / "Cargo.toml").write_text(
2445+
'[workspace]\nmembers = ["plugins/rust/python-package/alpha"]\n'
2446+
'[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n'
2447+
)
2448+
self._create_plugin(root, "alpha")
2449+
report = root / "coverage.xml"
2450+
report.write_text(
2451+
textwrap.dedent(
2452+
r"""
2453+
<coverage>
2454+
<packages>
2455+
<package name="plugins.rust.python-package.alpha.src">
2456+
<classes>
2457+
<class filename="D:\a\cpex-plugins\cpex-plugins\plugins\rust\python-package\alpha\src\lib.rs">
2458+
<lines>
2459+
<line number="1" hits="1"/>
2460+
<line number="2" hits="0"/>
2461+
</lines>
2462+
</class>
2463+
</classes>
2464+
</package>
2465+
</packages>
2466+
</coverage>
2467+
"""
2468+
).strip()
2469+
)
2470+
2471+
result = run_catalog("coverage-check", str(root), str(report), "50.0", '["alpha"]')
2472+
2473+
self.assertEqual(result.returncode, 0, result.stderr)
2474+
payload = json.loads(result.stdout)
2475+
self.assertEqual(payload["plugins"]["alpha"]["line_rate"], 50.0)
2476+
24412477
def test_coverage_check_rejects_plugin_with_no_counted_lines(self) -> None:
24422478
with tempfile.TemporaryDirectory() as tmpdir:
24432479
root = Path(tmpdir)
@@ -2683,7 +2719,10 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None:
26832719
self.assertIn('"${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" -v', workflow)
26842720
self.assertNotIn('PYTHONPATH="${GITHUB_WORKSPACE}/${{ needs.resolve.outputs.plugin_path }}/tests"', workflow)
26852721
self.assertEqual(workflow.count("cargo run --bin stub_gen"), 1)
2686-
self.assertIn('git show-ref --verify --quiet "refs/tags/${tag}"', workflow)
2722+
self.assertIn('git ls-remote --exit-code --tags origin "refs/tags/${tag}"', workflow)
2723+
self.assertIn('elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" && "${PUBLISH_ENABLED}" == "false" ]]; then', workflow)
2724+
self.assertIn('checkout_ref="${GITHUB_SHA}"', workflow)
2725+
self.assertIn('echo "Release tag ${tag} does not exist" >&2', workflow)
26872726
self.assertIn("python3 tools/plugin_catalog.py release-info .", workflow)
26882727
self.assertIn('if [[ -n "${TAG_INPUT}" ]]; then', workflow)
26892728
self.assertIn("workflow_call:", workflow)
@@ -2692,6 +2731,10 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None:
26922731
self.assertIn('git fetch --force origin "refs/heads/main:refs/remotes/origin/main"', workflow)
26932732
self.assertIn('if git merge-base --is-ancestor "${tag_ref}" "refs/remotes/origin/main"; then', workflow)
26942733
self.assertIn("tag_on_main: ${{ steps.resolve.outputs.tag_on_main }}", workflow)
2734+
self.assertIn("slug: ${{ steps.resolve.outputs.plugin }}", workflow)
2735+
self.assertIn("publish_enabled: ${{ steps.resolve.outputs.publish_enabled }}", workflow)
2736+
self.assertIn('echo "publish_enabled=false"', workflow)
2737+
self.assertIn('echo "publish_enabled=true"', workflow)
26952738
self.assertIn(
26962739
'wheel_matrix="$(python3 -c \'import json; print(json.dumps([{',
26972740
workflow,
@@ -2713,7 +2756,7 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None:
27132756
self.assertIn("runs-on: ${{ matrix.runner }}", workflow)
27142757
self.assertIn("name: wheel-${{ matrix.platform }}", workflow)
27152758
self.assertIn(
2716-
"if: ${{ (github.event_name != 'workflow_call' || inputs.publish_enabled) && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}",
2759+
"if: ${{ needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}",
27172760
workflow,
27182761
)
27192762
self.assertNotIn("matrix.", preflight_section)
@@ -2950,6 +2993,19 @@ def test_root_plugin_test_uses_plugin_ci_target(self) -> None:
29502993
self.assertIn("make ci", makefile)
29512994
self.assertNotIn("make install && make test-all", makefile)
29522995

2996+
def test_retry_make_ci_enforces_local_coverage_floor(self) -> None:
2997+
plugin_dir = REPO_ROOT / "plugins" / "rust" / "python-package" / "retry_with_backoff"
2998+
makefile = (plugin_dir / "Makefile").read_text()
2999+
self.assertIn("ci: ci-build test-integration coverage", makefile)
3000+
self.assertIn("rustup component add llvm-tools-preview", makefile)
3001+
self.assertIn("cargo install cargo-llvm-cov --version $(CARGO_LLVM_COV_VERSION) --locked", makefile)
3002+
self.assertIn("cargo llvm-cov clean --workspace", makefile)
3003+
self.assertIn("cargo llvm-cov report -p $(CARGO_PACKAGE)", makefile)
3004+
self.assertIn(
3005+
"python3 $(REPO_ROOT)/tools/plugin_catalog.py coverage-check $(REPO_ROOT) $(COVERAGE_REPORT) $(COVERAGE_MIN)",
3006+
makefile,
3007+
)
3008+
29533009
def test_secrets_detection_keeps_scanner_module_internal(self) -> None:
29543010
lib_rs = (
29553011
REPO_ROOT

0 commit comments

Comments
 (0)