Skip to content

fix(errs): Retry transient grouped failures - #621

Open
prathshenoy wants to merge 1 commit into
mainfrom
prath.shenoy/classify-joined-errors
Open

fix(errs): Retry transient grouped failures#621
prathshenoy wants to merge 1 commit into
mainfrom
prath.shenoy/classify-joined-errors

Conversation

@prathshenoy

@prathshenoy prathshenoy commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

What:

  • Retry work when any one of several failures reported together is transient instead of giving up on the first attempt.
  • Limit a classification attached to one grouped failure to that failure alone, instead of letting it decide the outcome for the others reported alongside it.

Why:

  • Stop discarding failures that would have cleared on their own by keeping a step that fans work out to several handlers from being abandoned because one handler hit a momentary error.

Test Plan

  • Add unit tests.

Revert Plan

  • Revert this PR. The changes in this PR are confined to the error classification framework.

Issues

@prathshenoy
prathshenoy marked this pull request as ready for review August 19, 2026 19:23
@prathshenoy
prathshenoy enabled auto-merge August 19, 2026 19:23
Comment thread platform/errs/processor.go
@prathshenoy
prathshenoy force-pushed the prath.shenoy/classify-joined-errors branch from 33e11c5 to 31ce24d Compare August 20, 2026 22:03
@prathshenoy
prathshenoy marked this pull request as draft August 20, 2026 22:12
auto-merge was automatically disabled August 20, 2026 22:12

Pull request was converted to draft

@prathshenoy
prathshenoy force-pushed the prath.shenoy/classify-joined-errors branch from 31ce24d to f3b2d0a Compare August 21, 2026 16:04
@prathshenoy
prathshenoy force-pushed the prath.shenoy/classify-joined-errors branch from f3b2d0a to 09f0cd9 Compare August 21, 2026 16:20
@prathshenoy
prathshenoy marked this pull request as ready for review August 21, 2026 16:21
return 4
case Infra:
return 3
case User:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why user before infraDep?

return Unknown
}

// verdictRank orders verdicts for combining the independent branches of a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

too verbose? please ask Claude to make it crisp... it looses the clarity

return Unknown
}

// wrapVerdict returns the verdict the framework wrap err carries, or Unknown if

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do we need the doc..i think ideally code is clear enough to not explain, unless you think there is need for one, may shorten it

// the chain is already classified deeper down.
//
// Passing no classifiers is valid — the processor will still honour any
// Both passes traverse joined errors (errors.Join, or anything else exposing

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This says both passes traverse joins, but Pass 1 doesn't — it walks only via errors.Unwrap() error, which yields nothing for a join. The inline comment 26 lines below (L97-99) says exactly that: "along the single-cause spine only… The walk stops at a join."

Worth correcting precisely because the design depends on it: Pass 1 not descending is what lets a wrapped branch be ranked against its siblings instead of short-circuiting the whole join. TestNewClassifierProcessor_WrappedBranchesAreWeighed only passes because of it — if Pass 1 did descend, Process would return the branch's *userError verbatim rather than wrapping it in NewRetryableError.

Suggested scoping:

Pass 2 traverses joined errors (errors.Join, or anything else exposing Unwrap() []error) as well as ordinary single-cause wraps; Pass 1 walks only the single-cause spine.

Comment thread platform/errs/README.md

### Joined errors

Both passes descend into joined errors — `errors.Join`, `fmt.Errorf` with more than one `%w`, or any other error exposing `Unwrap() []error` — as well as ordinary single-cause wraps. This matters for anything that fans work out to several children and reports their failures together, `submitqueue/extension/validator/composite` being the current example: `errors.Unwrap` returns nil for a join, so a walk built on it alone sees the join node and nothing beneath it, and every branch goes unclassified.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same issue as processor.go L71 — Pass 1 doesn't descend into joins, only Pass 2 does. This one also contradicts L47 nine lines up, which correctly says Pass 1 looks "on the error's single-cause spine."

s/Both passes descend/Pass 2 descends/ and the rest of the paragraph reads fine as-is.

Comment thread platform/errs/README.md

A framework wrap classifies the subtree beneath it and no further. Above a join it covers the whole join and Pass 1 returns the error verbatim, so no branch is consulted. *Inside* a branch it is one branch's account of one failure, with no standing to classify the failures beside it — so it contributes its own verdict to the rank like any other branch. That is what keeps a sibling's transient failure from being discarded by a branch that happened to arrive pre-classified, and it also removes an ordering artifact: two wrapped branches of differing retryability used to resolve by whichever one `errors.As` reached first.

The losing branch keeps its wrap in the chain, so `IsUserError` and `IsRetryable` can both report true for the same joined error — one from a branch, one from the outer wrap. Only the outer wrap drives the retry decision, the same way it does under `AlwaysRetryableProcessor`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Design question rather than a defect — this is clearly deliberate, and documented.

I checked every current caller and they all test IsRetryable first, so nothing is broken today:

  • platform/consumer/consumer.go:442if !errs.IsRetryable(err), the actual retry decision
  • platform/consumer/consumer.go:644if IsRetryable {} else if IsUserError {}, metrics tagging only
  • stovepipe/controller/process/process.go:144if !errs.IsRetryable(err)

The concern is reachability. Dual-true went from "a controller had to deliberately double-wrap" to "join any two children," but nothing in the API enforces the precedence. A future caller writing the equally natural if IsUserError(err) { ack } else if IsRetryable(err) { retry } would silently drop a transient failure — this PR's own bug arriving through a different door.

Might be worth either a contract note on IsUserError saying it must not be consulted before IsRetryable, or a single RetryDecision(err) helper that makes the precedence unskippable. Not blocking.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants