driftless 0.2.8__tar.gz → 0.2.10__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 (89) hide show
  1. {driftless-0.2.8 → driftless-0.2.10}/CHANGELOG.md +23 -3
  2. {driftless-0.2.8 → driftless-0.2.10}/PKG-INFO +2 -2
  3. {driftless-0.2.8 → driftless-0.2.10}/README.md +1 -1
  4. {driftless-0.2.8 → driftless-0.2.10}/docs/RELEASE.md +4 -4
  5. {driftless-0.2.8 → driftless-0.2.10}/site/docs.html +1 -1
  6. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/__init__.py +1 -1
  7. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/contract.py +29 -0
  8. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/harness.py +107 -41
  9. {driftless-0.2.8 → driftless-0.2.10}/tests/test_endpoint.py +114 -0
  10. {driftless-0.2.8 → driftless-0.2.10}/.gitignore +0 -0
  11. {driftless-0.2.8 → driftless-0.2.10}/LICENSE +0 -0
  12. {driftless-0.2.8 → driftless-0.2.10}/docs/repair-and-generators.md +0 -0
  13. {driftless-0.2.8 → driftless-0.2.10}/pyproject.toml +0 -0
  14. {driftless-0.2.8 → driftless-0.2.10}/site/assets/app.js +0 -0
  15. {driftless-0.2.8 → driftless-0.2.10}/site/assets/hero-workflow.png +0 -0
  16. {driftless-0.2.8 → driftless-0.2.10}/site/assets/landing.css +0 -0
  17. {driftless-0.2.8 → driftless-0.2.10}/site/assets/runs.css +0 -0
  18. {driftless-0.2.8 → driftless-0.2.10}/site/assets/runs.js +0 -0
  19. {driftless-0.2.8 → driftless-0.2.10}/site/assets/sample-run.json +0 -0
  20. {driftless-0.2.8 → driftless-0.2.10}/site/assets/styles.css +0 -0
  21. {driftless-0.2.8 → driftless-0.2.10}/site/index.html +0 -0
  22. {driftless-0.2.8 → driftless-0.2.10}/site/runs.html +0 -0
  23. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/calibrate.py +0 -0
  24. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/cli.py +0 -0
  25. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/compare.py +0 -0
  26. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/configure.py +0 -0
  27. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/data/model_lifecycle.json +0 -0
  28. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/datasource.py +0 -0
  29. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/datastate.py +0 -0
  30. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/discovery.py +0 -0
  31. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/engine.py +0 -0
  32. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/errors.py +0 -0
  33. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/evaluation.py +0 -0
  34. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/generators.py +0 -0
  35. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/github.py +0 -0
  36. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/init_ci.py +0 -0
  37. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/judges.py +0 -0
  38. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/label_audit.py +0 -0
  39. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/lifecycle.py +0 -0
  40. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/policy.py +0 -0
  41. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/preflight.py +0 -0
  42. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/progress.py +0 -0
  43. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/report.py +0 -0
  44. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/scanner.py +0 -0
  45. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/splits.py +0 -0
  46. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/templates.py +0 -0
  47. {driftless-0.2.8 → driftless-0.2.10}/src/driftless/view.py +0 -0
  48. {driftless-0.2.8 → driftless-0.2.10}/tests/fixtures/live_eval_baseline.json +0 -0
  49. {driftless-0.2.8 → driftless-0.2.10}/tests/fixtures/smoke/driftless.yml +0 -0
  50. {driftless-0.2.8 → driftless-0.2.10}/tests/fixtures/smoke/inputs.jsonl +0 -0
  51. {driftless-0.2.8 → driftless-0.2.10}/tests/fixtures/smoke/labels.jsonl +0 -0
  52. {driftless-0.2.8 → driftless-0.2.10}/tests/regression_metrics.py +0 -0
  53. {driftless-0.2.8 → driftless-0.2.10}/tests/scenarios.py +0 -0
  54. {driftless-0.2.8 → driftless-0.2.10}/tests/test_cli.py +0 -0
  55. {driftless-0.2.8 → driftless-0.2.10}/tests/test_compare.py +0 -0
  56. {driftless-0.2.8 → driftless-0.2.10}/tests/test_contract.py +0 -0
  57. {driftless-0.2.8 → driftless-0.2.10}/tests/test_data_change_gate.py +0 -0
  58. {driftless-0.2.8 → driftless-0.2.10}/tests/test_data_change_regression.py +0 -0
  59. {driftless-0.2.8 → driftless-0.2.10}/tests/test_datasource.py +0 -0
  60. {driftless-0.2.8 → driftless-0.2.10}/tests/test_datastate.py +0 -0
  61. {driftless-0.2.8 → driftless-0.2.10}/tests/test_discovery.py +0 -0
  62. {driftless-0.2.8 → driftless-0.2.10}/tests/test_engine.py +0 -0
  63. {driftless-0.2.8 → driftless-0.2.10}/tests/test_evaluation.py +0 -0
  64. {driftless-0.2.8 → driftless-0.2.10}/tests/test_extraction.py +0 -0
  65. {driftless-0.2.8 → driftless-0.2.10}/tests/test_fetch_provider_models.py +0 -0
  66. {driftless-0.2.8 → driftless-0.2.10}/tests/test_generators.py +0 -0
  67. {driftless-0.2.8 → driftless-0.2.10}/tests/test_github.py +0 -0
  68. {driftless-0.2.8 → driftless-0.2.10}/tests/test_grading_loop.py +0 -0
  69. {driftless-0.2.8 → driftless-0.2.10}/tests/test_harness.py +0 -0
  70. {driftless-0.2.8 → driftless-0.2.10}/tests/test_init_ci.py +0 -0
  71. {driftless-0.2.8 → driftless-0.2.10}/tests/test_judge.py +0 -0
  72. {driftless-0.2.8 → driftless-0.2.10}/tests/test_judge_loop.py +0 -0
  73. {driftless-0.2.8 → driftless-0.2.10}/tests/test_label_audit.py +0 -0
  74. {driftless-0.2.8 → driftless-0.2.10}/tests/test_lifecycle.py +0 -0
  75. {driftless-0.2.8 → driftless-0.2.10}/tests/test_migration_live.py +0 -0
  76. {driftless-0.2.8 → driftless-0.2.10}/tests/test_migration_regression.py +0 -0
  77. {driftless-0.2.8 → driftless-0.2.10}/tests/test_plan_act.py +0 -0
  78. {driftless-0.2.8 → driftless-0.2.10}/tests/test_policy.py +0 -0
  79. {driftless-0.2.8 → driftless-0.2.10}/tests/test_poll_act.py +0 -0
  80. {driftless-0.2.8 → driftless-0.2.10}/tests/test_preflight.py +0 -0
  81. {driftless-0.2.8 → driftless-0.2.10}/tests/test_progress.py +0 -0
  82. {driftless-0.2.8 → driftless-0.2.10}/tests/test_refine.py +0 -0
  83. {driftless-0.2.8 → driftless-0.2.10}/tests/test_refresh_catalog.py +0 -0
  84. {driftless-0.2.8 → driftless-0.2.10}/tests/test_regression_metrics.py +0 -0
  85. {driftless-0.2.8 → driftless-0.2.10}/tests/test_repair_prompt.py +0 -0
  86. {driftless-0.2.8 → driftless-0.2.10}/tests/test_report.py +0 -0
  87. {driftless-0.2.8 → driftless-0.2.10}/tests/test_scanner.py +0 -0
  88. {driftless-0.2.8 → driftless-0.2.10}/tests/test_splits.py +0 -0
  89. {driftless-0.2.8 → driftless-0.2.10}/tests/test_view.py +0 -0
@@ -17,6 +17,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
17
 
18
18
  ---
19
19
 
20
+ ## [0.2.10] - 2026-07-01
21
+
22
+ ### Added
23
+
24
+ - **P5.2 endpoint retry/backoff** — `run.endpoint_retries` (0–10) and
25
+ `run.endpoint_retry_backoff_seconds` retry transient HTTP (429/502/503/504)
26
+ and network errors with exponential backoff per input record.
27
+
28
+ ---
29
+
30
+ ## [0.2.9] - 2026-07-01
31
+
32
+ ### Added
33
+
34
+ - **P5.2 endpoint concurrency** — optional `run.endpoint_concurrency` (1–32,
35
+ default 1) runs endpoint POSTs in parallel via `ThreadPoolExecutor`; output
36
+ line order always matches the input file.
37
+
38
+ ---
39
+
20
40
  ## [0.2.8] - 2026-07-01
21
41
 
22
42
  ### Added
@@ -164,9 +184,9 @@ First public release on [PyPI](https://pypi.org/project/driftless/0.1.0/).
164
184
  - **Docs** — project overview, repair algorithm spec, 2×2 migration methodology,
165
185
  Poetry + Dependabot product framing.
166
186
 
167
- [Unreleased]: https://github.com/driftless-dev/driftless/compare/v0.2.8...HEAD
168
- [0.2.8]: https://github.com/driftless-dev/driftless/releases/tag/v0.2.8
169
- [0.2.7]: https://github.com/driftless-dev/driftless/compare/v0.2.7...v0.2.8
187
+ [Unreleased]: https://github.com/driftless-dev/driftless/compare/v0.2.10...HEAD
188
+ [0.2.10]: https://github.com/driftless-dev/driftless/releases/tag/v0.2.10
189
+ [0.2.9]: https://github.com/driftless-dev/driftless/compare/v0.2.9...v0.2.10
170
190
  [0.2.4]: https://github.com/driftless-dev/driftless/compare/v0.2.4...v0.2.5
171
191
  [0.2.3]: https://github.com/driftless-dev/driftless/compare/v0.2.3...v0.2.4
172
192
  [0.2.2]: https://github.com/driftless-dev/driftless/compare/v0.2.2...v0.2.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driftless
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: Keep prompts in sync when model or eval data changes — Poetry-style lock regeneration, Dependabot-style PRs.
5
5
  Project-URL: Homepage, https://github.com/driftless-dev/driftless
6
6
  Project-URL: Repository, https://github.com/driftless-dev/driftless
@@ -133,7 +133,7 @@ can run in CI. See `.github/workflows/` for a scheduled deprecation scan, weekly
133
133
  `plan --act` triage, and manually-triggered migration workflows.
134
134
 
135
135
  ```yaml
136
- - uses: driftless-dev/driftless@v0.2.8
136
+ - uses: driftless-dev/driftless@v0.2.10
137
137
  with:
138
138
  command: scan
139
139
  ```
@@ -94,7 +94,7 @@ can run in CI. See `.github/workflows/` for a scheduled deprecation scan, weekly
94
94
  `plan --act` triage, and manually-triggered migration workflows.
95
95
 
96
96
  ```yaml
97
- - uses: driftless-dev/driftless@v0.2.8
97
+ - uses: driftless-dev/driftless@v0.2.10
98
98
  with:
99
99
  command: scan
100
100
  ```
@@ -153,7 +153,7 @@ After a release, users can pin the composite Action by release tag
153
153
  (`action.yml` lives at the repo root — no `/action` path segment):
154
154
 
155
155
  ```yaml
156
- - uses: driftless-dev/driftless@v0.2.8
156
+ - uses: driftless-dev/driftless@v0.2.10
157
157
  with:
158
158
  command: scan
159
159
  ```
@@ -161,9 +161,9 @@ After a release, users can pin the composite Action by release tag
161
161
  Or pin the PyPI package in the Action input:
162
162
 
163
163
  ```yaml
164
- - uses: driftless-dev/driftless@v0.2.8
164
+ - uses: driftless-dev/driftless@v0.2.10
165
165
  with:
166
- version: "==0.2.8"
166
+ version: "==0.2.10"
167
167
  command: migrate
168
168
  ```
169
169
 
@@ -171,7 +171,7 @@ Optionally maintain a floating **`v1`** tag on the latest stable minor release
171
171
  (point it at the current release tag after each publish):
172
172
 
173
173
  ```bash
174
- git tag -f v1 v0.2.8 && git push origin v1 --force
174
+ git tag -f v1 v0.2.10 && git push origin v1 --force
175
175
  ```
176
176
 
177
177
  Update [`action.yml`](../action.yml) default `version` input when cutting releases.
@@ -428,7 +428,7 @@ driftless view -w support_classifier</code></pre>
428
428
  <span class="tok-k">runs-on</span>: ubuntu-latest
429
429
  <span class="tok-k">steps</span>:
430
430
  - <span class="tok-k">uses</span>: actions/checkout@v4
431
- - <span class="tok-k">uses</span>: driftless-dev/driftless@v0.2.8
431
+ - <span class="tok-k">uses</span>: driftless-dev/driftless@v0.2.10
432
432
  <span class="tok-k">with</span>:
433
433
  <span class="tok-k">command</span>: <span class="tok-s">plan</span></code></pre>
434
434
  <p>A scheduled <code class="inline">plan</code> gates CI when a deprecated model needs attention; a manually-triggered <code class="inline">migrate</code> opens a PR (or an issue when blocked) with the evidence attached.</p>
@@ -1,3 +1,3 @@
1
1
  """driftless: Dependabot for LLM models."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.10"
@@ -68,6 +68,14 @@ class RunSpec(StrictModel):
68
68
  # the shell runner, which uses ``{{ model }}`` substitution / env_var).
69
69
  model_param: str | None = None
70
70
  timeout_seconds: int = 1800
71
+ # For endpoints: max parallel POSTs (1 = sequential). Output order always
72
+ # matches input order regardless of completion order.
73
+ endpoint_concurrency: int = 1
74
+ # For endpoints: extra attempts after a transient failure (429/502/503/504 or
75
+ # network/timeout). 0 = fail on first error.
76
+ endpoint_retries: int = 0
77
+ # Base seconds for exponential backoff between endpoint retries (doubled each attempt).
78
+ endpoint_retry_backoff_seconds: float = 1.0
71
79
 
72
80
  @field_validator("command", "endpoint")
73
81
  @classmethod
@@ -76,6 +84,27 @@ class RunSpec(StrictModel):
76
84
  raise ValueError("must not be blank")
77
85
  return v
78
86
 
87
+ @field_validator("endpoint_concurrency")
88
+ @classmethod
89
+ def _endpoint_concurrency_range(cls, v: int) -> int:
90
+ if v < 1 or v > 32:
91
+ raise ValueError("run.endpoint_concurrency must be between 1 and 32")
92
+ return v
93
+
94
+ @field_validator("endpoint_retries")
95
+ @classmethod
96
+ def _endpoint_retries_range(cls, v: int) -> int:
97
+ if v < 0 or v > 10:
98
+ raise ValueError("run.endpoint_retries must be between 0 and 10")
99
+ return v
100
+
101
+ @field_validator("endpoint_retry_backoff_seconds")
102
+ @classmethod
103
+ def _endpoint_retry_backoff_range(cls, v: float) -> float:
104
+ if v < 0 or v > 60:
105
+ raise ValueError("run.endpoint_retry_backoff_seconds must be between 0 and 60")
106
+ return v
107
+
79
108
  @model_validator(mode="after")
80
109
  def _one_runner(self) -> "RunSpec":
81
110
  if not self.command and not self.endpoint:
@@ -16,6 +16,7 @@ import sys
16
16
  import time
17
17
  import urllib.error
18
18
  import urllib.request
19
+ from concurrent.futures import ThreadPoolExecutor
19
20
  from dataclasses import dataclass, field
20
21
  from pathlib import Path
21
22
 
@@ -198,6 +199,72 @@ def _read_jsonl(path: Path) -> list[dict]:
198
199
  return records
199
200
 
200
201
 
202
+ def _retryable_http_status(code: int) -> bool:
203
+ return code in {429, 502, 503, 504}
204
+
205
+
206
+ def _endpoint_post_record(
207
+ index: int,
208
+ rec: dict,
209
+ *,
210
+ endpoint: str,
211
+ model: str,
212
+ model_param: str,
213
+ headers: dict[str, str],
214
+ timeout: float,
215
+ id_field: str | None,
216
+ retries: int = 0,
217
+ retry_backoff: float = 1.0,
218
+ ) -> str:
219
+ """POST one input record and return the output JSONL line."""
220
+ body = dict(rec)
221
+ body[model_param] = model
222
+ payload = json.dumps(body).encode("utf-8")
223
+ max_attempts = 1 + retries
224
+ text: str | None = None
225
+
226
+ for attempt in range(max_attempts):
227
+ try:
228
+ text = _http_post(endpoint, payload, headers, timeout)
229
+ break
230
+ except urllib.error.HTTPError as exc:
231
+ if attempt < max_attempts - 1 and _retryable_http_status(exc.code):
232
+ if exc.fp:
233
+ exc.read()
234
+ time.sleep(retry_backoff * (2**attempt))
235
+ continue
236
+ raise HarnessError(
237
+ f"endpoint returned HTTP {exc.code} on record {index}",
238
+ hint=_tail(exc.read().decode("utf-8", "replace")) if exc.fp else str(exc),
239
+ ) from exc
240
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
241
+ if attempt < max_attempts - 1:
242
+ time.sleep(retry_backoff * (2**attempt))
243
+ continue
244
+ raise HarnessError(
245
+ f"endpoint request failed on record {index}: {endpoint}",
246
+ hint=str(getattr(exc, "reason", exc)),
247
+ ) from exc
248
+
249
+ assert text is not None
250
+
251
+ try:
252
+ obj = json.loads(text)
253
+ except json.JSONDecodeError as exc:
254
+ raise HarnessError(
255
+ f"endpoint returned non-JSON on record {index}",
256
+ hint=_tail(text) or "expected a JSON object per record",
257
+ ) from exc
258
+ if not isinstance(obj, dict):
259
+ raise HarnessError(
260
+ f"endpoint response on record {index} must be a JSON object",
261
+ hint=f"got {type(obj).__name__}",
262
+ )
263
+ if id_field and id_field not in obj and id_field in rec:
264
+ obj[id_field] = rec[id_field]
265
+ return json.dumps(obj)
266
+
267
+
201
268
  def _run_endpoint(
202
269
  workflow: Workflow, model: str, *, cwd: Path, output_path: Path
203
270
  ) -> RunResult:
@@ -207,7 +274,10 @@ def _run_endpoint(
207
274
  ``run.model_param`` (default ``"model"``); the JSON response object is written
208
275
  as the corresponding output record. When ``eval.id_field`` is set and the
209
276
  response omits it, the input's id is copied through so output<->label
210
- alignment still works.
277
+ alignment still works. Use ``run.endpoint_concurrency`` (>1) to POST in
278
+ parallel; output line order always matches the input file. Transient HTTP
279
+ (429/502/503/504) and network errors honor ``run.endpoint_retries`` with
280
+ exponential backoff from ``run.endpoint_retry_backoff_seconds``.
211
281
  """
212
282
  run = workflow.run
213
283
  input_path = (cwd / run.input_path).resolve()
@@ -225,48 +295,43 @@ def _run_endpoint(
225
295
  if token:
226
296
  headers["Authorization"] = f"Bearer {token}"
227
297
 
228
- out_lines: list[str] = []
298
+ endpoint = run.endpoint
299
+ if endpoint is None:
300
+ raise HarnessError(
301
+ "no endpoint URL is configured",
302
+ hint="set run.endpoint in the contract",
303
+ )
304
+
305
+ concurrency = run.endpoint_concurrency
306
+ timeout = float(run.timeout_seconds)
307
+ retries = run.endpoint_retries
308
+ retry_backoff = run.endpoint_retry_backoff_seconds
229
309
  start = time.monotonic()
230
- for i, rec in enumerate(records, start=1):
231
- body = dict(rec)
232
- body[model_param] = model
233
- endpoint = run.endpoint
234
- if endpoint is None:
235
- raise HarnessError(
236
- "no endpoint URL is configured",
237
- hint="set run.endpoint in the contract",
238
- )
239
- try:
240
- text = _http_post(
241
- endpoint, json.dumps(body).encode("utf-8"), headers, run.timeout_seconds
242
- )
243
- except urllib.error.HTTPError as exc:
244
- raise HarnessError(
245
- f"endpoint returned HTTP {exc.code} on record {i}",
246
- hint=_tail(exc.read().decode("utf-8", "replace")) if exc.fp else str(exc),
247
- ) from exc
248
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
249
- raise HarnessError(
250
- f"endpoint request failed on record {i}: {run.endpoint}",
251
- hint=str(getattr(exc, "reason", exc)),
252
- ) from exc
253
310
 
254
- try:
255
- obj = json.loads(text)
256
- except json.JSONDecodeError as exc:
257
- raise HarnessError(
258
- f"endpoint returned non-JSON on record {i}",
259
- hint=_tail(text) or "expected a JSON object per record",
260
- ) from exc
261
- if not isinstance(obj, dict):
262
- raise HarnessError(
263
- f"endpoint response on record {i} must be a JSON object",
264
- hint=f"got {type(obj).__name__}",
265
- )
266
- if id_field and id_field not in obj and id_field in rec:
267
- obj[id_field] = rec[id_field]
268
- out_lines.append(json.dumps(obj))
311
+ def post_one(index: int, rec: dict) -> tuple[int, str]:
312
+ line = _endpoint_post_record(
313
+ index,
314
+ rec,
315
+ endpoint=endpoint,
316
+ model=model,
317
+ model_param=model_param,
318
+ headers=headers,
319
+ timeout=timeout,
320
+ id_field=id_field,
321
+ retries=retries,
322
+ retry_backoff=retry_backoff,
323
+ )
324
+ return index, line
325
+
326
+ if concurrency <= 1 or len(records) <= 1:
327
+ indexed_lines = [post_one(i, rec) for i, rec in enumerate(records, start=1)]
328
+ else:
329
+ workers = min(concurrency, len(records))
330
+ pairs = [(i, rec) for i, rec in enumerate(records, start=1)]
331
+ with ThreadPoolExecutor(max_workers=workers) as pool:
332
+ indexed_lines = list(pool.map(lambda pair: post_one(pair[0], pair[1]), pairs))
269
333
 
334
+ out_lines = [line for _, line in indexed_lines]
270
335
  duration = time.monotonic() - start
271
336
  output_path.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
272
337
  return RunResult(
@@ -274,7 +339,8 @@ def _run_endpoint(
274
339
  output_path=output_path,
275
340
  returncode=0,
276
341
  duration_seconds=duration,
277
- stdout=f"{len(out_lines)} records via {run.endpoint}",
342
+ stdout=f"{len(out_lines)} records via {endpoint} "
343
+ f"(concurrency={concurrency}, retries={retries})",
278
344
  stderr="",
279
345
  env_overrides={},
280
346
  )
@@ -118,3 +118,117 @@ def test_run_rejects_both_command_and_endpoint():
118
118
  RunSpec.model_validate(
119
119
  {"command": "echo hi", "endpoint": "http://x", "input_path": "i", "output_path": "o"}
120
120
  )
121
+
122
+
123
+ def test_endpoint_concurrency_must_be_in_range():
124
+ with pytest.raises(ValueError, match="endpoint_concurrency"):
125
+ RunSpec.model_validate(
126
+ {
127
+ "endpoint": "http://x",
128
+ "input_path": "i",
129
+ "output_path": "o",
130
+ "endpoint_concurrency": 0,
131
+ }
132
+ )
133
+
134
+
135
+ def test_endpoint_concurrency_preserves_order_and_parallelizes(tmp_path, monkeypatch):
136
+ import threading
137
+ import time
138
+
139
+ records = [{"id": str(i), "text": "x"} for i in range(8)]
140
+ _write_inputs(tmp_path, records)
141
+ lock = threading.Lock()
142
+ in_flight = 0
143
+ max_in_flight = 0
144
+
145
+ def fake_post(url, payload, headers, timeout):
146
+ nonlocal in_flight, max_in_flight
147
+ body = json.loads(payload.decode("utf-8"))
148
+ with lock:
149
+ in_flight += 1
150
+ max_in_flight = max(max_in_flight, in_flight)
151
+ time.sleep(0.03)
152
+ with lock:
153
+ in_flight -= 1
154
+ return json.dumps({"id": body["id"], "label": "ok"})
155
+
156
+ monkeypatch.setattr(harness, "_http_post", fake_post)
157
+ result = run_workflow(
158
+ _endpoint_workflow(endpoint_concurrency=4), "m1", cwd=tmp_path
159
+ )
160
+
161
+ assert result.ok
162
+ assert max_in_flight >= 3
163
+ rows = _read_out(result.output_path)
164
+ assert [r["id"] for r in rows] == [str(i) for i in range(8)]
165
+ assert "concurrency=4" in result.stdout
166
+ assert "retries=0" in result.stdout
167
+
168
+
169
+ def test_endpoint_retries_transient_http_errors(tmp_path, monkeypatch):
170
+ import urllib.error
171
+
172
+ _write_inputs(tmp_path, [{"id": "a", "text": "x"}])
173
+ attempts = {"n": 0}
174
+ sleeps: list[float] = []
175
+
176
+ def fake_post(*args, **kwargs):
177
+ attempts["n"] += 1
178
+ if attempts["n"] < 3:
179
+ raise urllib.error.HTTPError(
180
+ url="http://x", code=503, msg="unavailable", hdrs={}, fp=None
181
+ )
182
+ return json.dumps({"id": "a", "label": "ok"})
183
+
184
+ monkeypatch.setattr(harness, "_http_post", fake_post)
185
+ monkeypatch.setattr(harness.time, "sleep", lambda s: sleeps.append(s))
186
+
187
+ result = run_workflow(
188
+ _endpoint_workflow(endpoint_retries=2, endpoint_retry_backoff_seconds=0.5),
189
+ "m1",
190
+ cwd=tmp_path,
191
+ )
192
+
193
+ assert result.ok
194
+ assert attempts["n"] == 3
195
+ assert sleeps == [0.5, 1.0]
196
+
197
+
198
+ def test_endpoint_does_not_retry_client_errors(tmp_path, monkeypatch):
199
+ import urllib.error
200
+
201
+ _write_inputs(tmp_path, [{"id": "a", "text": "x"}])
202
+ attempts = {"n": 0}
203
+
204
+ def fake_post(*args, **kwargs):
205
+ attempts["n"] += 1
206
+ raise urllib.error.HTTPError(
207
+ url="http://x", code=400, msg="bad request", hdrs={}, fp=None
208
+ )
209
+
210
+ monkeypatch.setattr(harness, "_http_post", fake_post)
211
+
212
+ with pytest.raises(HarnessError, match="HTTP 400"):
213
+ run_workflow(_endpoint_workflow(endpoint_retries=3), "m1", cwd=tmp_path)
214
+ assert attempts["n"] == 1
215
+
216
+
217
+ def test_endpoint_retries_network_errors(tmp_path, monkeypatch):
218
+ import urllib.error
219
+
220
+ _write_inputs(tmp_path, [{"id": "a", "text": "x"}])
221
+ attempts = {"n": 0}
222
+
223
+ def fake_post(*args, **kwargs):
224
+ attempts["n"] += 1
225
+ if attempts["n"] == 1:
226
+ raise urllib.error.URLError("connection reset")
227
+ return json.dumps({"id": "a", "label": "ok"})
228
+
229
+ monkeypatch.setattr(harness, "_http_post", fake_post)
230
+ monkeypatch.setattr(harness.time, "sleep", lambda _s: None)
231
+
232
+ result = run_workflow(_endpoint_workflow(endpoint_retries=1), "m1", cwd=tmp_path)
233
+ assert result.ok
234
+ assert attempts["n"] == 2
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