Skip to content

Commit c4715a8

Browse files
sarg3ntclaude
andauthored
fix(errors): emit JSON envelope from WriteHTTPError + harden fetch callers (#114)
* fix(errors): emit JSON envelope from WriteHTTPError + harden fetch callers (#112) errors.WriteHTTPError historically wrote `text/plain` via http.Error, so any API handler that returned an error through it (Logs, Services, Certificates, ...) sent a body like "Failed to fetch logs. Please try again later." Frontend JS that unconditionally `response.json()`s the body then threw `Unexpected token 'F', "Failed to "... is not valid JSON`, which the Logs and Services pages rendered verbatim as a hostile error toast. Change WriteHTTPError to emit the same `{"success": false, "message": "..."}` envelope that Handler.jsonError() produces, with `Content-Type: application/json`. Frontend callers that JSON.parse the body now get a structured error they can surface cleanly. Also harden the two visibly-affected pages (Logs, Services) to: - check response.ok before parsing - prefer the JSON envelope's .message field for the user-facing toast - fall back to response.statusText for non-JSON / malformed bodies Add regression tests for the new wire format in internal/framework/errors. Phase 1 slice of #112. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(errors): address Copilot review on WriteHTTPError JSON envelope (#112) Copilot flagged three callers that would render the new {"success":false,"message":...} envelope opaquely: 1. services.templ service-control: did `await response.text()` and toasted the raw blob. 2. certificates.templ downloadCertificate: same. 3. security.templ blockIP / unblockIP: same. Also flagged the 2xx-non-JSON fallback I added in logs.templ and services.templ as too permissive — silently rendering a non-JSON 2xx response would hide an auth-redirect HTML page or proxy error. Fixes: * security.templ now defines an `extractErrorMessage(response)` helper that parses the JSON envelope when content-type advertises JSON, falling back to text otherwise. blockIP / unblockIP route through it. * services.templ service-control inlines the same JSON-first pattern. * certificates.templ downloadCertificate inlines the same pattern. * logs.templ + services.templ success-path: a 2xx with a non-JSON body now throws a "non-JSON response" error instead of being silently rendered as log lines / empty service list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0faaca commit c4715a8

6 files changed

Lines changed: 201 additions & 14 deletions

File tree

gearbox/internal/framework/errors/errors.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ package errors
2626
import (
2727
"context"
2828
"database/sql"
29+
"encoding/json"
2930
"errors"
3031
"fmt"
3132
"log/slog"
@@ -168,6 +169,15 @@ func IgnoreNotFound(err error) error {
168169
}
169170

170171
// WriteHTTPError writes an AppError to an HTTP response and logs it.
172+
//
173+
// The wire format is a JSON envelope: `{"success": false, "message": "..."}`
174+
// with `Content-Type: application/json`. This matches the shape produced by
175+
// Handler.jsonError() and lets the frontend `response.json()` parse error
176+
// responses just like success responses — historically this used
177+
// `http.Error` (plain text), which caused JS callers that unconditionally
178+
// `JSON.parse` the body to throw `Unexpected token 'F', "Failed to "...`
179+
// (see issue #112 for the user-visible symptom on the Logs and Services
180+
// pages).
171181
func WriteHTTPError(w http.ResponseWriter, logger *slog.Logger, err error) {
172182
var appErr *AppError
173183

@@ -194,8 +204,19 @@ func WriteHTTPError(w http.ResponseWriter, logger *slog.Logger, err error) {
194204

195205
logger.LogAttrs(context.Background(), slog.LevelError, "HTTP error", logAttrs...)
196206

197-
// Write sanitized error to client
198-
http.Error(w, appErr.UserMessage, appErr.Code)
207+
// Write sanitized error to client as a JSON envelope so JS callers can
208+
// `response.json()` it without throwing on plain-text bodies.
209+
w.Header().Set("Content-Type", "application/json")
210+
w.WriteHeader(appErr.Code)
211+
if encErr := json.NewEncoder(w).Encode(map[string]any{
212+
"success": false,
213+
"message": appErr.UserMessage,
214+
}); encErr != nil {
215+
// Encoding can fail only if the writer is broken — the header is
216+
// already on the wire so we can't recover the response, just log.
217+
logger.LogAttrs(context.Background(), slog.LevelError, "encode error envelope failed",
218+
slog.String("error", encErr.Error()))
219+
}
199220
}
200221

201222
// SanitizeError converts any error to a user-safe message.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package errors
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// TestWriteHTTPError_JSONEnvelope guards the wire format consumers of
14+
// WriteHTTPError rely on — Logs page, Services page, and any other JS that
15+
// calls `response.json()` on both success and error responses. Historically
16+
// WriteHTTPError emitted `text/plain` ("Failed to ...") which caused
17+
// `JSON.parse` to throw `Unexpected token 'F'` in the browser; the fix in
18+
// issue #112 standardizes on a JSON envelope.
19+
func TestWriteHTTPError_JSONEnvelope(t *testing.T) {
20+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
21+
w := httptest.NewRecorder()
22+
23+
WriteHTTPError(w, logger, Internal("fetch logs", io.EOF))
24+
25+
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
26+
t.Fatalf("Content-Type = %q, want application/json", got)
27+
}
28+
if got := w.Code; got != http.StatusInternalServerError {
29+
t.Fatalf("Status = %d, want 500", got)
30+
}
31+
32+
var body map[string]any
33+
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
34+
t.Fatalf("response body is not JSON: %v\nbody=%q", err, w.Body.String())
35+
}
36+
if body["success"] != false {
37+
t.Errorf("body.success = %v, want false", body["success"])
38+
}
39+
msg, _ := body["message"].(string)
40+
if !strings.Contains(msg, "fetch logs") {
41+
t.Errorf("body.message = %q, want it to contain 'fetch logs'", msg)
42+
}
43+
}
44+
45+
// TestWriteHTTPError_UnknownErrorWraps verifies that a plain error (not an
46+
// AppError) still serializes as a JSON envelope with the generic
47+
// "process request" wrapping that Internal() produces.
48+
func TestWriteHTTPError_UnknownErrorWraps(t *testing.T) {
49+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
50+
w := httptest.NewRecorder()
51+
52+
WriteHTTPError(w, logger, io.ErrUnexpectedEOF)
53+
54+
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
55+
t.Fatalf("Content-Type = %q, want application/json", got)
56+
}
57+
var body map[string]any
58+
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
59+
t.Fatalf("response body is not JSON: %v\nbody=%q", err, w.Body.String())
60+
}
61+
if body["success"] != false {
62+
t.Errorf("body.success = %v, want false", body["success"])
63+
}
64+
}

gearbox/internal/framework/templates/pages/certificates.templ

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,8 +470,23 @@ templ Certificates(user *models.User, servers []models.BoxConfig, perms *models.
470470
const response = await fetch(`/api/${serverID}/certificates/${encodeURIComponent(domain)}/download`);
471471

472472
if (!response.ok) {
473-
const text = await response.text();
474-
throw new Error(text || `HTTP ${response.status}`);
473+
// errors.WriteHTTPError now returns a JSON envelope; older
474+
// http.Error paths still return text/plain. Try JSON first
475+
// so the toast surfaces the structured .message field
476+
// rather than the raw '{"success":false,...}' blob.
477+
const ct = response.headers.get('content-type') || '';
478+
let msg;
479+
if (ct.includes('application/json')) {
480+
try {
481+
const body = await response.json();
482+
msg = (body && (body.message || body.error)) || JSON.stringify(body);
483+
} catch (_) {
484+
msg = await response.text();
485+
}
486+
} else {
487+
msg = await response.text();
488+
}
489+
throw new Error(msg || `HTTP ${response.status}`);
475490
}
476491

477492
// Get filename from Content-Disposition header or use default

gearbox/internal/framework/templates/pages/logs.templ

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,14 +489,44 @@ templ Logs(user *models.User, servers []models.BoxConfig) {
489489
const url = `/api/${serverID}/logs/${logName}?lines=${lines}`;
490490

491491
fetch(url)
492-
.then(response => response.json())
492+
.then(async response => {
493+
// Backend returns a JSON envelope for both success and
494+
// error (see errors.WriteHTTPError) — try to parse it and
495+
// surface .message on error instead of letting JSON.parse
496+
// failures bubble up as confusing "Unexpected token" toasts.
497+
// A 2xx response with a non-JSON body is a protocol error
498+
// (e.g. an auth-redirect HTML page leaking through) and
499+
// must surface as such, not be rendered as log lines.
500+
const contentType = response.headers.get('content-type') || '';
501+
const isJSON = contentType.includes('application/json');
502+
if (!response.ok) {
503+
let msg = response.statusText || `HTTP ${response.status}`;
504+
if (isJSON) {
505+
try {
506+
const body = await response.json();
507+
if (body && (body.message || body.error)) {
508+
msg = body.message || body.error;
509+
}
510+
} catch (_) { /* fall through */ }
511+
}
512+
throw new Error(msg);
513+
}
514+
if (!isJSON) {
515+
throw new Error(`Unexpected non-JSON response from logs API (content-type=${contentType})`);
516+
}
517+
return response.json();
518+
})
493519
.then(data => {
494520
rawLogContent = data.logs || 'No logs available';
495521
displayLogs(rawLogContent);
496522
})
497523
.catch(error => {
498-
document.getElementById('log-container').innerHTML =
499-
'<div class="text-red-400">Error loading logs: ' + error.message + '</div>';
524+
const container = document.getElementById('log-container');
525+
container.replaceChildren();
526+
const div = document.createElement('div');
527+
div.className = 'text-red-400';
528+
div.textContent = 'Error loading logs: ' + error.message;
529+
container.appendChild(div);
500530
});
501531
}
502532

gearbox/internal/framework/templates/pages/security.templ

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,24 @@ templ SecurityPage(user *models.User, servers []models.BoxConfig, perms *models.
200200
let blockedIPsData = [];
201201
const canAction = { if perms != nil && perms.HasPermission(models.ComponentSecurity, models.PermissionAction) { "true" } else { "false" } } === 'true';
202202

203+
// extractErrorMessage normalizes the body of a non-2xx fetch response
204+
// into a user-facing string. errors.WriteHTTPError now returns a JSON
205+
// envelope `{success:false, message:...}`; older `http.Error` paths
206+
// still return text/plain. Try JSON first so the toast surfaces
207+
// .message instead of the raw envelope, then fall back to text.
208+
async function extractErrorMessage(response) {
209+
const ct = response.headers.get('content-type') || '';
210+
if (ct.includes('application/json')) {
211+
try {
212+
const body = await response.json();
213+
return (body && (body.message || body.error)) || JSON.stringify(body);
214+
} catch (_) {
215+
return await response.text();
216+
}
217+
}
218+
return await response.text();
219+
}
220+
203221
document.addEventListener('DOMContentLoaded', function() {
204222
const serverInput = document.getElementById('default-server-id');
205223
if (serverInput) {
@@ -363,8 +381,7 @@ templ SecurityPage(user *models.User, servers []models.BoxConfig, perms *models.
363381
});
364382

365383
if (!response.ok) {
366-
const error = await response.text();
367-
throw new Error(error);
384+
throw new Error(await extractErrorMessage(response));
368385
}
369386

370387
// Clear form and refresh
@@ -391,8 +408,7 @@ templ SecurityPage(user *models.User, servers []models.BoxConfig, perms *models.
391408
});
392409

393410
if (!response.ok) {
394-
const error = await response.text();
395-
throw new Error(error);
411+
throw new Error(await extractErrorMessage(response));
396412
}
397413

398414
await loadBlockedIPs();

gearbox/internal/framework/templates/pages/services.templ

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,33 @@ templ Services(user *models.User, servers []models.BoxConfig) {
213213
]);
214214

215215
fetch('/api/' + serverID + '/services?services=' + monitoredServices.join(','))
216-
.then(function(response) { return response.json(); })
216+
.then(async function(response) {
217+
// Backend returns a JSON envelope for both success and
218+
// error (see errors.WriteHTTPError); surface .message on
219+
// error so the UI doesn't show raw 'Unexpected token' from
220+
// JSON.parse on plain-text bodies. A 2xx response with a
221+
// non-JSON body (e.g. an auth-redirect HTML page leaking
222+
// through) is a protocol error and must surface as such,
223+
// not be silently swallowed as an empty list.
224+
const contentType = response.headers.get('content-type') || '';
225+
const isJSON = contentType.includes('application/json');
226+
if (!response.ok) {
227+
let msg = response.statusText || ('HTTP ' + response.status);
228+
if (isJSON) {
229+
try {
230+
const body = await response.json();
231+
if (body && (body.message || body.error)) {
232+
msg = body.message || body.error;
233+
}
234+
} catch (_) { /* fall through */ }
235+
}
236+
throw new Error(msg);
237+
}
238+
if (!isJSON) {
239+
throw new Error('Unexpected non-JSON response from services API (content-type=' + contentType + ')');
240+
}
241+
return response.json();
242+
})
217243
.then(function(data) {
218244
if (data.error) {
219245
container.innerHTML = '<div class="col-span-full text-red-500 dark:text-red-400 p-4 bg-red-50 dark:bg-red-900/20 rounded">' + escapeHtml(data.error) + '</div>';
@@ -500,8 +526,23 @@ templ Services(user *models.User, servers []models.BoxConfig) {
500526
});
501527

502528
if (!response.ok) {
503-
const text = await response.text();
504-
throw new Error(text || 'Failed to ' + action + ' service');
529+
// errors.WriteHTTPError now returns a JSON envelope; older
530+
// http.Error paths still return text/plain. Try JSON first
531+
// so the toast surfaces the structured .message field
532+
// rather than the raw '{"success":false,...}' blob.
533+
const ct = response.headers.get('content-type') || '';
534+
let msg;
535+
if (ct.includes('application/json')) {
536+
try {
537+
const body = await response.json();
538+
msg = (body && (body.message || body.error)) || JSON.stringify(body);
539+
} catch (_) {
540+
msg = await response.text();
541+
}
542+
} else {
543+
msg = await response.text();
544+
}
545+
throw new Error(msg || 'Failed to ' + action + ' service');
505546
}
506547

507548
const data = await response.json();

0 commit comments

Comments
 (0)