Getting started with transit¶
By the end of this you'll have two small programs running: a server whose every request is
logged, traced and rate-limited, and a client that retries a failing downstream and then
gives up on it deliberately. About fifteen minutes, most of it waiting for go mod tidy.
Both halves matter, because transit ships both. The same module wraps the code that accepts calls and the code that makes them.
Before you start¶
You'll need Go 1.26.5 or later (go version to check — that's what transit's go.mod
requires, and an older toolchain will stop to download it) and an empty directory to work
in. Nothing else — no service framework, no observability backend, no Docker.
mkdir transit-tour && cd transit-tour
go mod init transit-tour
go get gitlab.com/phpboyscout/go/transit
go get go.opentelemetry.io/otel/sdk
The second go get is only for this tutorial: transit reads whichever OpenTelemetry
provider is installed globally but never installs one, so you need an SDK to see a trace ID
appear.
The package is imported as transit/http, which collides with the standard library's
net/http. Alias it — every example here uses transithttp:
Build a server that logs every request¶
Create server/main.go:
package main
import (
"log/slog"
"net/http"
"os"
transithttp "gitlab.com/phpboyscout/go/transit/http"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// transit never installs a tracer provider — it reads the global one.
otel.SetTracerProvider(sdktrace.NewTracerProvider())
limits := transithttp.DefaultRateLimitConfig()
limits.RequestsPerSecond = 1 // absurdly low, so you can trip it by hand
limits.Burst = 2
chain := transithttp.NewChain(
transithttp.OTelMiddleware("demo"),
transithttp.LoggingMiddleware(log, transithttp.WithPathFilter("/healthz")),
transithttp.RateLimitMiddleware(log, limits),
)
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello\n"))
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Info("listening", "addr", ":8080")
_ = http.ListenAndServe(":8080", chain.Then(mux))
}
NewChain builds an ordered chain and Then applies it to any http.Handler. The chain
runs outermost-first: OTelMiddleware sees the request first, then logging, then the
rate limiter, then your handler.
That order is not arbitrary, and getting it wrong costs you a field. OTelMiddleware
creates the span and puts it in the request context it passes inward; the logging
middleware reads it back out. Put logging first instead and it will still log — just
without any trace_id, because the span doesn't exist yet at that point in the chain.
Start it:
Watch what a request produces¶
In another terminal:
The server prints one JSON record per request:
{"time":"2026-08-02T18:45:06.454182115Z","level":"INFO","msg":"request completed",
"method":"GET","path":"/hello","status":200,"bytes":6,"latency":"10.45µs",
"client_ip":"::1","user_agent":"curl/8.5.0",
"trace_id":"ea06478582e461bb3ddcc6280f8882dc","span_id":"277811f2045b8bee"}
(Wrapped here for width; it's one line on the wire.)
trace_id and span_id are the payoff for the chain order. Ship these logs anywhere that
also receives your traces and a request's log line and its span find each other.
client_ip is ::1 — the loopback peer of the connection. transit deliberately ignores
X-Forwarded-For unless you pass WithTrustedProxy(), so try it and watch nothing change:
Now hit the health endpoint:
It answers 200 and logs nothing at all — that's WithPathFilter. Note it's an exact
match on the path: /healthz is filtered, /healthz/ is not.
Trip the rate limiter¶
The limiter is set to 1 request per second with a burst of 2, so three quick requests will overflow it:
The third gets a 429 with a Retry-After: 1 header:
HTTP/1.1 429 Too Many Requests
Content-Type: text/plain; charset=utf-8
Retry-After: 1
X-Content-Type-Options: nosniff
rate limit exceeded
Two details worth knowing before you rely on this. The Retry-After value is the fixed
string 1 — it isn't computed from the bucket's real refill time and can't be configured.
And the rejection is logged at INFO, not ERROR, because only a status of 500 or above
raises the level.
That's one bucket shared by every caller. To limit per client instead, set
RateLimitConfig.KeyFunc = transithttp.ClientIPKey — but read
the reference
first, because keying on a client IP behind a proxy needs care.
Stop the server with Ctrl-C.
Make a client survive a flaky downstream¶
The client half of transit is a set of http.RoundTripper decorators. Create
client/main.go — it stands up a downstream that always fails, so you can watch retry and
the circuit breaker interact without needing a real one:
package main
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"time"
transithttp "gitlab.com/phpboyscout/go/transit/http"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
var attempts atomic.Int64
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer downstream.Close()
retryCfg := transithttp.DefaultRetryConfig()
retryCfg.InitialBackoff = 10 * time.Millisecond // so the tutorial doesn't crawl
retrying := transithttp.NewRetryTransport(http.DefaultTransport, retryCfg)
breakerCfg := transithttp.DefaultCircuitBreakerConfig()
breakerCfg.FailureThreshold = 2 // default is 5; 2 keeps this short
chain := transithttp.NewClientChain(
transithttp.WithCircuitBreaker(log, breakerCfg),
)
client := &http.Client{Transport: chain.Then(retrying)}
for i := range 3 {
resp, err := client.Get(downstream.URL + "/things")
switch {
case errors.Is(err, transithttp.ErrCircuitOpen):
fmt.Printf("call %d: circuit open, downstream not contacted\n", i+1)
case err != nil:
fmt.Printf("call %d: %v\n", i+1, err)
default:
resp.Body.Close()
fmt.Printf("call %d: status %d\n", i+1, resp.StatusCode)
}
}
fmt.Printf("downstream saw %d requests\n", attempts.Load())
}
Run it:
call 1: status 503
time=2026-08-02T18:45:22.373Z level=DEBUG msg="circuit breaker state change" from=closed to=open
call 2: status 503
call 3: circuit open, downstream not contacted
downstream saw 8 requests
Three lines of output, three things to take from them.
Eight requests for three calls. Each client.Get became four requests — the original
plus MaxRetries: 3 — and then the third call made none at all. Retry is silent: you get
the last response, not an error, so a 503 that survived three retries looks exactly like a
503 that didn't. Check the status; retry exhaustion is not reported any other way.
The breaker counted two failures, not eight. That's what the ordering buys. The chain wraps around the retry transport, so the breaker sees one verdict per logical call instead of one per attempt. Assemble it the other way round and a threshold of 5 would trip inside the first call.
The third call never left the process. ErrCircuitOpen comes back immediately, with no
connection attempt and no backoff sleep. Use errors.Is to detect it rather than ==:
http.Client wraps transport errors in a *url.Error.
Leave it running for 30 seconds and the breaker would move to half-open and admit one trial
request. That's the Cooldown default.
What to change before this is production code¶
Three things in the client above are wrong for real use, and they're wrong quietly:
- The bearer token is missing, and adding one has a trap.
WithBearerToken(token)without a host pins the credential to whichever host the transport sees first. Always pass the host:WithBearerToken(token, "api.example.com"). - A
POSTwould not have been retried. Only idempotent methods are resent on a retryable status, so a create or a charge can't be double-submitted by accident. Opting in is explicit — see the retry reference. - The breaker is shared across every host that goes through that transport. One sick downstream cuts off all of them. Give each downstream its own client.
Where next¶
- Compose HTTP middleware — the full server and client toolboxes as a working reference.
- Compose gRPC interceptors — the same concerns on the other transport.
- The middleware model — why chains compose the way they do, and where each concern belongs.
- Reference — every config field, its default, and what a wrong value does.