diff --git a/internal/api/handlers/responses.go b/internal/api/handlers/responses.go index f936342..df844ca 100644 --- a/internal/api/handlers/responses.go +++ b/internal/api/handlers/responses.go @@ -41,13 +41,19 @@ type APIError struct { Message string `json:"message"` // e.g. "channel not found" } -// respond writes a JSON response with the given status code and data. -// Logs if encoding fails since the header is already written at that point. +// respond encodes before committing the status, so an unsupported value cannot +// turn a serialization error into an empty success response. func respond(w http.ResponseWriter, status int, data any) { + body, err := json.Marshal(data) + if err != nil { + slog.Error("api: failed to encode response", "component", "api", "error", err) + status = http.StatusInternalServerError + body = []byte(`{"error":{"code":"internal_server_error","message":"Unable to return this response."}}`) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(data); err != nil { - slog.Error("api: failed to encode response", "component", "api", "error", err) + if _, err := w.Write(append(body, '\n')); err != nil { + slog.Error("api: failed to write response", "component", "api", "error", err) } } diff --git a/internal/api/handlers/responses_test.go b/internal/api/handlers/responses_test.go index 4450f1d..3204e05 100644 --- a/internal/api/handlers/responses_test.go +++ b/internal/api/handlers/responses_test.go @@ -5,11 +5,31 @@ package handlers import ( "encoding/json" + "math" "net/http" "net/http/httptest" "testing" ) +func TestRespond_EncodingFailureIsJSONError(t *testing.T) { + for name, value := range map[string]any{ + "nonfinite signal": map[string]float64{"snr": math.NaN()}, + "unsupported type": make(chan int), + } { + t.Run(name, func(t *testing.T) { + w := httptest.NewRecorder() + respond(w, http.StatusOK, value) + if w.Code != http.StatusInternalServerError || w.Header().Get("Content-Type") != "application/json" { + t.Fatalf("status %d, headers %v", w.Code, w.Header()) + } + var body map[string]APIError + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["error"].Code != "internal_server_error" { + t.Fatalf("invalid error response %q: %v", w.Body.String(), err) + } + }) + } +} + func TestRespond_StatusAndContentType(t *testing.T) { w := httptest.NewRecorder() respond(w, http.StatusOK, map[string]string{"hello": "world"})