netlab 0.6.1__py3-none-any.whl

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 (65) hide show
  1. netlab/__init__.py +10 -0
  2. netlab/artifacts.py +152 -0
  3. netlab/autoresearch/__init__.py +1 -0
  4. netlab/autoresearch/analysis_loop.py +134 -0
  5. netlab/autoresearch/backend.py +270 -0
  6. netlab/autoresearch/cli.py +295 -0
  7. netlab/autoresearch/dcbb_config.py +272 -0
  8. netlab/autoresearch/dcbb_failures.py +274 -0
  9. netlab/autoresearch/experiment_log.py +278 -0
  10. netlab/autoresearch/generation_loop.py +217 -0
  11. netlab/autoresearch/hypothesis.py +236 -0
  12. netlab/autoresearch/hypothesis_manager.py +199 -0
  13. netlab/autoresearch/memory.py +315 -0
  14. netlab/autoresearch/metrics_report.py +115 -0
  15. netlab/autoresearch/objective.py +170 -0
  16. netlab/autoresearch/prompt.py +288 -0
  17. netlab/autoresearch/runner.py +490 -0
  18. netlab/autoresearch/scenario_generator.py +380 -0
  19. netlab/autoresearch/scenario_validation.py +244 -0
  20. netlab/autoresearch/structural_analysis.py +297 -0
  21. netlab/autoresearch/sweep.py +570 -0
  22. netlab/cli.py +421 -0
  23. netlab/comparison.py +299 -0
  24. netlab/experiment.py +218 -0
  25. netlab/log_config.py +44 -0
  26. netlab/metrics/__init__.py +0 -0
  27. netlab/metrics/aggregate.py +22 -0
  28. netlab/metrics/analysis.py +89 -0
  29. netlab/metrics/bac.py +245 -0
  30. netlab/metrics/batch.py +706 -0
  31. netlab/metrics/common.py +87 -0
  32. netlab/metrics/comparisons.py +78 -0
  33. netlab/metrics/costpower.py +131 -0
  34. netlab/metrics/distributions.py +42 -0
  35. netlab/metrics/iterops.py +64 -0
  36. netlab/metrics/latency.py +295 -0
  37. netlab/metrics/matrixdump.py +108 -0
  38. netlab/metrics/msd.py +43 -0
  39. netlab/metrics/paired.py +84 -0
  40. netlab/metrics/plot_bac_delta_vs_baseline.py +197 -0
  41. netlab/metrics/plot_cross_seed_bac.py +168 -0
  42. netlab/metrics/plot_cross_seed_iterops.py +139 -0
  43. netlab/metrics/plot_cross_seed_latency.py +151 -0
  44. netlab/metrics/plot_significance_heatmap.py +120 -0
  45. netlab/metrics/reporting.py +347 -0
  46. netlab/metrics/seed_data.py +98 -0
  47. netlab/metrics/sps.py +115 -0
  48. netlab/metrics/summary.py +825 -0
  49. netlab/metrics/validation.py +193 -0
  50. netlab/metrics_failure.py +199 -0
  51. netlab/pipeline.py +207 -0
  52. netlab/results.py +240 -0
  53. netlab/run_record.py +46 -0
  54. netlab/runtime.py +54 -0
  55. netlab/scenario.py +162 -0
  56. netlab/simulation.py +284 -0
  57. netlab/tasks.py +58 -0
  58. netlab/topology.py +83 -0
  59. netlab/visualize.py +569 -0
  60. netlab-0.6.1.dist-info/METADATA +274 -0
  61. netlab-0.6.1.dist-info/RECORD +65 -0
  62. netlab-0.6.1.dist-info/WHEEL +5 -0
  63. netlab-0.6.1.dist-info/entry_points.txt +2 -0
  64. netlab-0.6.1.dist-info/licenses/LICENSE +21 -0
  65. netlab-0.6.1.dist-info/top_level.txt +1 -0
netlab/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """NetLab: metrics and experiment tooling for NetGraph scenarios."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("netlab")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0.dev" # Package metadata is unavailable before installation.
9
+
10
+ __all__ = ["__version__"]
netlab/artifacts.py ADDED
@@ -0,0 +1,152 @@
1
+ """Atomic artifact publication and content-based provenance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ from contextlib import contextmanager
9
+ from importlib.metadata import distribution
10
+ from importlib.util import find_spec
11
+ from pathlib import Path
12
+ from tempfile import NamedTemporaryFile
13
+ from typing import Any, Iterator
14
+
15
+
16
+ @contextmanager
17
+ def atomic_path(path: Path) -> Iterator[Path]:
18
+ """Publish a same-filesystem temporary file only after a successful write."""
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ with NamedTemporaryFile(
21
+ dir=path.parent, prefix=f".{path.name}.", delete=False
22
+ ) as f:
23
+ temporary = Path(f.name)
24
+ try:
25
+ yield temporary
26
+ temporary.replace(path)
27
+ finally:
28
+ temporary.unlink(missing_ok=True)
29
+
30
+
31
+ def write_text_atomic(path: Path, content: str) -> None:
32
+ with atomic_path(path) as temporary:
33
+ temporary.write_text(content, encoding="utf-8")
34
+
35
+
36
+ def write_json_atomic(path: Path, data: Any) -> None:
37
+ write_text_atomic(path, json.dumps(data, indent=2) + "\n")
38
+
39
+
40
+ def write_csv_atomic(path: Path, data: Any) -> None:
41
+ import pandas as pd
42
+
43
+ table = data if isinstance(data, (pd.DataFrame, pd.Series)) else pd.DataFrame(data)
44
+ with atomic_path(path) as temporary:
45
+ table.to_csv(temporary)
46
+
47
+
48
+ def sha256_file(path: Path) -> str:
49
+ with path.open("rb") as stream:
50
+ return hashlib.file_digest(stream, "sha256").hexdigest()
51
+
52
+
53
+ def fingerprint(data: Any) -> str:
54
+ return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
55
+
56
+
57
+ def package_versions() -> dict[str, dict[str, Any]]:
58
+ """Identify installed revisions, including current editable package contents."""
59
+ packages = {}
60
+ for name in ("netlab", "netlab-topogen", "ngraph", "netgraph-core"):
61
+ package = distribution(name)
62
+ record: dict[str, Any] = {"version": package.version}
63
+ direct_url = package.read_text("direct_url.json")
64
+ if direct_url:
65
+ record["source"] = json.loads(direct_url)
66
+ if record["source"].get("dir_info", {}).get("editable"):
67
+ spec = find_spec(name.replace("-", "_"))
68
+ if spec is None or not spec.submodule_search_locations:
69
+ raise ValueError(f"Cannot fingerprint editable package {name}")
70
+ files = {}
71
+ for location in spec.submodule_search_locations:
72
+ root = Path(location)
73
+ for path in sorted(root.rglob("*")):
74
+ if path.is_file() and path.suffix in {".py", ".so", ".pyd"}:
75
+ files[str(path.relative_to(root))] = sha256_file(path)
76
+ record["content_sha256"] = fingerprint(files)
77
+ packages[name] = record
78
+ return packages
79
+
80
+
81
+ def package_provenance() -> dict[str, dict[str, Any]]:
82
+ """Record verified Git or pip VCS identity without local URLs, and use null for unknown Git state."""
83
+ packages = package_versions()
84
+ netlab = packages["netlab"]
85
+ source = netlab.pop("source", {})
86
+ commit = None
87
+ dirty = None
88
+ root = Path(__file__).resolve().parents[1]
89
+ if (root / ".git").exists():
90
+ try:
91
+ status = subprocess.run(
92
+ [
93
+ "git",
94
+ "--no-optional-locks",
95
+ "status",
96
+ "--porcelain",
97
+ "--untracked-files=normal",
98
+ "--ignore-submodules=none",
99
+ ],
100
+ cwd=root,
101
+ capture_output=True,
102
+ text=True,
103
+ check=True,
104
+ )
105
+ dirty = bool(status.stdout)
106
+ if not dirty:
107
+ commit = subprocess.run(
108
+ ["git", "rev-parse", "HEAD"],
109
+ cwd=root,
110
+ capture_output=True,
111
+ text=True,
112
+ check=True,
113
+ ).stdout.strip()
114
+ except (subprocess.CalledProcessError, OSError):
115
+ commit = dirty = None
116
+ elif installed_commit := source.get("vcs_info", {}).get("commit_id"):
117
+ commit = installed_commit
118
+ dirty = False
119
+ netlab.update(commit=commit, dirty=dirty)
120
+ return packages
121
+
122
+
123
+ def ensure_run_identity(path: Path, inputs: Any) -> None:
124
+ """Bind a resumable output directory to exactly one set of inputs."""
125
+ key = fingerprint(inputs)
126
+ if path.exists():
127
+ previous = json.loads(path.read_text(encoding="utf-8"))
128
+ if previous["key"] != key:
129
+ raise ValueError(
130
+ f"Research inputs or dependencies changed: {path}. "
131
+ "Use a new output directory for this experiment."
132
+ )
133
+ else:
134
+ write_json_atomic(path, {"key": key, "inputs": inputs})
135
+
136
+
137
+ def cache_path(artifact: Path) -> Path:
138
+ return artifact.with_suffix(artifact.suffix + ".cache.json")
139
+
140
+
141
+ def cache_matches(artifact: Path, key: str) -> bool:
142
+ try:
143
+ manifest = json.loads(cache_path(artifact).read_text(encoding="utf-8"))
144
+ return manifest["key"] == key and manifest["sha256"] == sha256_file(artifact)
145
+ except (OSError, ValueError, KeyError, TypeError):
146
+ return False
147
+
148
+
149
+ def record_cache(artifact: Path, key: str) -> None:
150
+ write_json_atomic(
151
+ cache_path(artifact), {"key": key, "sha256": sha256_file(artifact)}
152
+ )
@@ -0,0 +1 @@
1
+ # LLM-assisted scenario generation and parameter searches.
@@ -0,0 +1,134 @@
1
+ """Compute simulation metrics and request an LLM interpretation and next experiment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .backend import LLMBackend
8
+ from .metrics_report import build_metrics_report
9
+
10
+
11
+ @dataclass
12
+ class AnalysisResult:
13
+ """Output of the analysis loop."""
14
+
15
+ metrics_report: str
16
+ interpretation: str # LLM-generated explanation
17
+ next_hypothesis: str # LLM-generated suggestion for next experiment
18
+ iterations_used: int = 0
19
+ complete: bool = False
20
+
21
+ def summary(self) -> str:
22
+ lines = self.interpretation.strip().splitlines()
23
+ n_lines = len(lines)
24
+ preview = lines[0][:80] if lines else "(empty)"
25
+ return f"{n_lines} lines, {self.iterations_used} iterations: {preview}..."
26
+
27
+
28
+ _ANALYSIS_SYSTEM_PROMPT = """\
29
+ You are a network reliability engineer analyzing simulation results.
30
+
31
+ Use the supplied metrics report to explain the topology's behavior under
32
+ failure. Distinguish measured results from proposed explanations. Flag
33
+ missing or inconsistent evidence, and do not invent measurements.
34
+
35
+ Keep the analysis concise. Support claims about topology and failure modes
36
+ with the scenario description and reported metrics.
37
+ """
38
+
39
+ _ANALYSIS_PROMPT = """\
40
+ Hypothesis being tested:
41
+ {hypothesis}
42
+
43
+ {metrics_report}
44
+
45
+ Explain these results. For each failure mode:
46
+ 1. Why does BAC have this value? What structural property causes it?
47
+ 2. Does latency degrade under failure, or just bandwidth?
48
+ 3. Are both directions affected equally?
49
+
50
+ Then summarize: what are the key design strengths and weaknesses of this topology?
51
+ """
52
+
53
+ _NEXT_HYPOTHESIS_PROMPT = """\
54
+ Based on this analysis:
55
+
56
+ {interpretation}
57
+
58
+ Original hypothesis:
59
+ {hypothesis}
60
+
61
+ Metrics summary:
62
+ {metrics_summary}
63
+
64
+ Propose the next topology experiment. Consider:
65
+ - What structural variation might improve resilience?
66
+ - What tradeoff hasn't been explored (cost vs redundancy, latency vs bandwidth)?
67
+ - What aspect of the results is surprising or unexplained?
68
+
69
+ Respond with a clear, actionable topology description that can be
70
+ directly used to generate an ngraph scenario. Be specific about
71
+ node counts, link capacities, and failure modes to test.
72
+ """
73
+
74
+
75
+ def run_analysis_loop(
76
+ results: dict,
77
+ hypothesis: str,
78
+ backend: LLMBackend,
79
+ max_iterations: int = 3,
80
+ ) -> AnalysisResult:
81
+ """Build a metrics report, interpret it, and propose the next experiment.
82
+
83
+ Args:
84
+ results: NetGraph simulation results.
85
+ hypothesis: Description of the experiment being analyzed.
86
+ backend: LLM backend for interpretation.
87
+ max_iterations: Maximum attempts to obtain a nonempty interpretation.
88
+ """
89
+ if max_iterations < 1:
90
+ raise ValueError("max_iterations must be positive")
91
+ metrics_report = build_metrics_report(results)
92
+
93
+ interpretation = ""
94
+ attempts_used = 0
95
+ for _attempt in range(1, max_iterations + 1):
96
+ attempts_used += 1
97
+ prompt = _ANALYSIS_PROMPT.format(
98
+ hypothesis=hypothesis,
99
+ metrics_report=metrics_report,
100
+ )
101
+ response = backend.generate(prompt, system=_ANALYSIS_SYSTEM_PROMPT)
102
+ interpretation = response.strip()
103
+ if interpretation:
104
+ break
105
+
106
+ if not interpretation:
107
+ return AnalysisResult(
108
+ metrics_report=metrics_report,
109
+ interpretation="(LLM produced no interpretation)",
110
+ next_hypothesis="",
111
+ iterations_used=max_iterations,
112
+ complete=False,
113
+ )
114
+
115
+ # Limit the next-experiment prompt to a short metrics excerpt.
116
+ metrics_lines = metrics_report.splitlines()
117
+ metrics_summary = "\n".join(metrics_lines[:20])
118
+
119
+ next_prompt = _NEXT_HYPOTHESIS_PROMPT.format(
120
+ interpretation=interpretation,
121
+ hypothesis=hypothesis,
122
+ metrics_summary=metrics_summary,
123
+ )
124
+ next_hypothesis = backend.generate(
125
+ next_prompt, system=_ANALYSIS_SYSTEM_PROMPT
126
+ ).strip()
127
+
128
+ return AnalysisResult(
129
+ metrics_report=metrics_report,
130
+ interpretation=interpretation,
131
+ next_hypothesis=next_hypothesis,
132
+ iterations_used=attempts_used,
133
+ complete=bool(next_hypothesis),
134
+ )
@@ -0,0 +1,270 @@
1
+ """LLM backends for scripted responses, CLI tools, and HTTP APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from abc import ABC, abstractmethod
12
+ from http.client import HTTPException
13
+ from pathlib import Path
14
+
15
+ from netlab.runtime import require_executable
16
+
17
+ SUPPORTED_BACKENDS = ("mock", "claude-cli", "codex-cli", "openai")
18
+ DEFAULT_CLAUDE_MODEL = "opus"
19
+ DEFAULT_CODEX_MODEL = ""
20
+ DEFAULT_OPENAI_MODEL = "gpt-4"
21
+
22
+
23
+ class LLMBackend(ABC):
24
+ """Interface for one text-generation request."""
25
+
26
+ @abstractmethod
27
+ def generate(self, prompt: str, system: str = "") -> str:
28
+ """Return generated text; raise on failure."""
29
+
30
+
31
+ class MockBackend(LLMBackend):
32
+ """Backend that returns scripted responses in order. For testing."""
33
+
34
+ def __init__(self, responses: list[str]) -> None:
35
+ self._responses = list(responses)
36
+ self._index = 0
37
+ self.calls: list[tuple[str, str]] = []
38
+
39
+ def generate(self, prompt: str, system: str = "") -> str:
40
+ self.calls.append((prompt, system))
41
+ if self._index >= len(self._responses):
42
+ raise StopIteration("MockBackend: no more scripted responses")
43
+ response = self._responses[self._index]
44
+ self._index += 1
45
+ return response
46
+
47
+
48
+ class ClaudeCLIBackend(LLMBackend):
49
+ """Backend that calls the `claude` CLI tool via subprocess."""
50
+
51
+ def __init__(
52
+ self,
53
+ model: str = DEFAULT_CLAUDE_MODEL,
54
+ command: str | None = None,
55
+ timeout: float = 120,
56
+ ) -> None:
57
+ self.model = model
58
+ self.command = command
59
+ self.timeout = timeout
60
+
61
+ def generate(self, prompt: str, system: str = "") -> str:
62
+ # Claude Code headless mode uses -p/--print with the prompt as its value.
63
+ executable = require_executable(
64
+ "claude",
65
+ explicit=self.command,
66
+ env_var="CLAUDE_BIN",
67
+ display_name="Claude CLI",
68
+ )
69
+ cmd = [executable, "-p", prompt]
70
+ if self.model:
71
+ cmd.extend(["--model", self.model])
72
+ if system:
73
+ cmd.extend(["--system-prompt", system])
74
+ try:
75
+ result = subprocess.run(
76
+ cmd,
77
+ capture_output=True,
78
+ text=True,
79
+ check=False,
80
+ timeout=self.timeout,
81
+ )
82
+ except subprocess.TimeoutExpired as exc:
83
+ raise RuntimeError(f"Claude CLI timed out after {self.timeout}s") from exc
84
+ except FileNotFoundError as exc:
85
+ raise RuntimeError(
86
+ f"Claude CLI executable not found: {executable}"
87
+ ) from exc
88
+ if result.returncode != 0:
89
+ raise RuntimeError(
90
+ f"claude CLI failed (exit {result.returncode}): {result.stderr}"
91
+ )
92
+ return result.stdout
93
+
94
+
95
+ class CodexCLIBackend(LLMBackend):
96
+ """Generate text with ``codex exec`` in ephemeral, read-only mode.
97
+
98
+ Read the response from the file written by ``--output-last-message``.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ model: str = DEFAULT_CODEX_MODEL,
104
+ command: str | None = None,
105
+ timeout: float = 120,
106
+ ) -> None:
107
+ self.model = model
108
+ self.command = command
109
+ self.timeout = timeout
110
+
111
+ def generate(self, prompt: str, system: str = "") -> str:
112
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
113
+
114
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp:
115
+ output_path = tmp.name
116
+
117
+ try:
118
+ executable = require_executable(
119
+ "codex",
120
+ explicit=self.command,
121
+ env_var="CODEX_BIN",
122
+ display_name="Codex CLI",
123
+ )
124
+ cmd = [
125
+ executable,
126
+ "exec",
127
+ "--ephemeral",
128
+ "--sandbox",
129
+ "read-only",
130
+ "--skip-git-repo-check",
131
+ "-o",
132
+ output_path,
133
+ ]
134
+ if self.model:
135
+ cmd.extend(["-m", self.model])
136
+ cmd.append(full_prompt)
137
+ try:
138
+ result = subprocess.run(
139
+ cmd,
140
+ capture_output=True,
141
+ text=True,
142
+ check=False,
143
+ timeout=self.timeout,
144
+ )
145
+ except subprocess.TimeoutExpired as exc:
146
+ raise RuntimeError(
147
+ f"Codex CLI timed out after {self.timeout}s"
148
+ ) from exc
149
+ except FileNotFoundError as exc:
150
+ raise RuntimeError(
151
+ f"Codex CLI executable not found: {executable}"
152
+ ) from exc
153
+ if result.returncode != 0:
154
+ raise RuntimeError(
155
+ f"codex CLI failed (exit {result.returncode}): {result.stderr}"
156
+ )
157
+ output = Path(output_path).read_text(encoding="utf-8")
158
+ return output
159
+ finally:
160
+ Path(output_path).unlink(missing_ok=True)
161
+
162
+
163
+ class OpenAICompatibleBackend(LLMBackend):
164
+ """Call an OpenAI-compatible HTTP API, retrying 429 and server errors."""
165
+
166
+ # Tests can override retry limits and delay.
167
+ max_retries: int = 3
168
+ base_delay: float = 2.0 # seconds
169
+
170
+ def __init__(
171
+ self,
172
+ base_url: str,
173
+ model: str,
174
+ api_key: str = "",
175
+ timeout: int = 120,
176
+ ) -> None:
177
+ self.base_url = base_url.rstrip("/")
178
+ self.model = model
179
+ self.api_key = api_key
180
+ self.timeout = timeout
181
+
182
+ def generate(self, prompt: str, system: str = "") -> str:
183
+ # base_url may or may not include /v1 — normalize
184
+ base = self.base_url.rstrip("/")
185
+ if base.endswith("/v1"):
186
+ url = f"{base}/chat/completions"
187
+ else:
188
+ url = f"{base}/v1/chat/completions"
189
+ messages: list[dict[str, str]] = []
190
+ if system:
191
+ messages.append({"role": "system", "content": system})
192
+ messages.append({"role": "user", "content": prompt})
193
+
194
+ body = json.dumps({"model": self.model, "messages": messages}).encode()
195
+
196
+ headers: dict[str, str] = {"Content-Type": "application/json"}
197
+ if self.api_key:
198
+ headers["Authorization"] = f"Bearer {self.api_key}"
199
+
200
+ last_exc: BaseException | None = None
201
+ for attempt in range(self.max_retries + 1):
202
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
203
+ try:
204
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
205
+ data = json.loads(resp.read().decode())
206
+ content = data["choices"][0]["message"]["content"]
207
+ if not isinstance(content, str):
208
+ raise RuntimeError("Backend response must contain text content")
209
+ return content
210
+ except urllib.error.HTTPError as exc:
211
+ status = exc.code
212
+ if status == 429:
213
+ retry_after = (
214
+ exc.headers.get("Retry-After") if exc.headers else None
215
+ )
216
+ try:
217
+ delay = (
218
+ min(60.0, max(0.0, float(retry_after)))
219
+ if retry_after is not None
220
+ else self.base_delay * (2**attempt)
221
+ )
222
+ except ValueError:
223
+ delay = self.base_delay * (2**attempt)
224
+ last_exc = exc
225
+ if attempt < self.max_retries:
226
+ time.sleep(delay)
227
+ continue
228
+ raise RuntimeError(
229
+ f"HTTP {status} after {self.max_retries + 1} attempts"
230
+ ) from exc
231
+ elif status >= 500:
232
+ delay = self.base_delay * (2**attempt)
233
+ last_exc = exc
234
+ if attempt < self.max_retries:
235
+ time.sleep(delay)
236
+ continue
237
+ raise RuntimeError(
238
+ f"HTTP {status} after {self.max_retries + 1} attempts"
239
+ ) from exc
240
+ else:
241
+ # 4xx (except 429): fail immediately
242
+ raise RuntimeError(f"HTTP {status}: {exc.reason}") from exc
243
+ except urllib.error.URLError as exc:
244
+ # Connection refused, DNS failure, etc.
245
+ last_exc = exc
246
+ if attempt < self.max_retries:
247
+ delay = self.base_delay * (2**attempt)
248
+ time.sleep(delay)
249
+ continue
250
+ raise RuntimeError(
251
+ f"connection error after {self.max_retries + 1} attempts: {exc.reason}"
252
+ ) from exc
253
+ except (TimeoutError, OSError, HTTPException) as exc:
254
+ # Socket timeout or low-level OS error
255
+ last_exc = exc
256
+ msg = str(exc).lower()
257
+ if "timed out" in msg or isinstance(exc, TimeoutError):
258
+ raise RuntimeError(f"timeout after {self.timeout}s: {exc}") from exc
259
+ # Other OS errors: retry
260
+ if attempt < self.max_retries:
261
+ delay = self.base_delay * (2**attempt)
262
+ time.sleep(delay)
263
+ continue
264
+ raise RuntimeError(
265
+ f"connection error after {self.max_retries + 1} attempts: {exc}"
266
+ ) from exc
267
+
268
+ raise RuntimeError(
269
+ f"Unexpected retry exhaustion: {last_exc}"
270
+ ) # pragma: no cover