Changelog
All notable changes to Photon. The project follows Semantic Versioning; before 1.0, minor versions may contain breaking changes, and every one is listed here with what to do about it.
Unreleased
Section titled “Unreleased”The first public release. Compared with the pre-release code (fd48b8f), this
fixes defects that made streaming unusable, closes two security holes, and
reworks the API for everyday use. Measurements are in
bench/RESULTS-2026-10.md.
Photon is now licensed under the Apache License 2.0 (previously BSL 1.1).
Problem type URIs moved from https://photon.sh/errors/<code> to
https://photon.agenticmarket.dev/errors/<code>; clients that match on type
should update, or match on the stable code field instead.
- Events were not delivered until the stream closed. A single
Eventsat in the buffer until 16 KB accumulated, so token streams arrived in one lump at the end. Each event is now sent as soon as the connection is free (~17 µs on loopback), with natural batching under load. - SSE field injection. A line break in an event name or id, or a lone
\rin data - which model output can contain - could inject SSE fields. Names and ids with CR, LF, or NUL are now refused withErrInvalidEvent; data is split on CRLF, LF, and CR exactly as clients split it. MaxHeaderscounted header names, not fields. A thousand copies of one header passed a limit of 100. It now counts every field.- A body of exactly
MaxRequestBodyByteswas rejected when sent chunked. The limit is now enforced withhttp.MaxBytesReader, and the error is the standard*http.MaxBytesError. GOMEMLIMITin Go’s own syntax (512MiB) was ignored, and512Kwas read as 51 KiB, so stream limits were derived from a guess. The limit is now read from the runtime. Cgroup v2 detection also walks up from the process’s own cgroup, so systemd’sMemoryMax=is found.Shutdownleft connections open after its deadline, although its documentation said they were closed. They now are.- TLS with
MaxConnectionsorMaxPerIPbroke HTTP/2. Admitted connections were wrapped, which hid the*tls.Connfrom net/http: a client that negotiated h2 was handed to the HTTP/1 parser, andr.TLSwas nil. Admission slots are now released from net/http’s connection-state hook and connections are never wrapped. Mountcould not serve trailing-slash or deep paths, so thenet/http/pprofindex andhttp.FileServerdirectory listings were 404s, andGroup("").Mount("/", h)panicked.SetLimitssilently discardedMaxStreamsandMemoryBudgetpassed before it. Option order no longer matters.- A negative
MaxHeaderBytesbecame net/http’s 1 MB default instead of an error. It, and a negativeMaxRequestBodyBytes, now panic inNew. An explicitMaxTotalStreamBufferBytesbelow what a derivedMaxStreamsneeds is now caught too. MaxTotalStreamBufferByteswas not a true ceiling:Growcounted only growth against it, so buffer memory could reach twice the limit.- Path parameters were sometimes escaped (when the path contained
%2For another escape net/http keeps inRawPath) and decoded otherwise. They are now always decoded, asServeMuxdoes. - Rejected requests logged one line each when a logger was configured, turning a refused flood into a log flood. They are now rate-limited like refused connections.
- (Review fixes before release)
Closecould return while the stream’s writer goroutine was still writing to a slow client, so the writer used aResponseWriternet/http had already finished - a data race on HTTP/1 and a process crash on HTTP/2.Closenow waits while the client makes progress, and otherwise forces the write to fail before returning.DropOldestcould drop the unsent half of a frame whose first half was on the wire.StartHeartbeatscommitted headers from a timer goroutine, racing the handler’s own header writes. A request whose raw path missed a protected route but whose decoded path matched it (/admin%2Fsecret) could fall through to a mount; it is now refused with a 400. - An oversized body was
body_too_largewhen its length was declared andlimit_exceededwhen it overflowed while reading. Both are nowlimit_exceededwith"limit":"MaxRequestBodyBytes". StartHeartbeatswrote from a second goroutine to a stream documented as single-goroutine. Streams are now safe for concurrent use.DropOldestandGrowcould not take effect with a real connection: a stuck client tripped the stall deadline first. With the new writer the producer never blocks on the socket, so both policies work, andDropOldestdrops whole events instead of raw bytes, which would have corrupted the stream.- A slow client that kept reading could be disconnected as if it had stopped, because a whole 32 KB batch had to finish within one stall period. Progress is now measured per 8 KB.
- The newline tokens a model emits were lost: data ending in
\ndropped the newline, and\n\narrived as\n. Data now round-trips exactly. Event(name, nil)sent nothing a browser would dispatch. Empty data is now sent as an emptydata:line, sodoneevents arrive.Router.Lookupreturned parameters from a buffer it had already returned to the pool. It now returns a copy.- Registering routes was quadratic: 5,000 routes took 7 seconds. Now 31 ms.
ErrStreamPanicwas documented but never produced. A panic after the first event is now recorded, logged, and aborts the connection, so a truncated answer cannot look complete.- Handler panics were silent by default because the default logger
discarded everything. Errors now go to
slog.Default. Connection: keep-alivewas sent on HTTP/2 streams, where RFC 9113 forbids connection-specific headers.photon initoverwrote existing files and generated ago.modthat only worked inside the repository.photon benchmiscounted events when a chunk boundary split a line. The OpenAI-compatible demo named its events, which OpenAI SDKs do not parse as chunks.
photon.SSE(func(s *Stream, r *http.Request) error)— the streaming handler adapter: limits, closing, error rendering, panic handling.Stream.JSON,Stream.Header,Stream.Flush,Stream.Done(closes on client disconnect and on server shutdown), automatic heartbeats.- Non-SSE streams (NDJSON, raw bytes) keep their own
Content-Typeand never receive heartbeats. Server.Run(addr)— listen, handle SIGINT/SIGTERM, drain;ShutdownTimeoutoption andDefaultShutdownTimeout(25 s).Group(prefix + middleware, nestable) andMount(anhttp.Handlerfor every method under a prefix - for MCP servers, pprof, legacy muxes).DecodeJSON(415 / 400 / 413 with safe messages) andError(RFC 9457 problem documents, 5xx logged with cause).photonerr.Unauthorized,Forbidden,UnsupportedMediaType,UnprocessableEntity,TooManyRequests.ErrRouteConflictandErrBadPattern, which a registration panic matches witherrors.Is.- A once-per-server warning when a middleware hides write deadlines by wrapping
the
ResponseWriterwithoutUnwrap. - Path parameters are stored with
Request.SetPathValue, sor.PathValueworks, andr.Patternholds the matched route template for metrics and OpenTelemetry. Retry-Afteron every 503.- Examples:
openai-proxy(a gateway that cancels the upstream model when the client leaves),agent(typed agent events with a browser UI).photon initscaffolds from the examples themselves. - Documentation: getting started, eight guides, six migration guides, a full API reference.
Changed (breaking)
Section titled “Changed (breaking)”- Module path is
github.com/agenticmarket/photon(wasphoton). - Verbs take
http.HandlerFunc(washttp.Handler). A plain function now works withoutphoton.HandlerFunc(...). Existingphoton.HandlerFunc(f)calls still compile. To pass a non-functionhttp.Handler, useHandleor itsServeHTTPmethod value. Useapplies to every route, including those registered before it, and to 404 and 405 responses (previously only routes registered after it). CORS preflights no longer needOPTIONSroutes. Middleware that should cover only some routes belongs on aGroup.NotFoundandMethodNotAllowedpanic after the server starts, like route registration: changing them on a live server was a data race.NewStream(w, r, opts...)takes stream options;StreamConfigis no longer exported.SetEventIDattaches the id to the next event in the same frame (was: written immediately as a separate line).SetRetrysends a complete frame.Stream.Overflowafter the first write is ignored with a warning (was: recorded as the stream’s error).- Streaming writes are asynchronous. A transport failure is reported by
the next
Eventcall (or byFlush), not by the call that queued the data. - Overflow policies:
DropOldestandGrowact when the buffer fills, without waiting for the stall deadline. The stall deadline now means “no progress for this long” under every policy. StartHeartbeatsopens the stream immediately (commits the 200) instead of after the first silent interval. Call it after validating the request.- Streams from
Streaming()without aServer(a bareRouterin a test) now get a standalone stream with default limits instead of none. - Validation: negative
MaxConnections,MaxConcurrentRequests, orMaxPerIPnow panic inNew, like the other limits. - Route patterns containing
{or}are refused, with a message giving the Photon spelling, instead of registering a literal path that never matches. /filesand/files/*pathmay now coexist: they match disjoint paths.- Duplicate parameter names (
/a/:id/b/:id) and group route paths without a leading slash (api.GET("models")) now panic at registration. Mountis a fallback: routes take precedence over mounts, and a longer prefix over a shorter one, soMount("/", legacy)serves everything not yet ported. Registering a route under a mount used to panic. A mount serves every method, setsr.Patterntoprefix/*, and appears inRoutes()once, with method*.- Responses to a rejected request (413, 431, 503) are full RFC 9457 documents
with
codeandlimitfields. - The default logger reports errors through
slog.Default(was: silent).