package handlers import ( "encoding/json" "errors" "net/http" "strconv" "time" "xprem/internal/services" ) type APIError struct { Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail"` } // RenderError enforces the structured RFC 7807 error format func RenderError(w http.ResponseWriter, status int, detail string) { w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") w.WriteHeader(status) json.NewEncoder(w).Encode(APIError{ Title: http.StatusText(status), Status: status, Detail: detail, }) } // RenderThrottled is the single response every rate-limited endpoint gives, and // it says nothing beyond "too many, wait". // // The wording is fixed on purpose. It must not vary with which limit was // reached, nor with whether the account named exists, because a caller able to // tell those apart can enumerate accounts by watching how the refusal changes. // One message for every case is what makes the counters unobservable from // outside. func RenderThrottled(w http.ResponseWriter, retryAfter time.Duration) { seconds := int(retryAfter.Seconds()) if seconds < 1 { seconds = 1 } w.Header().Set("Retry-After", strconv.Itoa(seconds)) RenderError(w, http.StatusTooManyRequests, "Too many attempts. Try again later.") } // RenderJSON writes payload as a JSON body with the given status. The success // counterpart to RenderError, shared so every handler renders the same way. func RenderJSON(w http.ResponseWriter, status int, payload interface{}) { marshaledResponse, err := json.Marshal(payload) if err != nil { RenderError(w, http.StatusInternalServerError, "An internal error occurred.") return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(marshaledResponse) } // activeRolloutConflictMessage is the CLI-facing 409 body for publish, republish and // rollback attempts against a branch and runtime version with an active per-update // rollout. The republish and rollback commands print it verbatim. const activeRolloutConflictMessage = "A progressive rollout is already active for this branch and runtime version. Finish or revert it from the dashboard first." // RenderCliAuthError distinguishes three outcomes: a credential that failed to // authenticate (401, generic message so nothing leaks about why), one that // authenticated but is blocked by its access rules (403, with the reason so // the CLI user knows what to fix), and one that could not be judged at all // because the control plane was unreachable (500, because "your key is // invalid" is the wrong thing to tell a CI job during a database blip). func RenderCliAuthError(w http.ResponseWriter, err error) { if errors.Is(err, services.ErrCliAccessDenied) { http.Error(w, err.Error(), http.StatusForbidden) return } if errors.Is(err, services.ErrCliAuthUnavailable) { http.Error(w, "Could not verify this credential", http.StatusInternalServerError) return } http.Error(w, "Error validating auth", http.StatusUnauthorized) }