Skip to content

fix: [NACT-2932] retry GitLab MR creation and stop blocking on traceback rendering - #650

Merged
tech1ndex merged 11 commits into
Roche:mainfrom
feiyunzhou:nact-2932-fix-foxops-mr-creation-race-and-timeout-handling
Nov 2, 2026
Merged

tech1ndex merged 11 commits into
Roche:mainfrom
feiyunzhou:nact-2932-fix-foxops-mr-creation-race-and-timeout-handling

Conversation

@feiyunzhou

@feiyunzhou feiyunzhou commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

WHY?

Nightly nactl update runs fail intermittently. foxops pushes the branch and
asks GitLab to open an MR in the same breath; GitLab answers from a branch cache
that can lag the push and rejects it with Source branch "<x>" does not exist.
foxops did not retry. Logging the resulting error then took ~52s, which pushed
the reply past the 60s reverse-proxy timeout, so the caller saw a misleading 504
instead of the real GitLab error.

WHAT?

  • Retry the merge request POST for up to 10s while GitLab still reports the
    pushed source branch as missing. Safe to retry: GitLab only sends this message
    when it created nothing.
  • Raise MergeRequestCreationFailedError carrying GitLab's own message, instead
    of httpx's Client error '400 Bad Request' for url ....
  • Render plain tracebacks. structlog's ConsoleRenderer silently switches to
    rich's formatter when rich is installed, and that formatter reprs every
    frame's local variables.

ADDITIONAL NOTES

The traceback was rendered twice per failed request — once in catch_all, once
more after Starlette re-raises and uvicorn logs it. Rendering is synchronous on
the event loop, so it stalled every other in-flight request too, and the first
render happens before the error response is sent.

Not touched: the mergeability-check loop in merge_request() can still spend up
to 25s of the same 60s budget. Separate concern.

@feiyunzhou
feiyunzhou requested a review from defreng as a code owner October 6, 2026 09:00
@feiyunzhou feiyunzhou closed this Oct 6, 2026
@feiyunzhou feiyunzhou reopened this Oct 6, 2026
@tech1ndex
tech1ndex self-requested a review October 6, 2026 11:22
@feiyunzhou
feiyunzhou force-pushed the nact-2932-fix-foxops-mr-creation-race-and-timeout-handling branch from 8d59890 to 39ee978 Compare October 8, 2026 12:46
…ack rendering

foxops asks GitLab to open a merge request immediately after pushing the
branch. GitLab answers from a branch cache that can lag the push, rejects the
request with `Source branch "<x>" does not exist`, and foxops did not retry.

The resulting traceback then took ~52s to log, because structlog's
ConsoleRenderer falls back to rich's traceback formatter whenever rich is
installed, and that formatter renders every frame's local variables. It ran
twice per failed request: once in catch_all, once more after Starlette
re-raises and uvicorn logs it. Rendering is synchronous on the event loop and
happens before the error response is sent, so replies missed the 60s
reverse-proxy timeout and callers saw a misleading 504.

- retry the merge request POST for up to 10s while GitLab reports the pushed
  source branch as missing
- raise MergeRequestCreationFailedError with GitLab's own message instead of
  httpx's "Client error '400 Bad Request' for url ..."
- render plain tracebacks, without frame locals
@feiyunzhou
feiyunzhou force-pushed the nact-2932-fix-foxops-mr-creation-race-and-timeout-handling branch from 39ee978 to f6c581e Compare October 8, 2026 13:34

@ermirry ermirry 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.

Found one timing issue with the retry deadline. All six added tests pass locally.

Comment on lines +224 to +227
@retry(
retry=retry_if_exception_type(_SourceBranchNotYetVisible),
stop=stop_after_delay(MERGE_REQUEST_SOURCE_BRANCH_RETRY_TIMEOUT.total_seconds()),
wait=wait_fixed(MERGE_REQUEST_SOURCE_BRANCH_RETRY_WAIT.total_seconds()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Enforce the retry deadline during HTTP requests

stop_after_delay(10) checks elapsed time after a failed attempt; it does not interrupt an in-flight POST. Each retry inherits the client's 120-second HTTP timeout, so a quick missing-branch response followed by a slow retry can exceed both the stated 10-second budget and the proxy's 60-second timeout, producing another 504. Apply a deadline covering retry waits and HTTP requests.

Reproduced with a 0.1-second budget: a slow second attempt kept the call running for 0.62 seconds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching this! Fixed

I give the POST request itself a timeout, same value as the 10s retry budget. So even GitLab answer very slow, this one request cannot run more than 10s (before it use client's default 120s).

Also I simplify the retry logic: now reuse httpx.HTTPStatusError (from raise_for_status()) directly as the retry signal, not need custom exception class anymore. One small function decide: if error is "branch not visible yet" then retry it, if other error then fail immediately, not retry.

Feiyun added 6 commits October 30, 2026 14:45
…gaps between attempts

stop_after_delay only checks elapsed time between attempts; it doesn't
interrupt an in-flight POST, which inherits the client's 120s timeout.
Wrap each attempt in asyncio.wait_for scoped to what's left of the retry
budget, floored at the retry wait so a fast call near the deadline still
gets a chance to complete.
…f a tracked deadline

asyncio.wait_for + a hand-tracked remaining-budget deadline was more
machinery than this needed. Passing the retry timeout straight through
as the request's own timeout gets the same practical effect for the
case this guards against (one slow attempt), with far less code.

Test now asserts the timeout is wired onto the request instead of
simulating a slow GitLab response, since httpx.MockTransport never
enforces timeouts itself.
…tusError

_SourceBranchNotYetVisible plus the manual is_client_error check and a
body-truncation helper were more machinery than needed. raise_for_status()
already gives an httpx.HTTPStatusError for GitLab's 4xx; a single predicate
classifies it as retryable or not, reused by both the tenacity retry
condition and the inner/outer error handling.

Also applied the team's Python style checklist across the whole PR diff
(not just this round's changes): no comments/docstrings, test functions
annotated -> None, assertions expected-value-first.
Two spots already know this happened but said nothing: the retry
giving up in _create_merge_request, and update_incomplete_change()
when /fix finds a commit and branch but no MR. Log both with the
repository and branch name so this is searchable/alertable instead
of only surfacing as a generic 500 or a 422 on manual repair.
Comment thread src/foxops/hosters/gitlab.py Resolved
Comment thread src/foxops/hosters/gitlab.py Resolved
Comment thread src/foxops/hosters/gitlab.py Resolved
Keeps the non-obvious bit (ConsoleRenderer silently picks rich's
exception formatter, which repr()s every frame's locals on the event
loop) and drops the incident narrative, which belongs in the MR.
@feiyunzhou

Copy link
Copy Markdown
Contributor Author

Tested this change on a real environment, not only unit tests.

What I did:

  • Built the image from the latest branch head (86e9bdd) locally, as multi-arch (amd64 + arm64).
  • Deployed it to a dev control plane and checked in the pod that the new code is what runs.
  • Created 3 test incarnations with Terraform, then triggered an update on all 3.

Result:

  • All 3 updates created the merge request with a single POST (HTTP 201). No retry was needed.
  • Automerge was triggered for all 3. No errors or tracebacks in the foxops logs.

Limitation:

  • I could not reproduce the GitLab "source branch does not exist" race on a real GitLab, so the retry path (and the give-up log) is covered by the unit tests only.

Feiyun added 3 commits November 2, 2026 22:46
The pattern only matched 'Source branch "x" does not exist', which comes from
MergeRequests::BuildService behind the web UI. Creating a merge request over
the REST API reaches a model validation instead, which reports the rejection
per attribute as {"source_branch": ["does not exist"]}, so the retry never
fired for the case it was written for. Verified against Gitlab 19.3.7-ee.

The unit tests passed because the mocked body was written from the same
assumption as the pattern, so they now cover both wordings.
The sentence form comes from MergeRequests::BuildService, which only the web
UI goes through: lib/api/merge_requests.rb calls MergeRequests::CreateService
directly, and neither it nor CreateService mentions BuildService, on 17.2.8 or
on 19.3.6. foxops only ever calls the API, so matching that wording covered a
response it cannot receive. The pattern now states the shape it expects, which
leaves nothing for a comment to add.
@tech1ndex
tech1ndex merged commit 1b519b7 into Roche:main Nov 2, 2026
12 checks passed
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.

4 participants