Skip to content

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:

import transithttp "gitlab.com/phpboyscout/go/transit/http"

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:

go run ./server

Watch what a request produces

In another terminal:

curl -s localhost:8080/hello

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:

curl -s -H 'X-Forwarded-For: 1.2.3.4' localhost:8080/hello

Now hit the health endpoint:

curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/healthz

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:

for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' localhost:8080/hello; done; echo
200 200 429

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:

go run ./client
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 POST would 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