fix(errors): sig fejlen i en kode, mellemleddet ikke kaster væk

Serveren svarede 502 med en forklaring, og Cloudflare erstattede kroppen med sin
egen side. Bevist i produktion: handleren skrev 122 bytes JSON på 38 ms med
teksten "the panel refused: HTTP 401", og browseren fik en HTML-fejlside. Det
gjaldt også Gmail, som bruger samme hjælper — derfor har fladen hele tiden kunnet
sige "noget gik galt" og ikke andet, selv om serveren vidste præcis hvad der var
sket.

En proxy har lov til at gøre det ved en origin-5xx. Så det er ikke Cloudflare,
der skal laves om: 502 var den pæne kode, ikke den brugbare. Upstream-afvisninger
er nu 422, som går uændret igennem. Navnet StatusUpstreamRefused står ét sted med
grunden, så det ikke bliver rullet tilbage af en, der med rette synes 502 lyder
mere rigtigt.

Fladen læste altid fejlkoden i kroppen og ikke statuskoden, så der var intet at
rette dér.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 19:17:49 +02:00
co-authored by Claude Opus 5
parent bb25b94f96
commit bd5e86dd03
5 changed files with 18 additions and 9 deletions
+11 -2
View File
@@ -585,6 +585,15 @@ func inboxName(locale string) string {
// sync that has been failing for a week is invisible otherwise. The background job
// writes a log line the next restart erases, and nobody is watching the one screen
// that would have said so.
// StatusUpstreamRefused is what this server answers when a service it depends on
// says no. Not 502, though that is what it means: a proxy is entitled to replace
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
// "something went wrong". A 4xx passes through untouched, and the truth reaching
// the person who has to act on it beats the tidier status code.
const StatusUpstreamRefused = http.StatusUnprocessableEntity
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
s.log.Warn(what, "err", err)
@@ -593,12 +602,12 @@ func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what str
userID = u.ID
}
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: http.StatusBadGateway,
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, http.StatusBadGateway, code, err.Error())
writeError(w, StatusUpstreamRefused, code, err.Error())
}
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
+3 -3
View File
@@ -45,7 +45,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusBadGateway {
if resp.StatusCode != StatusUpstreamRefused {
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
resp.StatusCode)
}
@@ -68,7 +68,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
if row["what"] != "sync gmail" {
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
}
if row["status"] != float64(http.StatusBadGateway) {
if row["status"] != float64(StatusUpstreamRefused) {
t.Errorf("status = %v, want 502", row["status"])
}
}
@@ -143,7 +143,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
t.Errorf("the sync took %s; the budget is not being applied",
took.Round(time.Second))
}
if resp.StatusCode >= 500 && resp.StatusCode != http.StatusBadGateway {
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
}
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
+2 -2
View File
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
if err != nil {
s.log.Warn("ai split", "err", err)
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
if err != nil {
s.log.Warn("ai summary", "err", err)
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
+1 -1
View File
@@ -96,7 +96,7 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
if resp.StatusCode >= 400 {
s.log.Error("panel refused the restart", "status", resp.StatusCode)
writeError(w, http.StatusBadGateway, CodeInternal,
writeError(w, StatusUpstreamRefused, CodeInternal,
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
return
}
+1 -1
View File
@@ -68,7 +68,7 @@ func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusBadGateway {
if resp.StatusCode != StatusUpstreamRefused {
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
}
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {