Skip to content

feat: report retryable scheduler outcome categories#831

Open
danecor wants to merge 3 commits into
mainfrom
dcorneil/retryable-outcome-telemetry
Open

feat: report retryable scheduler outcome categories#831
danecor wants to merge 3 commits into
mainfrom
dcorneil/retryable-outcome-telemetry

Conversation

@danecor

@danecor danecor commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • classify async scheduler retryable outcomes as rate limit, timeout, internal server, or connection failures
  • report rolling and cumulative category counts plus deferred queue depth
  • include sanitized retry metrics in scheduler health/completion diagnostics
  • emit first-occurrence category warnings without logging provider bodies or prompts

Behavior

Retryability, task deferral, salvage, AIMD, and early-shutdown behavior are unchanged. The change is observability-only.

Validation

  • targeted async scheduler tests: 8 passed
  • Ruff clean

Big Iron pins this branch by immutable commit while the change is reviewed.

@danecor
danecor requested a review from a team as a code owner July 18, 2026 21:49
@danecor
danecor deployed to agentic-ci July 18, 2026 21:49 — with GitHub Actions Active
@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds observability to the async scheduler's retryable-error handling: retryable exceptions are now classified into four categories (rate_limit, timeout, internal_server, connection), rolling and cumulative counts are tracked, first-occurrence per-kind warnings are emitted without logging provider bodies, and all metrics are included in the scheduler's health/completion diagnostics.

  • _retryable_error_kind maps the four known error types to canonical string keys; _provider_error_details walks the exception chain to extract safe HTTP metadata (status code, retry-after) for the warning log and detail counters.
  • The retryable_outcome_metrics property exposes rolling window counts, cumulative counts, detail breakdowns, and deferred queue depth via the existing diagnostics surface.
  • pyproject.toml adds explicit [tool.uv.sources] entries pinning three workspace packages for the build.

Confidence Score: 3/5

The core scheduling, deferral, and AIMD logic is untouched; the risk is confined to the new metric counters, but those counters carry incorrect labels for a real call path.

The exception handler calls _record_retryable_outcome(retryable=False, exc=exc) for non-retryable LLM failures, and the function maps every retryable=False case to success regardless of whether exc is set, silently inflating the success bucket.

async_scheduler.py — specifically the kind assignment in _record_retryable_outcome and the test coverage gap for the retryable=False, exc=non_retryable_exc call path.

Important Files Changed

Filename Overview
packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py Adds retryable-outcome classification, rolling/cumulative counters, and first-occurrence warnings; non-retryable LLM failures are incorrectly bucketed as "success" in the new metrics.
packages/data-designer-engine/tests/engine/dataset_builders/test_async_scheduler.py New parametrized test validates error-kind classification and sanitized output; does not cover the non-retryable-exception call path (retryable=False with exc set).
pyproject.toml Adds explicit workspace sources for data-designer packages in [tool.uv.sources]; straightforward dependency configuration change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Task as LLM Task
    participant Exec as _execute_task_inner
    participant Record as _record_retryable_outcome
    participant Counts as _retryable_outcome_counts
    participant Rolling as _recent_retryable_kinds

    alt Task succeeds
        Task-->>Exec: return result
        Exec->>Record: "retryable=False, exc=None"
        Record->>Record: "kind = success"
        Record->>Counts: "counts[success] += 1"
        Record->>Rolling: append(success)
    else Retryable failure
        Task-->>Exec: raise ModelRateLimitError
        Exec->>Record: "retryable=True, exc=retryable_exc"
        Record->>Record: "kind = rate_limit etc"
        Record->>Counts: "counts[kind] += 1"
        Record->>Rolling: append(kind)
    else Non-retryable failure
        Task-->>Exec: raise GenerationValidationFailureError
        Exec->>Record: "retryable=False, exc=non_retryable_exc"
        Record->>Record: "kind = success mis-labeled"
        Record->>Counts: "counts[success] += 1"
        Record->>Rolling: append(success)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Task as LLM Task
    participant Exec as _execute_task_inner
    participant Record as _record_retryable_outcome
    participant Counts as _retryable_outcome_counts
    participant Rolling as _recent_retryable_kinds

    alt Task succeeds
        Task-->>Exec: return result
        Exec->>Record: "retryable=False, exc=None"
        Record->>Record: "kind = success"
        Record->>Counts: "counts[success] += 1"
        Record->>Rolling: append(success)
    else Retryable failure
        Task-->>Exec: raise ModelRateLimitError
        Exec->>Record: "retryable=True, exc=retryable_exc"
        Record->>Record: "kind = rate_limit etc"
        Record->>Counts: "counts[kind] += 1"
        Record->>Rolling: append(kind)
    else Non-retryable failure
        Task-->>Exec: raise GenerationValidationFailureError
        Exec->>Record: "retryable=False, exc=non_retryable_exc"
        Record->>Record: "kind = success mis-labeled"
        Record->>Counts: "counts[success] += 1"
        Record->>Rolling: append(success)
    end
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py:1571
**Non-retryable failures misclassified as "success"**

`kind` is always `"success"` when `retryable=False`, but the exception handler at line 1751 also calls `_record_retryable_outcome(retryable=False, exc=exc)` for non-retryable LLM failures (auth errors, schema errors, code bugs). Those failures increment `_retryable_outcome_counts["success"]` and are appended to `_recent_retryable_kinds` as `"success"`, so the diagnostics inflate the success count and understate actual failure rates. An operator seeing `cumulative_counts: {success: 80, rate_limit: 20}` cannot tell whether the 80 represent genuine successes or a mix of successes and non-retryable failures.

Reviews (1): Last reviewed commit: "Declare Data Designer workspace sources" | Re-trigger Greptile

rate. Only retryable errors (rate-limit, timeout, 5xx, connection) count
toward the rate; non-retryable failures register as 0.
"""
kind = self._retryable_error_kind(exc) if retryable else "success"

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 Non-retryable failures misclassified as "success"

kind is always "success" when retryable=False, but the exception handler at line 1751 also calls _record_retryable_outcome(retryable=False, exc=exc) for non-retryable LLM failures (auth errors, schema errors, code bugs). Those failures increment _retryable_outcome_counts["success"] and are appended to _recent_retryable_kinds as "success", so the diagnostics inflate the success count and understate actual failure rates. An operator seeing cumulative_counts: {success: 80, rate_limit: 20} cannot tell whether the 80 represent genuine successes or a mix of successes and non-retryable failures.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
Line: 1571

Comment:
**Non-retryable failures misclassified as "success"**

`kind` is always `"success"` when `retryable=False`, but the exception handler at line 1751 also calls `_record_retryable_outcome(retryable=False, exc=exc)` for non-retryable LLM failures (auth errors, schema errors, code bugs). Those failures increment `_retryable_outcome_counts["success"]` and are appended to `_recent_retryable_kinds` as `"success"`, so the diagnostics inflate the success count and understate actual failure rates. An operator seeing `cumulative_counts: {success: 80, rate_limit: 20}` cannot tell whether the 80 represent genuine successes or a mix of successes and non-retryable failures.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

Copy link
Copy Markdown
Contributor

Code Review — PR #831: Report retryable scheduler outcome categories

Summary

This PR extends AsyncTaskScheduler to classify and report retryable model-task
outcomes by category (rate_limit, timeout, internal_server, connection,
other_retryable). It adds:

  • New per-scheduler state: _recent_retryable_kinds (rolling deque),
    _retryable_outcome_counts / _retryable_detail_counts (cumulative Counters),
    and _logged_retryable_kinds (one-shot WARN dedupe set).
  • A retryable_outcome_metrics property surfaced in
    _scheduler_health_diagnostics, plus enriched degraded-provider WARN text.
  • Two static helpers: _retryable_error_kind (exception → category) and
    _provider_error_details (walks the exception chain to extract only
    status_code / retry_after from the underlying ProviderError — no free
    text, addressing sensitive-data leakage).
  • A parametrized classification test and a [tool.uv.sources] workspace block
    in the root pyproject.toml.

The sanitization approach (extract only transport metadata, never provider free
text) is sound, and the test asserting "sensitive provider text" not in str(metrics)
is a good guard. The logging migration from f-string to %s-lazy args is also a
correctness improvement. The findings below concern telemetry accuracy and a
new source of WARN-level log noise rather than crashes.

Findings

1. Non-retryable failures are bucketed as "success" (telemetry corruption)

packages/.../async_scheduler.py:1571

kind = self._retryable_error_kind(exc) if retryable else "success"

_record_retryable_outcome is called from two sites in the exception path,
not just the success path:

  • async_scheduler.py:1726 — genuine success: _record_retryable_outcome(retryable=False)
  • async_scheduler.py:1751failure handler: _record_retryable_outcome(retryable=retryable, exc=exc)

When a failure is non-retryable (auth error, schema/validation error, a code
bug), retryable=False, so kind is set to "success". A hard, non-retryable
failure is therefore counted under cumulative_counts["success"] /
rolling_counts["success"] and shown in the WARN rolling_summary /
cumulative_summary as a success.

Failure scenario: a run where a fraction of model tasks fail with a
non-retryable schema error will report those tasks as success=N in the
retryable-outcome telemetry — exactly inverting the signal this feature exists
to provide. The degraded-provider rate remains correct (it uses the boolean
_recent_retryable deque), but the per-kind breakdown that this PR introduces is
misleading. Consider a distinct label such as "non_retryable" for the
retryable=False and exc is not None case.

2. Test does not cover the mislabeling case

packages/.../test_async_scheduler.py:1633

test_retryable_outcome_metrics_classify_errors exercises only
_record_retryable_outcome(retryable=False) with no exc (true success). It
never exercises a non-retryable failure (retryable=False, exc=<some error>),
which is why finding #1 slips through. Add a case passing a non-retryable
exception and assert the intended bucket, so the "success" conflation is caught.

3. New per-kind WARN fires on the first single transient error

packages/.../async_scheduler.py:1579-1588

if kind not in self._logged_retryable_kinds:
    self._logged_retryable_kinds.add(kind)
    ...
    logger.warning("Observed retryable model-task error: kind=%s...")

Unlike the aggregate degraded-provider WARN (gated by both rate threshold and
degraded_warn_interval_s), this WARN fires on the first occurrence of each
kind
with no rate/interval gate. Transient rate-limits and connection blips
that salvage fully recovers from are routine against real LLM providers, so a
completely healthy run will now emit up to ~5 WARN lines (one per kind) for
errors that never affected the output. This is new log noise at WARN level for
a self-healing condition. Consider INFO level, or gating it behind the same
degradation signal, so WARN stays reserved for genuinely actionable states.

4. status_code / retry_after extraction depends on a fragile exception chain

packages/.../async_scheduler.py:1545-1554

_provider_error_details walks current.__cause__ or current.__context__ to
find the wrapped ProviderError. The Model*Error subclasses are raised via
handle_llm_exceptions_raise_from_provider_error using
raise error_cls(...) from None, which sets __cause__=None and
__suppress_context__=True but leaves __context__ pointing at the
ProviderError — so the common path works today. However, this relies on no
intermediate layer re-raising the Model*Error in a way that breaks the chain
(e.g. constructing a fresh error outside an except block, or another
from None after the context is gone). If that happens, status_code /
retry_after silently become None and retryable_details loses the
:http_<code> suffix — a quiet telemetry degradation, never a crash. Worth a
brief comment documenting the __context__-preservation assumption so a future
refactor of the error-wrapping layer doesn't silently regress it.

5. (Minor) _provider_error_details computed unconditionally

packages/.../async_scheduler.py:1573

status_code, retry_after = self._provider_error_details(exc) runs on every
call including the success path. For success (exc=None) the walk returns
immediately, and for non-retryable failures the results are discarded (guarded
by if retryable:). Cost is negligible, but moving the call inside the
if retryable: block would make the intent clearer and avoid the wasted walk on
non-retryable failures.

Notes on pyproject.toml

The added [tool.uv.sources] block declaring the three packages as
{ workspace = true } is consistent with the members list (lines 24–27) and
is standard uv monorepo configuration (root has package = false). No issue —
it makes the workspace packages resolve from the local tree, complementing the
"Git-consumable" work in the preceding commits.

Structural Impact (graphify, 2.5s)

Risk: HIGH (1 core abstraction(s) modified; 10 import direction violation(s))

  • 2 Python files, 125 AST entities, 2/78 clusters

Import Direction Violations (10)

Legal direction: interface -> engine -> config

  • _string_keyed_counts() (engine) --calls--> .items() (interface)
  • .__init__() (engine) --calls--> .items() (interface)
  • ._record_observed_task_state() (engine) --calls--> .items() (interface)
  • retryable_outcome_metrics() (engine) --calls--> .items() (interface)
  • ._request_pressure_diagnostics() (engine) --calls--> .items() (interface)
  • +5 more

Core Abstractions Modified

High-Connectivity Changes

  • AsyncTaskScheduler (153 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • _RowGroupState (47 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • _DispatchOutcome (47 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • Checkpoint any row groups that reached completion. (45 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • Dispatch all frontier tasks and their downstream until quiescent. (45 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • Cancel all tracked worker tasks and wait for them to finish. (45 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • Main scheduler loop. On cancellation (``CancelledError``), all tracked (45 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • Trigger early shutdown if recent error rate exceeds threshold. (45 deps) in packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
  • +83 more

Cross-Package Dependencies

  • _string_keyed_counts() (engine) --calls--> .items() (interface)
  • _RowGroupState (engine) --uses--> GenerationStrategy (config)
  • _DispatchOutcome (engine) --uses--> GenerationStrategy (config)
  • AsyncTaskScheduler (engine) --uses--> GenerationStrategy (config)
  • .__init__() (engine) --calls--> .items() (interface)
  • ._record_observed_task_state() (engine) --calls--> .items() (interface)
  • +46 more

Reviewer note on structural impact: the "import direction violations" and
several "cross-package dependencies" flagged here (.items() attributed to the
interface package) are false positives — .items() is the built-in dict
method, not a symbol from data_designer.interface. The genuine cross-package
edges are _RowGroupState / _DispatchOutcome / AsyncTaskScheduler -->
GenerationStrategy (config), which follow the legal engine → config direction
and are pre-existing, not introduced by this PR. This PR's actual footprint is
narrow: it adds state and reporting to an already highly-connected class
(AsyncTaskScheduler, 153 deps) but does not change its public method
signatures, task-scheduling control flow, or return shapes. The only signature
change is the additive, keyword-only, default-valued exc parameter on the
private _record_retryable_outcome, and both call sites are updated in this
diff. Backward-compatibility risk is therefore low despite the HIGH label; the
substantive risk is telemetry correctness (findings #1#4), not blast radius.

Verdict

Request changes (soft). No crash-level or backward-compatibility defects,
and the sanitization design is good. But finding #1 is a real correctness bug in
the feature's core purpose: non-retryable failures are recorded as "success",
which inverts the very signal this PR adds — it should be fixed (with a covering
test per #2) before merge. Findings #3 (new WARN noise on transient,
self-recovered errors) and #4 (undocumented reliance on __context__
preservation) are worth addressing but are non-blocking. #5 is optional cleanup.

@danecor danecor changed the title Report retryable scheduler outcome categories feat: report retryable scheduler outcome categories Jul 19, 2026
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.

1 participant