Skip to content

feat(experiments): retries, timeouts, failure accounting and resume for experiments and batch evaluation - #2115

Open
Cookie0103 wants to merge 2 commits into
langfuse:mainfrom
Cookie0103:feat/experiment-reliability
Open

Cookie0103 wants to merge 2 commits into
langfuse:mainfrom
Cookie0103:feat/experiment-reliability

Conversation

@Cookie0103

@Cookie0103 Cookie0103 commented Nov 8, 2026 •

Copy link
Copy Markdown

What does this PR do?

Fixes langfuse/langfuse#19902

Langfuse.run_experiment and run_batched_evaluation handle every item as a single attempt and keep failures only as a log line:

  • Failed items disappear from the result. asyncio.gather(..., return_exceptions=True) logs a failed item and drops it. ExperimentResult.item_results contains only the successes, and run evaluators aggregate over that list. A run where half the items crashed therefore reports aggregates over the survivors and gives no indication of what failed.
  • No retry or timeout. _run_task calls the task once, so a transient 429 or 5xx from the model provider fails the item for good. A task or LLM-judge evaluator that hangs blocks the whole run indefinitely. Mapper and evaluator calls in run_batched_evaluation behave the same way.
  • No resume. Dataset experiments already get a deterministic experiment_id, but an interrupted 2,000-item run still has to start over and pay again for every completed task call.
  • No failure budget. A run with a bad API key keeps going until every item has failed.

This PR adds an opt-in ExecutionPolicy. With no policy passed, the span tree, observations, create_score calls and item_results are unchanged. A test pins the default-path span names and the score count.

from langfuse import ExecutionPolicy, RetryPolicy, TimeoutPolicy, FailurePolicy, FileCheckpointStore

result = langfuse.run_experiment(
    name="capitals", run_name="nightly-2026-11-08", data=items, task=task, evaluators=[judge],
    execution=ExecutionPolicy(
        task_retry=RetryPolicy(max_attempts=4),          # 429 / 5xx / timeouts, honours Retry-After
        evaluator_retry=RetryPolicy(max_attempts=2),
        timeouts=TimeoutPolicy(task_timeout=60, evaluator_timeout=30, run_timeout=3600),
        failures=FailurePolicy(max_failure_rate=0.3),    # stop early, cancel in-flight items
        checkpoint=FileCheckpointStore(".lf-checkpoints"), resume=True,
    ),
)
result.failures        # ItemFailure: stage, error, per-attempt records, trace_id
result.execution       # ExecutionSummary: succeeded / failed / retried / resumed / aborted

Design

The new code lives in the private package langfuse/_experiment/. Public names are re-exported from langfuse.experiment and langfuse.

Module Responsibility
policy.py Frozen, validated RetryPolicy, TimeoutPolicy, FailurePolicy and ExecutionPolicy. The default retry predicate reads status_code / response.status_code and SDK error class names (OpenAI/Anthropic/LiteLLM naming) through the MRO, so no provider SDK is imported. It parses Retry-After given in seconds or as an HTTP date, and never retries cancellation.
retrying.py A single retry loop with seeded jitter, shared by run_experiment and run_batched_evaluation.
executor.py ExperimentExecutor handles scheduling under max_concurrency, the failure budget, the run deadline, and cancellation (in-flight items are cancelled, with a bounded wait so a task that swallows CancelledError cannot hang the run). It also handles checkpointing and resume.
checkpoint.py A versioned checkpoint with a sha256 integrity check and a fingerprint of the task name, evaluator names and data digest. InMemoryCheckpointStore and FileCheckpointStore write atomically (temp file, fsync, os.replace) with debounced writes performed through asyncio.to_thread. Filenames are restricted to [A-Za-z0-9_-].
item_key.py Stable per-item identity. The experiment_item_id expression that used to be inlined in client.py moved here, so the trace attribute and the checkpoint key cannot diverge. Duplicate items get deterministic suffixes.
results.py AttemptRecord, ItemFailure, EvaluatorFailure and ExecutionSummary.

Behaviour and trade-offs

  • item_results keeps its meaning (successes only). Failures are added next to it as failures / evaluator_failures.
    • A run evaluator receives failures= only if its signature declares a failures parameter. Existing **kwargs evaluators receive exactly what they received before.
  • Retries appear in traces. Each failed attempt becomes a WARNING experiment-item-task-attempt child observation of the existing task span, so backoff shows up as gaps rather than being folded into one duration. When max_attempts == 1 (the default), no extra observations are created.
  • Resume does not repeat work. Completed items are rebuilt from the checkpoint (output, evaluations, trace id), with no new task call, trace or score. Failed checkpoint items are retried.
    • A changed task, evaluators or data raises CheckpointMismatchError, unless resume="force" is passed.
    • For local data the random experiment_id cannot identify a run. The checkpoint is therefore keyed by (name, run_name), and resume=True without an explicit run_name raises a clear ValueError. The resumed run reuses the experiment id stored in the checkpoint.
  • run_batched_evaluation. The same policies apply to mapper, evaluator and composite calls. When the failure budget is exceeded, paging stops and the method returns completed=False with a valid resume_token and execution.aborted=True.

Limitations

  • A task that ignores cancellation keeps running after its timeout until it returns. The run itself does not wait on it beyond a bounded grace period.
  • Resume reuses the stored outputs and evaluations. It cannot detect that the server-side scores of a completed item were deleted in the meantime.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactor
  • Documentation update
  • Tooling, CI, or repo maintenance

Verification

uv run --frozen pytest -n auto --dist worksteal tests/unit   # 1091 passed, 2 skipped, 18 errors
uv run --frozen ruff check .                                  # All checks passed
uv run --frozen ruff format --check .                         # 605 files already formatted
uv run --frozen mypy langfuse --no-error-summary              # clean

The 18 errors are all in tests/unit/test_prompt.py (Langfuse client is not initialized). They are identical on a clean checkout of main, so they are environment-dependent and not related to this change.

New tests are in tests/unit/test_experiment_execution.py and tests/unit/test_batch_evaluation_execution.py. They run exporter-local, with no server or provider calls, and cover:

  • the retry predicate table: status attributes, Retry-After in seconds and as an HTTP date, class names, and cancellation;
  • backoff bounds;
  • retry then success, with recorded attempts and attempt observations;
  • non-retryable errors;
  • task and evaluator timeouts;
  • failure recording while item_results stays unchanged, including the run-evaluator failures opt-in;
  • the failure budget by count and by rate;
  • run timeout;
  • cancellation writing a checkpoint and re-raising;
  • resume for both dataset and local data, checked with a task call counter and a stable experiment_id;
  • fingerprint mismatch and force;
  • atomic writes, filename sanitisation and integrity checks in the file store;
  • duplicate item keys;
  • the default-path span tree;
  • the batch-evaluation paths.

Checklist

  • I self-reviewed the diff using code_review.md.
  • I added or updated tests for behavior changes.
  • I updated docs, examples, or .env.template if needed. (Public docstrings are updated. Happy to add a docs page in langfuse-docs if you want the feature documented there.)
  • I did not hand-edit generated files; if generated files changed, I used the upstream regeneration path.
  • I did not commit secrets or credentials.

AI disclosure: implemented with AI coding assistance (Claude, Cursor). I reviewed the diff and ran the commands above.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 0/5

This PR needs fixes before merging because it can drop successful results, reuse stale checkpoint results, and continue work after cancellation.

Summary

Adds execution policies, retry records, failure summaries, and checkpoint-based resume for experiments and batch evaluation.

  • Experiment runs can retry work, report failures, and resume completed items.
  • Batch evaluations can retry stages and stop paging with a resume token.

Acknowledged behavior:

  • Cookie0103 intentionally checks batch run timeouts between pages, as pinned by the new test.
  • Cookie0103 documents that tasks may ignore cancellation.
  • Cookie0103 documents that resume cannot detect server-side deletion of existing scores.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Run experiment] --> B[Build item keys and fingerprint]
    B --> C[Load checkpoint]
    C --> D[Rebuild completed results]
    C --> E[Schedule remaining items]
    E --> F[Task retries and timeout]
    F --> G[Item evaluators]
    G --> H[Composite evaluator]
    H --> I[Record result and save checkpoint]
    E --> J[Check failure budget and run deadline]
    D --> K[Order results]
    I --> K
    J --> K
    K --> L[Run evaluators outside executor]
    L --> M[Return experiment result]
Loading

Reviews (1) · Last reviewed commit: "feat(experiments): export checkpoint sto..." · Reviewed by Greptile

Cookie0103 and others added 2 commits November 8, 2026 12:45
…or run_experiment and run_batched_evaluation

run_experiment dropped failed items from item_results with only a log line,
called the task and evaluators once with no timeout, and had no way to
resume an interrupted run. run_batched_evaluation had the same gaps for
mapper and evaluator calls.

Adds an opt-in ExecutionPolicy (private langfuse/_experiment package):
- RetryPolicy with jittered backoff honouring Retry-After and a default
  predicate that reads HTTP status attributes and SDK error class names
- task, evaluator and whole-run timeouts, and a failure budget that stops
  scheduling and cancels in-flight items
- ExperimentResult/BatchEvaluationResult record failures, evaluator
  failures and an ExecutionSummary (item_results is unchanged)
- checkpoint and resume via in-memory or file stores (atomic, debounced,
  off the event loop); completed items are rebuilt without re-running the
  task or duplicating scores
- retry attempts are recorded as child observations of the task span

Without a policy the span tree, scores and results are unchanged.

Refs langfuse/langfuse#19902

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@CLAassistant

CLAassistant commented Nov 8, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment on lines +480 to +489
by_key: Dict[str, ExperimentItemResult] = {}
for r in completed_results:
by_key[_key_for_item(r.item, self.dataset_version)] = r
for r in resumed_results:
by_key[_key_for_item(r.item, self.dataset_version)] = r

ordered: List[ExperimentItemResult] = []
for item, key in zip(self.data, self._item_keys):
if key in by_key:
ordered.append(by_key[key])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate results disappear

Repeated items lose results even without an execution policy. make_item_keys adds occurrence suffixes, but _key_for_item rebuilds each result's key from a one-item list. Results overwrite each other under the unsuffixed key, and the suffixed entries are omitted. Run evaluators then receive fewer results than successful task calls. Keep the assigned key alongside each result instead of recomputing it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_experiment/executor.py
Line: 480-489

Comment:
**Duplicate results disappear**

Repeated items lose results even without an execution policy. `make_item_keys` adds occurrence suffixes, but `_key_for_item` rebuilds each result's key from a one-item list. Results overwrite each other under the unsuffixed key, and the suffixed entries are omitted. Run evaluators then receive fewer results than successful task calls. Keep the assigned key alongside each result instead of recomputing it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +450 to +454
if not self._aborted and self._failure_budget_exceeded(
failed, attempted
):
self._aborted = True
self._abort_reason = "failure budget exceeded"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Requested failure exceptions never arrive

With on_threshold="raise", both runners still stop and return normally when the failure budget trips. The setting is only checked when constructing FailurePolicy. Jobs that rely on the exception to fail CI or trigger recovery will miss it. Honor the setting in both runners and attach the partial experiment result as documented.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_experiment/executor.py
Line: 450-454

Comment:
**Requested failure exceptions never arrive**

With `on_threshold="raise"`, both runners still stop and return normally when the failure budget trips. The setting is only checked when constructing `FailurePolicy`. Jobs that rely on the exception to fail CI or trigger recovery will miss it. Honor the setting in both runners and attach the partial experiment result as documented.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +180 to +184
return ExperimentFingerprint(
task_name=task_name,
evaluator_names=evaluator_names,
data_size=len(self.data),
data_digest=compute_data_digest(self._item_keys),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Resume accepts changed data

The data fingerprint hashes item keys rather than all data passed to tasks and evaluators. Dataset keys contain only the item ID and optional version; local keys omit metadata. Editing an unpinned dataset item or changing local metadata therefore passes the resume check and returns old outputs and evaluations. Hash the relevant item contents separately from the stable item key.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_experiment/executor.py
Line: 180-184

Comment:
**Resume accepts changed data**

The data fingerprint hashes item keys rather than all data passed to tasks and evaluators. Dataset keys contain only the item ID and optional version; local keys omit `metadata`. Editing an unpinned dataset item or changing local metadata therefore passes the resume check and returns old outputs and evaluations. Hash the relevant item contents separately from the stable item key.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

processor=processor,
max_concurrency=max_concurrency,
)
exec_result = await executor.run(task=task, evaluators=evaluators)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Composite scores stay stale

composite_evaluator is missing from the checkpoint fingerprint. Adding or replacing it while keeping the task and regular evaluators unchanged passes the resume check. Completed items are skipped, leaving their composite evaluations missing or stale. Pass this evaluator into the fingerprint too.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/client.py
Line: 2988

Comment:
**Composite scores stay stale**

`composite_evaluator` is missing from the checkpoint fingerprint. Adding or replacing it while keeping the task and regular evaluators unchanged passes the resume check. Completed items are skipped, leaving their composite evaluations missing or stale. Pass this evaluator into the fingerprint too.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +2851 to +2854
and not any(
not isinstance(item, dict) and getattr(item, "dataset_id", None)
for item in data
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dataset resume repeats completed work

Dataset runs allow resume=True without a stable run_name or explicit experiment_id. The generated run name contains the current timestamp, and that name feeds the dataset experiment ID used to find the checkpoint. Calling the experiment again creates a different key and repeats completed task calls. Require a stable name or explicit ID for dataset resume too.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/client.py
Line: 2851-2854

Comment:
**Dataset resume repeats completed work**

Dataset runs allow `resume=True` without a stable `run_name` or explicit `experiment_id`. The generated run name contains the current timestamp, and that name feeds the dataset experiment ID used to find the checkpoint. Calling the experiment again creates a different key and repeats completed task calls. Require a stable name or explicit ID for dataset resume too.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1172 to +1174
if execution is not None and execution.failures.is_exceeded(
len(execution_failures),
total_items_processed + total_items_failed,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failure rates count errors twice

The batch failure rate divides failure records by items. Several failing evaluators on one item add several records, so the rate can exceed 100% and stop a run below the requested share of affected items. The same mismatch can make execution.failed exceed the item count. Count distinct affected items for item-based limits and summaries, while keeping individual records for detail.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/batch_evaluation.py
Line: 1172-1174

Comment:
**Failure rates count errors twice**

The batch failure rate divides failure records by items. Several failing evaluators on one item add several records, so the rate can exceed 100% and stop a run below the requested share of affected items. The same mismatch can make `execution.failed` exceed the item count. Count distinct affected items for item-based limits and summaries, while keeping individual records for detail.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +56 to +58
except BaseException as e: # noqa: BLE001
attempt_exc = e
last_exc = e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cancellation starts another attempt

With retry_on=lambda exc: True, _run_with_retry catches CancelledError and retries it. An abort can therefore start another paid task attempt after cancellation. Separately, process_one turns other BaseException values such as SystemExit into item failures. Re-raise cancellation and process-exit exceptions before applying retry or item-failure logic.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_experiment/retrying.py
Line: 56-58

Comment:
**Cancellation starts another attempt**

With `retry_on=lambda exc: True`, `_run_with_retry` catches `CancelledError` and retries it. An abort can therefore start another paid task attempt after cancellation. Separately, `process_one` turns other `BaseException` values such as `SystemExit` into item failures. Re-raise cancellation and process-exit exceptions before applying retry or item-failure logic.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

stage="task",
error_type=type(e).__name__,
error_message=str(e),
attempts=[],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Attempt details are lost

_run_with_retry attaches attempt records to the final exception, but both task failure branches and both evaluator failure branches replace them with attempts=[]. Evaluator retries also pass no attempt hook, so execution retry counts omit them. Preserve the attached records and include evaluator attempts in the summary so callers can inspect the work that ran.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/client.py
Line: 3552

Comment:
**Attempt details are lost**

`_run_with_retry` attaches attempt records to the final exception, but both task failure branches and both evaluator failure branches replace them with `attempts=[]`. Evaluator retries also pass no attempt hook, so execution retry counts omit them. Preserve the attached records and include evaluator attempts in the summary so callers can inspect the work that ran.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +3288 to +3291
if task_attempts is not None:
self._emit_attempt_observations(
task_span, task_attempts
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Attempt spans show wrong times

Attempt observations are created only after the task succeeds, using that later moment as their time. They do not cover the failed calls or show the backoff gaps, and exhausted retries emit none. Emit observations around each attempt, or set their times from the recorded start and duration on both success and terminal failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/client.py
Line: 3288-3291

Comment:
**Attempt spans show wrong times**

Attempt observations are created only after the task succeeds, using that later moment as their time. They do not cover the failed calls or show the backoff gaps, and exhausted retries emit none. Emit observations around each attempt, or set their times from the recorded start and duration on both success and terminal failure.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

for base in base_keys:
count = seen.get(base, 0)
seen[base] = count + 1
if count == 0 and base not in keys:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Key generation slows large runs

base not in keys scans the growing result list for every distinct item, making make_item_keys quadratic. Every experiment pays this cost, even without checkpointing. Use the existing seen dictionary or a set for membership checks so large runs do not repeatedly scan all prior keys before starting tasks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_experiment/item_key.py
Line: 87

Comment:
**Key generation slows large runs**

`base not in keys` scans the growing result list for every distinct item, making `make_item_keys` quadratic. Every experiment pays this cost, even without checkpointing. Use the existing `seen` dictionary or a set for membership checks so large runs do not repeatedly scan all prior keys before starting tasks.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sdk-python): retries, timeouts, failure accounting and resume for run_experiment

2 participants