1 Commits

Author SHA1 Message Date
4a91635b2a Render templates via a buffer, not the ResponseWriter (closes #123)
All checks were successful
check / check (push) Successful in 3m48s
executeTemplate ran the template straight into the ResponseWriter, so a
mid-render failure left the already-emitted prefix written and the
response committed: the handler could no longer set a 500 and the
client got a truncated page, typically with a 200. It also let handler
tests pass against the flushed prefix of a page that aborted below the
assertions.

Execute into a bytes.Buffer instead, and set the content type and copy
the buffer out only once rendering has fully succeeded. On failure
nothing has been written, so the 500 still reaches the client.

Add a test that renders a template failing partway through and asserts
both the 500 and that the body carries no part of the aborted page.
Against the previous streaming renderer it fails on both counts (200,
body "PARTIAL PAGE CONTENTInternal server error").
2026-08-12 09:40:54 +00:00
3 changed files with 99 additions and 3 deletions

View File

@@ -1,6 +1,19 @@
package handlers package handlers
import "net/http" import (
"html/template"
"net/http"
)
// AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a
// template of its own.
func (s *Handlers) AddTemplateForTest(
pageTemplate string,
tmpl *template.Template,
) {
s.templates[pageTemplate] = tmpl
}
// RenderTemplateForTest exposes renderTemplate for use in the // RenderTemplateForTest exposes renderTemplate for use in the
// handlers_test package. // handlers_test package.

View File

@@ -3,6 +3,7 @@
package handlers package handlers
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -224,13 +225,20 @@ func (s *Handlers) renderTemplate(
s.executeTemplate(w, tmpl, wrapper) s.executeTemplate(w, tmpl, wrapper)
} }
// executeTemplate runs the template and handles errors. // executeTemplate renders the template into a buffer and writes to
// the response only once rendering has fully succeeded. Executing
// straight into the ResponseWriter commits a partial body and a 200
// status before a mid-render error can be reported, leaving no way
// to serve a 500. These pages are small, so holding one in memory is
// the right trade.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
) { ) {
err := tmpl.Execute(w, data) var buf bytes.Buffer
err := tmpl.Execute(&buf, data)
if err != nil { if err != nil {
s.log.Error( s.log.Error(
"failed to execute template", "error", err, "failed to execute template", "error", err,
@@ -239,5 +247,16 @@ func (s *Handlers) executeTemplate(
w, "Internal server error", w, "Internal server error",
http.StatusInternalServerError, http.StatusInternalServerError,
) )
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error(
"failed to write rendered page", "error", err,
)
} }
} }

View File

@@ -2,6 +2,8 @@ package handlers_test
import ( import (
"context" "context"
"errors"
"html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "sync"
@@ -220,6 +222,68 @@ func TestRenderTemplate(t *testing.T) {
) )
} }
// errMidRender is the failure a test template raises partway through
// rendering.
var errMidRender = errors.New("deliberate mid-render failure")
// midRenderFailure is template data whose first method renders and
// whose second fails, so the template aborts after output has
// already been produced.
type midRenderFailure struct{}
// Prefix is the output a streaming renderer would flush before the
// failure below aborts the template.
func (midRenderFailure) Prefix() string { return partialPageMarker }
// Boom aborts template execution.
func (midRenderFailure) Boom() (string, error) {
return "", errMidRender
}
// partialPageMarker is content the failing template emits before it
// aborts.
const partialPageMarker = "PARTIAL PAGE CONTENT"
// TestRenderTemplateMidRenderErrorSendsNoPartialBody proves the
// renderer does not commit output it cannot finish: a template that
// fails partway through must yield a 500 and a body carrying none of
// the content emitted before the failure. Against a renderer that
// executes straight into the ResponseWriter this fails on both
// counts, returning 200 with the prefix already flushed.
func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(
`{{.Data.Prefix}}{{.Data.Boom}}TAIL`,
),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.RenderTemplateForTest(
w, req, "failing.html", midRenderFailure{},
)
assert.Equal(
t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500",
)
assert.Equal(
t, "Internal server error\n", w.Body.String(),
"the response must carry no part of the aborted page",
)
}
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()