Skip to content

Fix: A superseded merge exception outlives the attempt that raised it #504

Closed

Description

Summary

_last_merge_exception records the last exception a merge attempt raised, but nothing clears it when a later attempt in the same retry loop gets a non-raising answer from the API. A consumer reading it afterwards can therefore attribute the run's outcome to an exception that a subsequent attempt superseded.

Raised from review of #503, where the classification it feeds was being introduced on a new path. Declined there deliberately: the defect is in _retry.py's bookkeeping and is read by four unrelated consumers, so correcting it inside a conflict-reporting change would have altered behaviour well outside that change's remit.

The sequence

_merge_pr_with_retry (src/dependamerge/merge_manager/_retry.py:53-99) loops over attempts. Two branches end an iteration:

  • _dispatch_merge raises → _handle_merge_exception stores the exception (_retry.py:150-153) and returns CONTINUE or STOP.
  • _dispatch_merge returns False (GitHub answered merged: false) → _retry_after_false_merge returns RETRY or STOP. Nothing is stored, and nothing is cleared.

So a run that goes

attempt 0: 502 Bad Gateway      -> exception stored, CONTINUE
attempt 1: HTTP 200, merged=false -> nothing stored, STOP

returns False with the 502 still in _last_merge_exception, even though the attempt that actually decided the outcome was a clean refusal by GitHub.

The same applies to an exception followed by the loop simply exhausting its attempts.

Who reads it

The stored exception is consulted well after the loop returns:

Consumer Uses it to
_failure.py:159 _get_failure_summary choose the reported failure reason, and whether it is withdrawable
_conflict_wait.py _rebased_merge_was_refused classify a post-rebase merge failure (added in #503)
_approval.py:115 detect a missing-approval rejection and re-approve
_single_pr_merge.py:248 detect a pending-workflows rejection and wait
_required_workflows.py:101 same, guarded by _last_merge_exception_head

Only the last of those guards against staleness, and it guards a different staleness — a force-push moving the head SHA — rather than a superseded attempt on the same head.

Consequence

Bounded, and currently in the harmless direction. On the _conflict_wait.py path the reported text is fixed prose, so a stale 502 hides nothing; it makes a genuine refusal read as non-withdrawable, and the failure stands where it could have been reported as re-runnable. The next run merges the PR. _confirm_failure's other two corrections — merged, and closed-unmerged — do not consult the flag, so nothing that actually landed is misreported.

_get_failure_summary is the consumer with more to lose: it would surface the 502's text as the reason for a failure that GitHub refused on the PR's state, which is a less accurate diagnosis than the one available.

Suggested approach

Have the loop record what ended it, rather than leaving consumers to infer that from a field only one branch writes. Either:

  • clear _last_merge_exception / _last_merge_exception_head when a later attempt receives a non-raising API response, so the field means "the exception from the final attempt" as its readers assume; or
  • return the terminal outcome from _merge_pr_with_retry alongside the boolean, so no inference from run-scoped state is needed.

The first is smaller and fixes every consumer at once. The second is closer to what the callers actually want, and overlaps with #496, which asks for the same thing on the recreate path.

Whichever is chosen, the mixed sequence needs a test: an exception on one attempt followed by merged: false on the next, asserting the outcome is attributed to the refusal.

Acceptance

  • A stored exception cannot outlive the attempt that raised it when a later attempt gets an answer
  • _get_failure_summary reports the refusal, not the superseded transport error, for the mixed sequence
  • The four existing consumers keep their current behaviour for the unmixed cases, with tests showing so
  • uv run pytest green; quality gate unchanged

Related

Activity

  1. added 2 commits that reference this issue on Sep 14, 2026
    4ea8a13
    7ada84c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions