v0.262.1: the busy refusal is a CONFLICT, not a server error (R-633)
gates / gates (push) Successful in 24s

Caught by the live proof on 9202, not by a test. The v0.262.0 guard fired exactly right and
answered HTTP 500: router.go maps remove errors by grepping the error TEXT for "not deployed" /
"still running" / "not found" / "protected", and the busy sentence contains none of them.

A 500 tells the UI something broke; this is "wait a moment". Now a typed *stacks.RemoveBusyError
matched with errors.As and answered 409, carrying both the Hungarian bytes and the bundle key.

Its test asserts the sentence contains none of the words the text mapping greps for, so the type is
load-bearing rather than decorative. Red-proof seen failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-22 21:48:01 +02:00
parent b793638484
commit 3d41758be0
4 changed files with 75 additions and 3 deletions
+9
View File
@@ -911,6 +911,15 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return
}
// R-633: "the backup side owns this app right now" is a CONFLICT, not a server error. It is
// matched by TYPE rather than by prose — the status mapping below greps the error text, and
// the busy sentence contains none of the words it looks for, so the first live run of this
// guard answered 500 while doing exactly the right thing.
var busy *stacks.RemoveBusyError
if errors.As(err, &busy) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden