Ardian About Directory
Claude Code
Email me

api

2026 — A Go library that carries who is calling and their time zone from one Go service to the next

01 Problem

A backend, the part of an app that runs on servers, is often split into services, separate programs that each do one job and call each other over HTTP, the protocol of the web. Every HTTP request carries headers, named lines of text sent ahead of its body. Most services never meet the person using the app. A gateway, the service at the edge that every request passes through first, checks who signed in and passes the request on with the answer in its headers, the user ID of the person, the session they are in, and often their time zone.

Behind the gateway, every handler, the function that answers one kind of request, wants that answer, and every call a service makes to the next one has to carry it along, or the next service no longer knows who it works for. In Go, each request comes with a context.Context, a value handed down to every function the request reaches, which can carry values like these. Middleware, code that wraps every route and runs before its handler, is where a header is read into the context once, so no function has to pass a user ID along by hand.

I first wrote that code on 28 January 2026, when one of the APIs at Avagenc, where I build AI agents, stopped checking sign-in tokens itself and began trusting an x-user-id header from the gateway in front of it. The API behind Avagenc Zee, an AI agent for the smart home, copied the same middleware on 4 February, and from 24 February a server that turned that API into tools for an agent set the header again by hand on every call it made to it.

On 10 April 2026 I moved that code into one library, and Zee moved to it the next day.

  1. Monolith. The backend starts as one app with no network calls, but ctx still passes through every function.
  2. Microservices. The app splits in two, and its sign-in becomes an API gateway in front of service A.
  3. A client appears above the API gateway.
  4. Service A splits off service B, then service C, then service B splits off service D, so the backend keeps growing into more services.
  5. A request travels from the client to the API gateway, and on to service A with the headers user-id, session-id, and time-zone.
  6. It goes on from service A to service B and from service B to service D with all three.
  7. Then service A calls service C with only user-id and time-zone, because session-id was forgotten.
  8. Service C refuses the call with 401, session ID cannot be empty.

02 Solution

go.trueardian.com/api is an open source Go library that holds that code once, a library being code other programs import rather than a program that runs on its own. It imports nothing outside Go's standard library, so adding it pulls in no other code. It began as go.ibnfadl.com/api, moved to go.naturallyfunny.dev on 16 April 2026, and moved again to go.trueardian.com on 5 October.

It carries three values, each in a package of its own. The user package carries who is calling, the session package carries which session the request belongs to, and the timezone package carries where the person is. Each of the three has the same three pieces, a middleware that reads its header into the context or refuses the request, an accessor that reads the value back anywhere below, and a propagator that writes it onto the next request.

The api package at the root carries the values out. Its Transport runs those propagators on every request a service sends, and its two writers answer a request with JSON. The next sections take them in that order, the user and the session, the time zone, propagation, and responses.

Install
go get go.trueardian.com/api
Packages
user ─────┐
session ──┼─▶ api
timezone ─┘

03 Identity

The user package carries who is calling, the ID the gateway put in the user-id header. The session package carries the session the request belongs to, like one conversation or one sign-in, from the session-id header. The two work the same way, so a service imports one of them or both.

HTTPWithID wraps the routes and reads the header. An empty or missing one stops the request with a 401, the HTTP status for a caller the server does not know, so a handler never runs without it. Below the middleware, IDFromContext hands back the ID and an ok that says whether it was there. Behind the middleware ok is always true, and it matters for code reached some other way, like a scheduled job or a test.

Code that does not start from a header fills the context itself with ContextWithID, which runs the same check for an empty ID. The key the value sits under in the context is private to the package, so no other package can put an ID there without that check.

The session package came on 9 May 2026, the day Avagenc Ava, the lead agent of Avagenc Chat, began keeping each conversation under its session ID rather than under the person's user ID. By 14 May all three keys were public, so another library like Postera could read a value straight from the context. Since 22 October the keys are private, and the accessors return the value and a bool where they used to return an error for a missing value.

Read who is calling
handler := user.HTTPWithID(session.HTTPWithID(mux))

uid, ok := user.IDFromContext(r.Context())
sid, ok := session.IDFromContext(r.Context())
Fill the context without a header
ctx, err := user.ContextWithID(ctx, "u_123")

04 Time zone

A server usually keeps its clock in UTC, Coordinated Universal Time, the time every zone is counted from, so 9am on its clock is 4pm in Jakarta. Any handler that shows a time to a person, or schedules something for them, needs their time zone. So the apps send it in a time-zone header as an IANA name, one of the standard names like Asia/Jakarta that know every daylight saving rule.

HTTPWithZone loads the name with time.LoadLocation, Go's lookup of a zone by its name, before it goes into the context, so a name that does not exist, like Mars/Olympus, never reaches a handler. Neither does Local, a name Go quietly turns into the server's own zone. A missing name, an unknown one, and Local all stop the request with a 400. ZoneFromContext hands back the loaded zone, a *time.Location, ready for t.In(loc).

The time zone came on 7 May 2026, under the header x-user-timezone. Until 22 October the context held the name as a string, so a handler that wanted the zone had to load it again, and Local passed the check. Since then the context holds the loaded zone and Local is refused.

Show a time in the caller's zone
handler := timezone.HTTPWithZone(mux)

loc, ok := timezone.ZoneFromContext(r.Context())
now := time.Now().In(loc)

05 Propagation

When a handler calls the next service, the new request starts with no headers, and the three values sit in the context, not on the request. Transport is an http.RoundTripper, the part of a Go http.Client that sends each request, and before it sends one it runs a list of propagators on it. Each propagator writes one value from the context back as a header, under the same name its middleware reads, and skips a value that is not there, so the same client also works for a call with no person behind it.

It clones the request before setting any header, because a RoundTripper must never change the request it was handed, and sends the clone through Base, a RoundTripper of your own, or http.DefaultTransport when Base is left nil. A propagator is a plain func(ctx, header), so a trace ID or any other value can travel the same way in a few lines of your own.

Transport came from a bug. In May 2026 Avagenc Ava ran as a service of her own and handed work to Avagenc Zee over HTTP, setting each header on each call by hand. On 9 May Zee began requiring a session, and Ava's code still forwarded only the user and the time zone, so every task she handed to Zee was refused as unauthenticated until the fix came fifteen minutes later. On 12 May the library got Transport, and the same day Ava replaced her hand-written headers with it.

On 24 May Ava sent the new header names to Zee and the old x- names to Avagenc Yori and Avagenc Rafal, which had not moved yet, through the same Transport with a different list of propagators for each. Until 22 October a propagator was built with WithHeader from a context key and a header name. Since then each package ships its own, PropagateID or PropagateZone, so a value always leaves under the name its middleware reads. The same day Transport began passing CloseIdleConnections, the call that closes idle connections, on to Base.

Carry the values to the next service
client := &http.Client{
Transport: &api.Transport{
Propagators: []api.Propagator{
user.PropagateID,
session.PropagateID,
timezone.PropagateZone,
},
},
}

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://downstream.example.com/internal", nil)
resp, err := client.Do(req)

06 Responses

A service answers a request with a status, like 200 for success, and a body, usually JSON, the text format web APIs answer in. The api package has two writers for it. WriteJSON writes a payload, and WriteProblem writes an error, which is what each middleware answers with when it refuses a request.

Both turn the body into JSON before they send the status, so a payload that cannot be encoded becomes a 500, the status for a failure on the server, instead of a 200 with nothing in it. Until 22 October 2026 the status went out first, and such a payload left as a 200 with an empty body.

Today the two writers are the same, and that is on purpose. WriteProblem is the one that will grow into RFC 9457 problem details, the standard shape for an error from an HTTP API, so every place that writes an error already calls the right function when it does.

It did not start that way. From the first commit the library wrapped every response in one envelope, with fields for success, a code, a message, and the data. From 21 April 2026 its codes followed those of gRPC, Google's framework for calls between services, and from 23 April clients could unwrap the envelope with Decode. On 14 May the envelope was removed, so each app owns the shape of its own payloads, and both writers take any value.

Answer a request
api.WriteJSON(w, http.StatusOK, payload)
api.WriteProblem(w, http.StatusUnprocessableEntity, map[string]any{"detail": "..."})

07 Composition

Each middleware is a plain func(http.Handler) http.Handler, the usual shape of Go middleware, so the three stack in any order, with any router, next to middleware of your own. Each one stops the request on its own, so the caller hears about the first problem only. Stacked as user, session, and timezone, a request missing every header gets the 401 for its user ID, and its time zone is never read.

Each value package imports api and nothing else outside the standard library, and api imports nothing outside it at all, so a service pulls in just the values it uses.

On 9 May 2026 the three middleware were folded into one generic helper, HeaderToContext, which took a header name, a function that fills the context, and the error to answer with. On 14 May it was removed, and each package now spells out its own middleware in a few lines that read the header, fill the context, and answer the refusal.

Stack all three
handler := user.HTTPWithID(session.HTTPWithID(timezone.HTTPWithZone(mux)))
The whole user middleware
func HTTPWithID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := ContextWithID(r.Context(), r.Header.Get(header))
if err != nil {
api.WriteProblem(w, http.StatusUnauthorized, map[string]any{
"detail": err.Error(),
})
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}

08 Naming

The headers are user-id, session-id, and time-zone, without the X- prefix that custom headers once carried, which RFC 6648 retired in 2012. HTTP/2 lowercases every header name on the wire anyway. Go writes them as User-Id, Session-Id, and Time-Zone on HTTP/1.1, and since header names match without regard to case, the next service reads them either way, and a caller that sends User-Id works too. Until 14 May 2026 they were x-user-id, x-session-id, and x-user-timezone.

The packages are named so that none clashes with a package of Go's own. On 22 October 2026 the http package moved to the root as package api, and the time package became timezone again, the name it had before 14 May. So a service imports them next to net/http and time without renaming either.

09 Error handling

Every refusal is written by WriteProblem as JSON with a single detail field, the error message, and the handler never runs. A missing or empty user-id or session-id is a 401. A missing, unknown, or Local time zone is a 400 instead, because a bad time zone is a malformed request and not an unknown caller.

Outside HTTP, ContextWithID and ContextWithZone return the same messages as plain errors. Transport hands back whatever error Base returns, unchanged. A body that cannot be encoded is logged and answered with a 500.

Every refusal
user-id missing or empty      401  {"detail":"user ID cannot be empty"}
session-id missing or empty 401 {"detail":"session ID cannot be empty"}
time-zone missing or empty 400 {"detail":"timezone cannot be empty"}
time-zone Local 400 {"detail":"timezone must be an IANA name, not Local"}
time-zone Mars/Olympus 400 {"detail":"unknown time zone Mars/Olympus"}
body that cannot be encoded 500 Internal Server Error

10 Limits

It does not authenticate anyone. It carries an identity and never checks one, so anyone who can reach a service can send whatever user-id they like. The headers have to come from something trusted at the edge, one that strips them off outside traffic.

It does not route, log, trace, or measure. A propagator can carry a trace ID, but none ships with it.

It carries three values and no more. A request ID, a locale, or a tenant is a copy of the session package under a new name, which is two files.

WriteProblem does not write RFC 9457 problem details yet, and that is on the roadmap.

11 Use cases

Avagenc Chat, a group chat of AI agents for people in Indonesia, runs on one chat API. Its web and iOS apps send the time zone of the person in a time-zone header, and the routes that run an agent read it through HTTPWithZone. A person who signs in with Google is verified through Firebase Authentication, and the chat API then fills the context with user.ContextWithID, since no gateway sets user-id there.

When Cloud Tasks wakes Avagenc Ava through Postera, it sends the user-id and time-zone of the person back as headers along with an API key, so the wake route reads them through HTTPWithID and HTTPWithZone like any other request. When Ava hands a task to Avagenc Zee, Avagenc Yori, or Avagenc Rafal, she fills a new context with ContextWithID and ContextWithZone, carrying over the person, the session, and the time zone without any HTTP.

Transport carried those handoffs while the agents were services of their own, until the separate Ava service was archived on 29 June 2026 and the agents came to run inside the chat API.

Undisclosed Apartment, an apartment tower still under construction, writes the answers and errors of its backends with WriteJSON and WriteProblem.

api

Any Go service behind a gateway knows who is calling and their time zone in every handler and passes both on to the next service it calls, without writing a header by hand and with nothing outside the standard library