From 118c0288737fbb9172dea33425c8dde6f0572f95 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 26 May 2026 15:57:29 +0200 Subject: [PATCH] feat: add structured error response helpers --- internal/playback/handler/handler.go | 41 ++++++++++++++++++++------ internal/server/respond.go | 43 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 internal/server/respond.go diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index e189605..571cfde 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "mal/internal/domain" + "mal/internal/server" "mal/pkg/net/limits" "mal/pkg/net/proxytransport" "mal/pkg/net/useragent" @@ -86,13 +87,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) { animeID, err := strconv.Atoi(c.Param("animeId")) if err != nil || animeID <= 0 { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } episode := c.Param("episode") if episode == "" { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "missing episode") return } @@ -106,7 +107,15 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) { data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watch_episode_data_build_failed", + "playback", + "failed to load episode data", + map[string]any{"anime_id": animeID, "episode": episode, "mode": mode, "user_id": userID}, + err, + ) return } @@ -148,7 +157,7 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { } if userID == "" { // Avoid spamming 500s for anonymous playback; progress is user-scoped. - c.Status(http.StatusUnauthorized) + server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized") return } @@ -159,13 +168,21 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body") return } err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watch_progress_save_failed", + "playback", + "failed to save progress", + map[string]any{"mal_id": req.MalID, "episode": req.Episode, "user_id": userID}, + err, + ) return } @@ -185,13 +202,21 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body") return } err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watch_complete_failed", + "playback", + "failed to complete anime", + map[string]any{"mal_id": req.MalID, "user_id": userID}, + err, + ) return } diff --git a/internal/server/respond.go b/internal/server/respond.go new file mode 100644 index 0000000..6a550db --- /dev/null +++ b/internal/server/respond.go @@ -0,0 +1,43 @@ +package server + +import ( + "mal/internal/observability" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type ErrorResponse struct { + Error string `json:"error"` +} + +func RespondHTMLOrJSONError(c *gin.Context, status int, message string) { + if acceptsHTML(c) { + c.String(status, message) + c.Abort() + return + } + c.JSON(status, ErrorResponse{Error: message}) + c.Abort() +} + +func RespondError(c *gin.Context, status int, event string, component string, message string, fields map[string]any, err error) { + level := observability.LogLevelWarn + if status >= http.StatusInternalServerError { + level = observability.LogLevelError + } + observability.LogJSON(level, event, component, "", fields, err) + RespondHTMLOrJSONError(c, status, message) +} + +func acceptsHTML(c *gin.Context) bool { + if strings.Contains(c.GetHeader("Accept"), "text/html") { + return true + } + if strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true") { + return true + } + return false +} +