Iris gives you two layers for writing responses. The response helpers on
the context are the recommended surface for JSON APIs: each takes the value
and the error your service returned and decides what to send, including the
error response when something went wrong. Underneath sit the Context
writers (JSON, XML, YAML, text, files, streams) for the cases where you need
full control over the bytes on the wire. This chapter covers both layers, plus status codes,
response headers, redirects, streaming, Server-Sent Events, content
negotiation and the handler that runs when a response write itself fails.
Background
An HTTP response mirrors the request’s shape. Its first line (the
status line) carries a three-digit status code and a short reason
phrase (“200 OK”, “404 Not Found”). Headers follow, describing what the
body is (Content-Type), how large it is (Content-Length) and how it
may be cached (Cache-Control). The body (HTML, JSON, file bytes)
comes last, and some responses legitimately have none.
Status codes are grouped by their first digit, and the class alone tells a client how to react:
- 2xx: success. 200 carries a body, 201 announces a created resource, 204 succeeds with nothing to return.
- 3xx: redirection. The resource is elsewhere, or (304) the copy the client already holds is still fresh.
- 4xx: the client made a mistake. Malformed input (400), missing credentials (401), no permission (403), no such resource (404).
- 5xx: the server failed. The request itself may have been perfectly valid.
Treat the code you send as an API contract, because software you do not control acts on it. Browsers follow 3xx redirects on their own. Proxies and CDNs cache responses based on the code and headers: a 200 may be stored and replayed, a 500 will not be. HTTP clients and load balancers retry on some codes and give up on others. Monitoring counts 5xx responses to decide whether to page someone. Send a 200 with an error message hidden in the body and every one of those layers draws the wrong conclusion.
Content negotiation is the name for “one URL, several formats”. The
client’s Accept request header lists the representations it can handle
(application/json, text/xml, …) with optional preference weights;
the server compares that list against what it can produce and picks the
best match, or answers 406 Not Acceptable when there is none. The same
mechanism extends to compression (Accept-Encoding) and language
(Accept-Language).
What this chapter’s helpers remove from your code is the ceremony around those rules. Picking the right status for success and failure, shaping a consistent error body, remembering that headers must precede the first body byte. The response helpers encode those decisions once, so a typical handler ends with a single line instead of a block of status bookkeeping.
The Response Helpers
In a typical handler the response is the last line: call the service, then hand the result and the error to one helper. Every helper follows the same contract:
ok := ctx.Helper(value, err) // ok == false: an error response was writtenIf err is non-nil, the helper routes it through the central error mapping
of rest/errors (see Chapter 9) and a structured
error response is written. Your handler never branches on the error.
Otherwise the helper renders the success response. The returned bool is
true on success; you only check it when more work follows the write.
package main
import (
"github.com/kataras/iris/v14"
)
type ArticlesAPI struct {
svc *ArticleService // your application's service layer.
}
func NewArticlesAPI(svc *ArticleService) *ArticlesAPI {
return &ArticlesAPI{svc: svc}
}
func (api *ArticlesAPI) Routing(r iris.Router) {
r.Get("/", api.list)
r.Post("/", api.create)
r.Get("/{id:int64}", api.get)
r.Put("/{id:int64}", api.update)
r.Delete("/{id:int64}", api.delete)
}
func (api *ArticlesAPI) get(ctx iris.Context) {
id, ok := ctx.BindParam[int64]("id")
if !ok {
return // error response already written.
}
article, err := api.svc.ByID(ctx, id)
ctx.OK(article, err)
}
func (api *ArticlesAPI) create(ctx iris.Context) {
input, ok := ctx.BindJSON[ArticleInput]()
if !ok {
return // 400 already written: malformed JSON or failed Validate.
}
id, err := api.svc.Create(ctx, input)
ctx.Created(id, err) // 201 with {"id": ...}.
}
func (api *ArticlesAPI) update(ctx iris.Context) {
id, ok := ctx.BindParam[int64]("id")
if !ok {
return
}
input, ok := ctx.BindJSON[ArticleInput]()
if !ok {
return
}
updated, err := api.svc.Update(ctx, id, input)
ctx.NoContent(updated, err) // 204, or 304 if nothing changed.
}
func (api *ArticlesAPI) delete(ctx iris.Context) {
id, ok := ctx.BindParam[int64]("id")
if !ok {
return
}
deleted, err := api.svc.Delete(ctx, id)
ctx.NoContent(deleted, err)
}The full set of response helpers:
| Helper | Success status | Renders |
|---|---|---|
ctx.OK(resp, err) | 200 (status untouched) | resp as JSON; a nil resp sends nothing |
ctx.Created(resp, err) | 201 | id scalars wrapped as {"id": ...}, other values as-is |
ctx.NoContent(modified, err) | 204 or 304 | empty body: 204 when modified is true, 304 otherwise |
ctx.Count(rowsAffected, err) | 200 | {"count": N}; zero is a valid answer, a negative count renders as zero |
ctx.List(items, err, tolerate...) | 200 | items as a JSON array; empty and nil both render [] |
ctx.JSONOrError(resp) | current | resp as JSON, with encode failures handled for you |
ctx.Paginated(collect) | 200 | the standard page envelope |
Two of the family are generic: ctx.Count infers the count’s numeric
type from its argument, and ctx.Paginated infers filter, item and count
types from the collector you pass. Both follow the same contract as the
others: the error routes through the central mapping, and false means a
response was written.
ctx.JSONOrError is the low-level member of the family: it has no error
argument, it simply calls ctx.JSON and converts an encoding failure into a
proper error response instead of a broken body. Reach for it when the status
code or the value needs preparing first.
What Created renders
ctx.Created recognizes identifier values and wraps them in a JSON object,
so a create handler can return the new id directly from the service:
type IDType interface {
string | int | int64 | uint64 | uuid.UUID
}
type IDPayload[T IDType] struct {
ID T `json:"id"`
}A string, int, int64, uint64 or uuid.UUID result becomes
{"id": ...} (a UUID is rendered as its string form). The uuid.UUID
here is the standard library’s uuid package type (Go 1.27), the same
one route macros and the book’s examples use. A []string whose
elements are UUIDs becomes a JSON array of {"id": ...} objects, useful
for bulk inserts. Any other value, such as the full created entity, is sent
as-is. A nil value sends the bare 201.
What List renders
A list endpoint that is not paginated has two shapes to get right, and
hand-written versions get both wrong. A nil slice marshals as null
rather than [], which every client has to special-case; and a store that
reports “not found” for an empty result set turns an ordinary empty list
into a 404. ctx.List answers both:
func (api *ArticlesAPI) list(ctx iris.Context) {
articles, err := api.svc.All(ctx)
ctx.List(articles, err, sql.ErrNoRows) // 200 [] when there is nothing.
}A nil or empty slice renders [], never null, so the client can always
iterate the answer. An error matching one of the tolerated sentinels
(errors.Is, so a wrapped one matches) renders [] as well: a
repository’s not-found for an empty set is not a failure of the list
endpoint. Any other error goes through the central error mapping and
List reports false, exactly like the rest of the family. Passing
something that is neither a slice nor an array is a programmer error and
panics naming the type, rather than rendering a JSON object where the
client expects an array.
Reach for ctx.Paginated instead as soon as the list can grow: List
sends the whole set.
Pagination in one call
ctx.Paginated runs the complete listing flow: it reads ?page= and
?size= from the query and an optional JSON filter object from the request
body, calls your collector with the parsed options, and renders the page
envelope.
import (
stdContext "context"
"github.com/kataras/iris/v14"
)
func (api *ArticlesAPI) list(ctx iris.Context) {
ctx.Paginated(func(reqCtx stdContext.Context,
opts iris.PageOptions, filter ArticleFilter) ([]Article, int64, error) {
return api.svc.List(reqCtx, opts, filter)
})
}The collector returns the requested window of items and the total row count; everything else (limit/offset math, the envelope, error handling) is done for you. The response shape:
{
"current_page": 1,
"page_size": 20,
"total_pages": 3,
"total_items": 42,
"has_next_page": true,
"filter": null,
"items": [{"id": 1, "title": "First"}]
}Request reading (ctx.BindJSON, validation, ctx.BindParam) is covered in
Chapter 5.
Context Writers
When a helper does not fit (a non-JSON format, a custom shape, a template),
drop down to the Context writers. Each one sets the matching Content-Type
and writes the body:
| Method | Content-Type | Options value |
|---|---|---|
ctx.JSON(v, ...) | application/json | iris.JSON |
ctx.JSONP(v, ...) | text/javascript | iris.JSONP |
ctx.XML(v, ...) | text/xml | iris.XML |
ctx.YAML(v) | application/x-yaml | — |
ctx.TextYAML(v) | text/yaml | — |
ctx.Markdown(b, ...) | text/html | iris.Markdown |
ctx.Text(s) | text/plain | — |
ctx.HTML(s) | text/html | — |
ctx.Binary(b) | application/octet-stream | — |
JSON, JSONP, XML and Markdown accept an optional options value as
their last argument; the rest take none. Every one of them returns a single
error, nil when the body was written.
JSONP is a legacy format: the JSON is wrapped in a JavaScript function
call so that browsers predating cross-site request rules could load it
through a <script> tag. New APIs rarely need it.
JSON
func report(ctx iris.Context) {
data := iris.Map{"name": "Jane", "score": 9001}
ctx.JSON(data, iris.JSON{
Indent: " ", // pretty-print with two spaces.
EscapeHTML: false, // true escapes <, >, & inside strings.
Prefix: "", // optional string written before the body.
EscapeNonASCII: false, // escape non-ASCII runes when true.
ArrayPrefix: false, // prepend "while(1);" to top-level arrays.
})
}Without options, ctx.JSON(v) streams the value with the default encoder,
which is the standard library’s encoding/json/v2 (Go 1.27). Two of its
defaults are worth knowing. HTML characters inside strings are not
escaped: responses are served as application/json, never inline HTML,
so the escaping buys nothing and costs bytes; set EscapeHTML: true to
restore the old encoding/json behavior (JSONP always escapes, because
there the payload does land in a script body). And nil slices and maps
render as [] and {} rather than null, so list endpoints never make
clients branch on null. time.Duration still encodes as int64
nanoseconds. For anything else the struct’s MarshalOptions field
accepts raw encoding/json/v2 options, appended after the framework
defaults so yours win. iris.Map is a shorthand for map[string]any.
Those framework defaults live in one place, core/pkg/jsonutil, and every
part of Iris that touches JSON composes its options from there: the context
writers and readers, the rest/errors renderers, the SSE and AI encoders,
the access log and the HTTP client. That is why a value encoded by the
client and decoded by a handler cannot disagree about what the wire looks
like.
XML
Structs marshal through encoding/xml. Maps need the iris.XMLMap wrapper
because Go maps have no element name:
ctx.XML(payload, iris.XML{Indent: " "})
ctx.XML(iris.XMLMap("Root", iris.Map{"key": "value"}))Markdown
ctx.Markdown parses the given markdown bytes to HTML and serves the result
as text/html. Set Sanitize when the source is user-provided:
ctx.Markdown([]byte("# Release Notes"), iris.Markdown{Sanitize: true})Raw writes
For output that needs no content-type handling, write directly:
ctx.WriteString("plain body")
ctx.Writef("hello, %s", name)
ctx.Write([]byte{0xCA, 0xFE})HTML template rendering goes through the middleware/view package
(view.Handler plus view.Render) and is covered in
Chapter 12.
Status Codes
ctx.StatusCode sets the response status; ctx.GetStatusCode reads what has
been set so far. The status must be set before the first body write; writing
the body flushes the status line and headers to the wire:
func createLegacy(ctx iris.Context) {
ctx.StatusCode(iris.StatusCreated) // 201, before the body.
ctx.JSON(iris.Map{"id": 42})
}That handler is named for what it is. ctx.Created(42, err) from
The Response Helpers above sets the same status,
wraps the same {"id": 42} and routes the error, so a real create endpoint
never sets a status by hand.
Iris exposes the full set of constants: iris.StatusOK,
iris.StatusCreated, iris.StatusNoContent, iris.StatusBadRequest,
iris.StatusNotFound, iris.StatusInternalServerError and the rest.
iris.StatusText(code) returns the standard reason phrase.
The StopWith family sets a status, writes an optional body and stops the
handler chain in one call (following ctx.Next calls are ignored):
| Method | Body |
|---|---|
ctx.StopWithStatus(code) | none |
ctx.StopWithText(code, text) | the given text |
ctx.StopWithJSON(code, v) | v as JSON |
ctx.StopWithError(code, err) | err.Error() as text; none when err is private |
ctx.StopWithPlainError(code, err) | none; the error is stored for error handlers |
StopWithError and StopWithPlainError also record the error through
ctx.SetErr, so middleware running after the handler can inspect it
with ctx.GetErr, or with ctx.ErrPublic, which reports whether the
stored error is safe to show the client and hands back the original
either way. If the error is private, because iris.PrivateError or
errors.Private marked it or it wraps such an error anywhere in its
chain, StopWithError withholds the message, sends only the status and
stores the error as private. The “none” in the table is what the method
writes. On an application built with iris.NewBuilder() the response
does not stay empty: errors.Handler fills a failure status that nobody
gave a body to with the JSON error envelope after the chain returns. For
API error responses prefer the structured senders of rest/errors
(Chapter 9); the StopWith methods are the
low-level tools they are built on.
Response Headers
ctx.SetHeader writes a response header; an empty value deletes it. Request
and response access are separate methods, a common source of confusion:
| Read the request | Read the response |
|---|---|
ctx.Header("X-Key") | ctx.ResponseHeader("X-Key") |
ctx.ContentType() | ctx.ResponseContentType() |
ctx.MediaType() | ctx.ResponseMediaType() |
func cached(ctx iris.Context) {
ctx.SetHeader("Cache-Control", "public, max-age=3600")
ctx.SetHeader("X-API-Version", "1")
ctx.JSON(iris.Map{"ok": true})
// Reads back what this handler set, not what the client sent:
_ = ctx.ResponseHeader("Cache-Control") // "public, max-age=3600"
_ = ctx.ResponseMediaType() // "application/json"
}SetHeader replaces any value already set for that name, so a middleware
registered twice cannot emit the header twice. When a list is what you mean,
ctx.AddHeader appends instead, and the underlying header map is always
there for anything else:
ctx.AddHeader("Vary", "Accept-Encoding")
ctx.AddHeader("Vary", "Accept-Language")
ctx.ResponseWriter().Header().Set("X-Custom", "only-value")
ctx.ResponseWriter().Header().Add("Link", "</style.css>; rel=preload")ctx.SetContentType sets the Content-Type and returns an error for invalid
values; the writers above call it for you, so you only need it with raw
writes. Like the status code, headers must be in place before the first body
byte; anything set afterwards is silently dropped by net/http.
Redirects
A redirect is a response with a 3xx status and a Location header
naming another URL; a browser (or any client configured to follow
redirects) reacts by requesting that URL instead. The code chooses the
semantics: 301 and 308 mean “moved for good, update your links” and may
be cached by browsers for a long time, while 302, 303 and 307 mean “go
here this time”.
ctx.Redirect sends a redirect and stops the handler chain. The status code
argument is optional and defaults to 302 (iris.StatusFound):
func oldEndpoint(ctx iris.Context) {
ctx.Redirect("/v2/endpoint", iris.StatusMovedPermanently) // 301
}
func afterLogin(ctx iris.Context) {
ctx.Redirect("/dashboard") // 302 by default.
}Use iris.StatusTemporaryRedirect (307) or iris.StatusPermanentRedirect
(308) when the client must replay the request with the same method and body,
and iris.StatusSeeOther (303) to turn a POST into a follow-up GET. A
non-3xx status set earlier on the context is ignored: redirect responses
are always sent with a redirect code.
File Responses
Three methods cover files, differing in where the content comes from and how the browser treats it:
// Inline: render in the browser (assets, images, PDFs to display).
func manual(ctx iris.Context) {
if err := ctx.ServeFile("./documents/manual.pdf"); err != nil {
return // 404, 403 or 500 status already set.
}
}
// Attachment: force a download dialog with the given file name.
func export(ctx iris.Context) {
ctx.SendFile("./exports/report-2026.csv", "report.csv")
}
// From memory or any io.ReadSeeker, with a modification time.
func generated(ctx iris.Context) {
content := bytes.NewReader(pdfBytes)
ctx.ServeContent(content, "invoice.pdf", time.Now())
}SendFile is ServeFile plus a Content-Disposition: attachment header; it
derives the download name from the source path when the second argument is
empty. ServeContent delegates to http.ServeContent, so Range requests,
If-Modified-Since and ETag-based conditional requests work out of the box.
Every method has a rate-limited variant that throttles the transfer.
limit is bytes per second, burst the bucket size in bytes:
ctx.ServeFileWithRate("./files/video.mp4", 1024*1024, 64*1024) // ~1 MB/s
ctx.SendFileWithRate("./files/big.zip", "big.zip", 256*1024, 32*1024)
ctx.ServeContentWithRate(content, "data.bin", modTime, 512*1024, 32*1024)Conditional requests are how HTTP avoids resending unchanged content: the client remembers a fingerprint of the copy it holds (its modification time, or an ETag, an opaque version tag chosen by the server) and sends it back on the next request, meaning “only send the body if this changed”. When nothing changed, the server answers 304 Not Modified with an empty body and the client keeps its copy. Range requests are the related trick for large files: the client asks for a byte slice instead of the whole file, which is what makes download resumption and video seeking work.
For conditional non-file responses, the same primitives http.ServeContent
uses are available directly: ctx.SetLastModified(t) sets the header,
ctx.CheckIfModifiedSince(t) compares against the request,
ctx.WriteNotModified() sends a 304, and ctx.WriteWithExpiration(body, t)
combines all three into one call.
Serving whole directories is the job of the controller/fileserver
package, covered with its caching options in
Chapter 14.
Streaming
Normally the server buffers a response and sends it when the handler returns. Flushing changes that: each chunk is pushed to the client the moment it is written, turning the connection into a live feed.
For a response produced over time (a progress feed, a large export), write in chunks and flush each one to the client:
func progress(ctx iris.Context) {
ctx.SetContentType("text/plain")
for i := 1; i <= 5; i++ {
ctx.Writef("step %d done\n", i)
ctx.ResponseWriter().Flush() // push the chunk now.
time.Sleep(time.Second)
}
}ctx.StreamWriter packages the same loop: it calls your function,
flushes, and calls it again until you return a non-nil error or the client
goes away. Return nil to continue with the next chunk; return an error
(io.EOF is the conventional “nothing more to send”) to end the stream:
import (
"fmt"
"io"
"time"
"github.com/kataras/iris/v14"
)
func countdown(ctx iris.Context) {
ctx.SetContentType("text/plain")
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
left := 10
ctx.StreamWriter(func(w io.Writer) error {
if left == 0 {
return io.EOF // any non-nil error ends the stream.
}
<-ticker.C
left--
_, err := fmt.Fprintf(w, "%d seconds left\n", left)
return err // nil: flush this chunk and call me again.
})
}When the client disconnects, StreamWriter returns the request context’s
cancellation error, so the goroutine does not leak. In hand-rolled loops,
check ctx.IsCanceled() between chunks for the same guarantee.
StreamWriter is a poll loop: the framework asks for the next chunk when
it is ready for one. That suits a writer which blocks until it has
something to say (a ticker, a queue read, a database cursor) and fits
badly when the events arrive from a producer goroutine on their own
schedule. For Server-Sent Events, the next section is the better path in
both shapes.
One trap applies to every stream, whichever way you write it. Two of the
bundled middleware buffer the body: middleware/compress writes into a
compressor, and middleware/recorder writes into a bytes.Buffer, which
accesslog installs whenever it reads the response body, and which
iris.NewBuilder().LogRequests(true) therefore turns on. Neither forwards
anything when you flush, so the handler streams into a buffer and the
client sees one lump on return. ctx.DisableResponseBuffering() removes
those wrappers for the current response, before the first write, and
returns an error naming any writer that refused to step aside. The sse
package calls it for you.
Server-Sent Events
Server-Sent Events (SSE) is a one-way stream of text frames sent over an
ordinary HTTP response. A frame is a group of field: value lines ended
by a blank line, and a line beginning with a colon is a comment:
event: token
data: Hello
: keep-alive
Four fields are defined. event names the event and, when absent, the
client receives it as message; data carries the payload; id becomes
the client’s resume cursor; retry sets how long the client waits before
reconnecting. Browsers consume the format natively through EventSource,
which reconnects on its own and sends the last id it saw back in a
Last-Event-ID header. Everything else is ordinary HTTP: status codes,
authorization headers and proxies all behave as they do for any other
response.
Writing those frames by hand is two lines of fmt.Fprintf, which is why
so many services do, and why so many of them fail in the same four places.
A buffering middleware holds the frames. The server’s absolute write
timeout ends the response mid-answer. A reverse proxy buffers it anyway.
And a client that vanishes without closing its connection is never
noticed, so the work behind the stream runs on for nobody. The sse
package exists for those four, and the rest of this section is what it
does about them.
Opening a stream
sse.Open prepares the response and writes nothing:
import (
"fmt"
"time"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
"github.com/kataras/iris/v14/sse"
)
func events(ctx iris.Context) {
stream, err := sse.Open(ctx)
if errors.HandleError(ctx, err) {
return // ErrNotStreamable renders as a 500; nothing has been written.
}
// Close writes the terminal frame, on success as well as on failure.
defer stream.Close(nil)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for i := 1; i <= 10; i++ {
select {
case <-ctx.ShuttingDown():
return // the server is stopping: end while we still can.
case <-stream.Context().Done():
return // the client is gone.
case <-ticker.C:
}
if err := stream.Send(sse.Event{
Name: "tick",
Data: fmt.Appendf(nil, "tick %d", i),
}); err != nil {
return
}
}
}That Open writes nothing is the property the rest of the design rests
on. The protocol headers commit on the first Send, so a check that runs
after opening can still refuse the request properly, with a status code
and a body:
func guarded(ctx iris.Context) {
stream, err := sse.Open(ctx)
if errors.HandleError(ctx, err) {
return
}
defer stream.Close(nil)
if ctx.Query().Get("token") == "" {
// A real status code and a real body: not one byte has gone out.
errors.Unauthenticated.Message(ctx, "token required")
return
}
stream.Send(sse.Event{Name: "welcome", Data: []byte("you are in")})
}A handler that opens a stream and returns without sending anything leaves the response untouched as well, which is what lets an error handler further up the chain render it.
Open does check one thing up front: whether this response can flush at
all. It cannot under Timeout.HandlerTimeout, which wraps the router in
http.TimeoutHandler, whose response writer implements neither
http.Flusher nor http.Hijacker. Every write buffers and the client
receives a 503 page instead of a stream. Because the answer is otherwise
only available at flush time, which is far too late, Open reports
sse.ErrNotStreamable with zero bytes written and the handler can still
answer honestly. Setting HandlerTimeout on an application that streams
anything logs a warning at startup for the same reason.
On the first Send, and for that one response, five adjustments land that
no middleware could guess were wanted:
- every buffering response-writer wrapper is removed, so compression and
the recorder that
accessloginstalls stop holding the events; - the server’s absolute
WriteTimeoutis cleared. It is a total deadline rather than an idle one, so it would otherwise end a long answer mid-sentence; - full duplex is enabled on HTTP/1, so a request body can still be read after the response has started, which is how a POST-driven stream is called;
Cache-Control: no-cache, no-transformandX-Accel-Buffering: noare set and anyContent-Lengthis deleted, which is what stops nginx and the CDNs in front of it from buffering the stream into one response;Connection: keep-aliveis set on HTTP/1 only, where it is meaningful. It is a protocol error on HTTP/2.
Sending events
Send writes one frame and flushes it. SendJSON(name, v) marshals a
value into the data field, and Comment writes a comment-only frame. All
of them are safe to call from more than one goroutine: the heartbeat takes
the same lock, so a keep-alive can never interleave with a half-written
frame.
sse.Event is the frame itself, and a zero field is simply absent from
the wire:
| Field | Wire field | Notes |
|---|---|---|
ID | id | Becomes the client’s Last-Event-ID. Stamped for you while AutoID is on |
Name | event | Empty means message, which is what an EventSource onmessage handler receives |
Data | data | A multi-line value is split across several data fields and rejoined by the client with newlines |
Retry | retry | The client’s reconnection delay, written once with the first event |
Comment | : | Written before the fields. A nil Data with a comment dispatches no event at all |
ID, Name and Comment are rejected with sse.ErrInvalidField when
they contain CR, LF or NUL. Those values are terminated by a newline on
the wire, so without the check a caller-supplied id forges frames of its
own.
Closing, and the frame that says so
Close writes a final done event and releases the stream. It is
idempotent, so a deferred Close(nil) and an explicit Close(err)
coexist, and a panicking handler still terminates the stream cleanly.
It writes that frame on success too, and that is the point. If the
terminator is always present, its absence is how a client detects
truncation. Nothing below it offers that signal: chunked framing catches a
torn connection, but a proxy idle timeout closes the stream cleanly and
the client cannot tell a finished answer from a severed one. A browser
client closes on it explicitly, since EventSource would otherwise
reconnect:
const es = new EventSource("/events");
es.addEventListener("tick", (e) => console.log(e.data));
es.addEventListener("done", () => es.close()); // the terminal frame.When Close is given an error and nothing has been committed, it writes
nothing and returns the error, so the caller renders an ordinary HTTP
response. After the first Send it writes an error event and then the
terminator, because the status code is already spent. stream.Committed()
reports which side of that line you are on, and stream.Err() reports why
a stream ended.
What goes into that error event is the ErrorFrame option, and its
default is the reason a failed stream is not a special case.
sse.ReportErrorFrame runs the error through
errors.Report, the rest/errors pipeline with
the rendering step removed: the route’s error mappings apply, the logger
and the collector fire, a correlation id is generated, and an unmapped or
private error is redacted to a generic message before anything reaches the
client. The payload is the same rest/errors envelope an ordinary failure
would have produced, so one client parser handles a failure whether it
arrived before the first byte or after it.
The redaction is the part worth noticing. Half of what a streaming handler
can fail on is somebody else’s text, and an upstream message naming a
vendor, a model or an internal host has no business on the wire.
sse.RawErrorFrame sends the error’s own words instead, which is
appropriate only when you know where those words came from. A frame is
skipped entirely when there is nothing to report, which is the case for a
client that already disconnected while errors.SkipCanceled is on: there
is nobody left to tell.
The options, and where their defaults come from
sse.Open(ctx, opts) takes an sse.Options. Start from
sse.DefaultOptions() and change what you need: a zero field means off
rather than default, so switching the heartbeat or the terminator off
stays expressible.
| Option | Default | What it is for |
|---|---|---|
Heartbeat | 15s | The longest the stream may go without writing anything |
HeartbeatFrame | ": ping\n\n" | What the heartbeat writes. A comment, which conforming clients ignore |
Retry | 3s | Advertised to the client once, with the first event |
AutoID | true | Stamps an increasing id on every event that has none, so a gap is detectable within one connection. Not resumption: the counter restarts at 1 on every stream |
Terminator | "done" | The event name Close writes |
ErrorFrame | sse.ReportErrorFrame | Renders the error event Close writes when a committed stream failed |
SendTimeout | 30s | A write deadline for one Send. Exceeding it fails the stream with sse.ErrSlowClient |
Two of those defaults carry an argument worth reading. Fifteen seconds is
not a round number: it is the largest heartbeat that survives every common
intermediary at once, since nginx proxy_read_timeout and an AWS
Application Load Balancer both idle out at 60 seconds, Cloudflare returns
a 524 at 100, and Heroku’s router applies a rolling 55. The heartbeat’s
larger job is liveness rather than keep-alive: only a real FIN or RST, or
a failing write, cancels a request context, so a client that disappears
without one (a closed lid, a dropped link, an evicted NAT entry) stays
invisible until TCP gives up, which on Linux is roughly 924 seconds of
leaked goroutine and leaked upstream work. Writing something is what lets
the kernel fail and the handler find out. A Google Cloud load balancer is
the exception no heartbeat rescues, because it applies its backend timeout
to the whole request and response; raise timeoutSec there instead.
SendTimeout is the backpressure mechanism, and it is a deadline rather
than a queue on purpose. Send is synchronous, so there is no buffer of
the framework’s to budget, and a client that will not drain shows up as a
write that does not complete. Exceeding it fails the stream and never
skips an event, because a dropped delta corrupts the message the client is
assembling with no way for it to notice.
HeartbeatFrame is configurable rather than fixed because not every
client conforms. sashabaranov/go-openai, widely used against
OpenAI-compatible servers, counts comment frames toward its
emptyMessagesLimit and fails the stream instead of ignoring them.
Serving such a client, send a real event.
Batching deltas
A fast producer is a syscall amplifier. One frame per token means one
write, one flush, one TCP segment and one client-side render per token,
and the tokens arrive faster than any of those is worth doing. Coalesce
batches the events you name and emits at most one frame per name per
interval, concatenating their payloads:
batch := stream.Coalesce(200*time.Millisecond, "word")
defer batch.Close()
for _, word := range strings.Fields("the quick brown fox") {
ev := sse.Event{Name: "word", Data: []byte(word + " ")}
if err := batch.Send(ev); err != nil {
return
}
time.Sleep(80 * time.Millisecond)
}A background ticker drains the buffers, which is what makes the interval a ceiling rather than a floor. Without one, a buffered delta is only emitted when the next one arrives, so text that already reached the server sits unsent through exactly the mid-answer pause a reader notices.
Because the payloads are concatenated, this is for text-like deltas
(running prose, a reasoning trace) and not for events whose data is a
self-contained JSON document. Anything not named passes straight through,
after the pending buffers are flushed, so ordering holds. The coalescer
owns the stream while it is open and Send on the stream itself flushes
it first, so the interleaving hazard a hand-rolled batcher has to document
cannot be expressed here.
Resuming after a reconnect
stream.LastEventID() reports the cursor the client sent, from the
Last-Event-ID header or the lastEventId query parameter. Both are
load-bearing: a browser EventSource sends the header and cannot be made
to send anything else, while a client built on fetch usually has to put
it in the URL. The value is sanitised, because it is echoed back on every
frame.
Where the events between that cursor and now come from is the application’s business. The package stores nothing, which is what keeps it small enough to be correct: a replay buffer belongs where the data already lives.
Reading somebody else’s stream
sse.Read is the other half, for consuming an event stream from an
upstream service. It is the framework’s first iter.Seq2 API, so it
ranges:
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
for ev, err := range sse.Read(resp.Body, sse.ReadOptions{
Terminators: []string{"[DONE]"},
}) {
if err != nil {
return err
}
fmt.Println(ev.Name, string(ev.Data))
}It implements the parsing algorithm rather than matching a prefix, which
matters more than it sounds: data: with and without a space are both
legal, and a reader that matches data: sees an empty stream from a
producer that omits it. Multi-line data, comments, unknown fields, a
leading byte order mark and lines ended by CR, LF or CRLF are all handled.
It never yields a partially framed event, because an event is dispatched
by the blank line that ends it; a stream cut mid-event ends the iteration
with io.ErrUnexpectedEOF rather than quietly, as if the answer were
complete. A clean end of stream simply ends the iteration, and io.EOF is
not reported as an error.
ReadOptions.Terminators ends the iteration on an in-band sentinel such
as [DONE], which the OpenAI dialect and everything shaped after it emit.
It is a concession on the reading side and never something this package
writes, for the reason its own terminator is an event name: a sentinel
travels on the same channel as the payload, so a producer that
legitimately emits the literal string would end the stream.
RequireTerminator goes with it: when the upstream promised a sentinel
and closed without one, the iteration ends with sse.ErrNoTerminator,
because a stream that ends short was cut however clean the close looked to
the transport. MaxEventBytes caps one event, not the stream, at 1 MB by
default, and KeepComments yields an upstream’s own heartbeats instead of
skipping them.
Relaying a model’s stream is what the ai package is for, and it is built
on both halves of this one. See Chapter 21.
Content Negotiation
When one endpoint must serve multiple representations, declare what the
server offers with ctx.Negotiation(), then let ctx.Negotiate pick the
best match for the request’s Accept header:
type User struct {
ID int `json:"id" xml:"id"`
Name string `json:"name" xml:"name"`
}
func user(ctx iris.Context) {
// Server-side offers, in priority order.
ctx.Negotiation().JSON().XML().Text()
u := User{ID: 42, Name: "Jane"}
ctx.Negotiate(iris.N{
JSON: u,
XML: u,
Text: fmt.Sprintf("user %d: %s", u.ID, u.Name),
})
}iris.N holds one value per offered representation (Text, HTML,
Markdown, Binary, JSON, JSONP, XML, YAML, Protobuf,
MsgPack, plus Other for custom types); the matched field is rendered
with the corresponding writer. If nothing matches, Negotiate sets 406 Not
Acceptable and returns context.ErrContentNotSupported. The builder can
also carry the content itself (ctx.Negotiation().JSON(v).XML(v) followed
by ctx.Negotiate(nil)) and offers Charset(...) and EncodingGzip()
to negotiate charset and compression alongside the media type.
The Response Error Handler
The writers report failures (a value JSON cannot encode, a broken
connection) back to the caller as an error. In practice handlers do not
check that return value; instead, every failed write also invokes the
context error handler, configured at Options.Context.ErrorHandler. The
default logs the error in debug mode and replies with a bare 500.
Replace it application-wide through the options (partial options merge with the defaults, see Chapter 11):
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
func main() {
app := iris.New(iris.Options{
Context: iris.ContextOptions{
ErrorHandler: func(ctx iris.Context, err error) {
// Logs the write failure and answers a canonical 500; the
// encoder's own message never reaches the client.
errors.Internal.LogErr(ctx, err)
},
},
})
app.Get("/", func(ctx iris.Context) {
ctx.JSON(iris.Map{"message": "hello"})
})
app.Listen(":8080")
}For a single route or group, iris.WithErrorHandler builds a middleware that
overrides the handler for the requests it runs on. It calls ctx.Next()
itself, so register it like any other middleware and the chain carries on:
app.Use(iris.WithErrorHandler(func(ctx iris.Context, err error) {
errors.Internal.ErrPrivate(ctx, err)
}))It works the same in front of a single route:
app.Get("/report", iris.WithErrorHandler(reportErrors), reportHandler)Applications built with iris.NewBuilder get
errors.DefaultContextErrorHandler from rest/errors installed
automatically, so write failures render as the same structured error JSON as
every other error in the app. Setting
Options.Context.DisableErrorHandler: true turns the mechanism off
entirely, leaving the returned error as the only signal.
Summary
- The Context helpers are the recommended way to respond in JSON APIs: one
contract,
ctx.Helper(value, err), withctx.OK,ctx.Created(id values become{"id": ...}),ctx.NoContent,ctx.JSONOrError,ctx.Countandctx.Paginated, covering the standard cases and routing every error through the central mapping. - The
Contextwriters handle the formats directly:JSON,JSONP,XMLandMarkdownaccept an options value;YAML,TextYAML,Text,HTMLandBinarytake none. - Set status codes with
StatusCodebefore the body; theStopWithfamily combines status, body and stopping the chain. SetHeaderreplaces andAddHeaderappends a response header, andResponseHeaderreads one back, mirroringctx.Headerfor the request side;ResponseContentTypeandResponseMediaTypepair with the request’sContentTypeandMediaType.Redirectdefaults to 302;ServeFile,SendFileandServeContentcover inline files, downloads and in-memory content, each with a rate-limited variant.- Stream with
WriteplusResponseWriter().Flush(), or letStreamWriterrun the flush loop; end the stream by returning an error.ctx.DisableResponseBuffering()removes the middleware wrappers that would otherwise hold the chunks. - The
ssepackage writes Server-Sent Events:sse.Opencommits nothing until the firstSend, so a check after it can still return a status code; that firstSendunbuffers the response, clears the absoluteWriteTimeoutand sets the anti-buffering headers;Closewrites the terminal frame on success too, so its absence means truncation.Coalescebatches deltas andsse.Readconsumes an upstream stream. - A failure after the first byte is reported in-band by the
ErrorFrameoption, whose default runs the error through therest/errorspipeline, so it is mapped, logged, collected and redacted like any other failure. Negotiation()andNegotiate(iris.N{...})serve multiple representations from one endpoint.- Failed writes trigger
Options.Context.ErrorHandler; theiris.WithErrorHandlermiddleware overrides it for the routes it runs on, and builder apps default to the structuredrest/errorshandler.
Further Reading
- MDN: HTTP response status codes: every status code with its intended use.
- MDN: Content negotiation:
how
Acceptheaders drive representation selection. - MDN: Redirections in HTTP: the 3xx family and when to use each code.
- MDN: HTTP conditional requests: ETag, Last-Modified and the 304 mechanism in detail.
- MDN: Using server-sent events:
the SSE wire format and the browser-side
EventSourceAPI. - WHATWG HTML: Server-sent events:
the normative field list and the parsing algorithm
sse.Readimplements. - RFC 9110: HTTP Semantics: the authoritative source for status codes, headers and negotiation.