Chapter 6

Response Handling

The rest helpers that take a value and an error, the Context writers underneath them, status codes, redirects, streaming and content negotiation.

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:

Go
ok := ctx.Helper(value, err) // ok == false: an error response was written

If 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.

Go
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:

HelperSuccess statusRenders
ctx.OK(resp, err)200 (status untouched)resp as JSON; a nil resp sends nothing
ctx.Created(resp, err)201id scalars wrapped as {"id": ...}, other values as-is
ctx.NoContent(modified, err)204 or 304empty 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...)200items as a JSON array; empty and nil both render []
ctx.JSONOrError(resp)currentresp as JSON, with encode failures handled for you
ctx.Paginated(collect)200the 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:

Go
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:

Go
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.

Go
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:

JSON
{
    "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:

MethodContent-TypeOptions value
ctx.JSON(v, ...)application/jsoniris.JSON
ctx.JSONP(v, ...)text/javascriptiris.JSONP
ctx.XML(v, ...)text/xmliris.XML
ctx.YAML(v)application/x-yaml—
ctx.TextYAML(v)text/yaml—
ctx.Markdown(b, ...)text/htmliris.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

Go
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:

Go
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:

Go
ctx.Markdown([]byte("# Release Notes"), iris.Markdown{Sanitize: true})

Raw writes

For output that needs no content-type handling, write directly:

Go
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:

Go
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):

MethodBody
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 requestRead the response
ctx.Header("X-Key")ctx.ResponseHeader("X-Key")
ctx.ContentType()ctx.ResponseContentType()
ctx.MediaType()ctx.ResponseMediaType()
Go
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:

Go
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):

Go
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:

Go
// 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:

Go
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:

Go
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:

Go
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:

Go
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:

Go
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 accesslog installs stop holding the events;
  • the server’s absolute WriteTimeout is 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-transform and X-Accel-Buffering: no are set and any Content-Length is deleted, which is what stops nginx and the CDNs in front of it from buffering the stream into one response;
  • Connection: keep-alive is 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:

FieldWire fieldNotes
IDidBecomes the client’s Last-Event-ID. Stamped for you while AutoID is on
NameeventEmpty means message, which is what an EventSource onmessage handler receives
DatadataA multi-line value is split across several data fields and rejoined by the client with newlines
RetryretryThe 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:

JavaScript
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.

OptionDefaultWhat it is for
Heartbeat15sThe longest the stream may go without writing anything
HeartbeatFrame": ping\n\n"What the heartbeat writes. A comment, which conforming clients ignore
Retry3sAdvertised to the client once, with the first event
AutoIDtrueStamps 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
ErrorFramesse.ReportErrorFrameRenders the error event Close writes when a committed stream failed
SendTimeout30sA 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:

Go
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:

Go
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:

Go
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):

Go
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:

Go
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:

Go
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), with ctx.OK, ctx.Created (id values become {"id": ...}), ctx.NoContent, ctx.JSONOrError, ctx.Count and ctx.Paginated, covering the standard cases and routing every error through the central mapping.
  • The Context writers handle the formats directly: JSON, JSONP, XML and Markdown accept an options value; YAML, TextYAML, Text, HTML and Binary take none.
  • Set status codes with StatusCode before the body; the StopWith family combines status, body and stopping the chain.
  • SetHeader replaces and AddHeader appends a response header, and ResponseHeader reads one back, mirroring ctx.Header for the request side; ResponseContentType and ResponseMediaType pair with the request’s ContentType and MediaType.
  • Redirect defaults to 302; ServeFile, SendFile and ServeContent cover inline files, downloads and in-memory content, each with a rate-limited variant.
  • Stream with Write plus ResponseWriter().Flush(), or let StreamWriter run the flush loop; end the stream by returning an error. ctx.DisableResponseBuffering() removes the middleware wrappers that would otherwise hold the chunks.
  • The sse package writes Server-Sent Events: sse.Open commits nothing until the first Send, so a check after it can still return a status code; that first Send unbuffers the response, clears the absolute WriteTimeout and sets the anti-buffering headers; Close writes the terminal frame on success too, so its absence means truncation. Coalesce batches deltas and sse.Read consumes an upstream stream.
  • A failure after the first byte is reported in-band by the ErrorFrame option, whose default runs the error through the rest/errors pipeline, so it is mapped, logged, collected and redacted like any other failure.
  • Negotiation() and Negotiate(iris.N{...}) serve multiple representations from one endpoint.
  • Failed writes trigger Options.Context.ErrorHandler; the iris.WithErrorHandler middleware overrides it for the routes it runs on, and builder apps default to the structured rest/errors handler.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.