Skip to content

fix: failed-build page states the payment outcome and only offers a Retry that will work - #574

Open
stevenfackley wants to merge 4 commits into
mainfrom
fix/result-page-truth
Open

stevenfackley wants to merge 4 commits into
mainfrom
fix/result-page-truth

Conversation

@stevenfackley

@stevenfackley stevenfackley commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

What was wrong on the failed-build page

  • It offered a Retry the server would refuse: after a refund (the Engine answers 402) and at the retry limit.
  • It said nothing about a refund the customer had been given. An internal failure said "contact support" even when the money was already on its way back.
  • "Compilation failed on attempt N of 3" read attempt_count, which was usually 0.
  • "Retrying usually resolves this" stayed on screen when no retry was left.
  • Retries were not counted at all, so the "3 retries" gate never triggered and a failure that kept repeating was never refunded.

What the page does now

It works from the payment behind the build (payment_status, read with the owner's row) and from the customer's own retry count.

Situation Page says Retry
Paid, retryable, retries left Payment still in place, retrying is free, refunded automatically if the last retry fails. "N of 3 retries left" Yes
Paid, refund started or done "A full refund for this build has been started… You don't need to do anything." No
Paid, nothing left to retry, failed under 15 min ago A refund starts automatically, when to worry, support address, generation ID No
Same, failed over 15 min ago with the payment still in place "We owe you a refund… has not gone out. Write to support" No
Paid compile failure Refunded under the Compile Guarantee No
Paid tier with no completed payment "This build was not started" No
Free, retries left / used up Retries left / "Start a new one" Yes / No
  • The page keeps polling a failed paid build for up to three minutes, because the Engine writes "failed" a moment before it starts the refund.
  • After a refused retry the page re-reads the row, so it stops showing what was just refused.

How retries are counted

  • New column retry_count (migration 0004: one additive column with a default, safe while the old code runs; deploy-prod applies migrations before starting the new images). It counts presses of Retry only. attempt_count stays the reconciler's restart budget, so a deploy that restarts a build no longer spends one of the customer's retries.
  • resetForRetry raises retry_count in the same write and refuses the fourth.
  • A retry is given back only on 402: the Engine refused for good and will never build the row. On 503, 429, a timeout or an unreachable Engine the row stays pending and the reconciler builds it, so the retry stays counted. On 400 (input refused) the retry also stays counted, and the error is shown.
  • The Engine refunds a paid failure when its category offers no Retry, or when retry_count has reached 3. The count is read at the failure, on its own (GetRetryCountAsync), so it does not depend on the row's JSON parsing. If the count can't be read, nothing is refunded on a guess.

Engine changes

  • A paid build cut off by a restart is rebuilt, not refunded. The row has the prompt and schema, so it runs again from the top, under the attempt budget and only while its payment is still completed. A late-stage row that went stale while the process was alive is still failed and refunded.
  • A boot pass restarts, at once, the builds the restart itself cut off (in-progress rows last touched before process start). Before, the customer watched a frozen page for the 15-minute stale window after every deploy. The boot pass never fails or refunds a row.
  • An unreadable paid row is now failed as internal and refunded. Before it was schema: Retry shown, a retry that could never work, never refunded.
  • A web test reads the Engine's OffersRetry and RetryLimit and fails when they differ from the page's rules.

Checked

  • Two independent reviews. Their findings are fixed in the last three commits.
  • All eight failure states looked at in a browser on a throwaway page (not committed).
  • Store SQL ran against a real Postgres 17 with the app's migrations, 0004 included. That caught a bug before commit: the payment subquery compared the transaction to its own id and always returned null.
  • Web: typecheck clean, all test folders pass (run in batches; the machine runs out of memory on the full suite at once). Engine: 173 targeted tests pass, including the Postgres ones. Worker builds.

Not covered

  • Not exercised end to end with a real failed paid build and a real Stripe refund.
  • The dashboard list does not show refund state; only the build's own page does.
  • transactions.generation_id has no index. The lookup runs on every status poll; the table is small today.
  • The restart check compares the database clock with the Engine's clock. Skew larger than the stop/start gap would send a cut-off build down the fail-and-refund path instead of the rebuild path.
  • CI's Docker and E2E jobs have been failing on every branch, main included, since 13:53 UTC: GitHub's runners cannot resolve security.ubuntu.com. Unrelated to this change; the prod deploy runs on our own runner.

…ding it

A paid row stranded in building, packing or uploading by a restart was failed, and since the refund rule landed, refunded. Every merge deploys prod, so a deploy during the compile stage cost the sale. The row still has the prompt and schema, so the reconciler now runs it again from the top, under the attempt budget and only while its payment is still completed. A late-stage row that went stale while this process was alive is still failed and refunded: the worker dropped it with an outcome the sweep cannot know.
…her Retry will work

The failed-build page offered a Retry the server would refuse (after a refund, at the attempt limit), said nothing about a refund the customer had been given, and counted 'attempt N of 3' from a number that was usually 0.

It now works from the payment behind the build, which is read with the owner's row: Retry is shown only when the web's attempt gate and the Engine's payment gate will both accept it, with the retries left; a paid build shows whether the payment is kept, a refund has started, or one is on its way. The page keeps polling a failed paid build for up to three minutes so a refund recorded a moment after the failure shows up.

Retries are now counted: resetForRetry raises attempt_count in the same write and refuses the fourth. That makes the Engine's refund at the attempt limit reachable for a failure that keeps repeating. A test reads the Engine's retry rules and fails when they differ from the page's.
…e review gaps

Review of #574 found:
- A retry was counted even when no build started, so an Engine outage on the last retry ended in a refund instead of a build. The retry is now given back unless the Engine accepts the build.
- Retries and the reconciler's restarts shared attempt_count, so every deploy that landed on a build spent one of the customer's retries, and the page then said they had used them. New column retry_count (migration 0004, additive) counts presses of Retry only; attempt_count stays the reconciler's restart budget.
- The refund decision read the count once at the start of a run, and a failed read meant zero. It now reads the row at the failure, and refunds nothing if the row cannot be read.
- The page promised a refund 'starts automatically' for failures where none was coming (old failures, a failed refund attempt). After 15 minutes it now says the refund has not gone out and to write to support.
- After a refused retry the page kept saying the payment was in place. It now re-reads the row.
- An unreadable paid row was failed as schema (Retry shown, never refunded). It is now failed as internal and refunded.
- A build cut off by a deploy waited the 15-minute stale window before restarting. A boot pass restarts it at once.
- The reconciler tests used a clock that never moved, so comparing against 'now' instead of process start would have passed.
… the count on its own

Second review of #574:
- A retry was given back after any non-acceptance, but on 503/429/unreachable the row stays pending and the reconciler builds it, so the customer got an uncounted build; a timeout after the Engine accepted did the same. Only 402 gives the retry back now.
- A 400 (input refused) gave the retry back and returned success, so a Retry that can never work was offered forever. It now returns the error and the retry stays counted.
- The refund decision read the whole row; a row whose JSON no longer parses read as 'unknown' and was never refunded. GetRetryCountAsync reads retry_count alone.
- Stale comments about attempt_count and when the Engine reads the count.

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.

1 participant