Skip to content

Go: packages, interfaces, goroutines, channels, context, net/http, testing

Modulelang.06 · practice · Go · Pass 1 · 4 to 6 h
You buildprimers/lang.06/proxy.go: New (a streaming HTTP reverse proxy with a deadline) and CopyFlush (the copy loop at its heart)
Contractnone: the two signatures and the rules in section 4 are the contract
Testscourse/tests/lang.06/check runs go vet and go test -race with the course tests in course/tests/go/lang_06/ (what each test checks: section 4)
Needslang.05 HTTP and SSE (reading), lang.03 C (reading: pointers and the dynamic array, which Go’s slices echo)
Used byno call site (a primer): gw.00 applies it next, then dur.01, load.01, and ag.01
MilestoneMS-P1
Optional depthA Tour of Go (free), Effective Go (free), The Go Memory Model (free), net/http package docs (free), Donovan and Kernighan, The Go Programming Language, ch. 7 to 9
  • A Go module is a tree of packages; a package is one directory; a name is exported when it starts with a capital letter. go vet and gofmt are part of the language’s culture, and the check runs go vet.
  • An interface is satisfied implicitly: anything with a Read([]byte) (int, error) method is an io.Reader. http.Handler, io.Reader, and io.Writer are the three interfaces a proxy is built from.
  • A goroutine is a function running concurrently; a channel passes values between goroutines and synchronizes them. net/http runs every request in its own goroutine, so a handler is concurrent code even when it looks sequential.
  • A context carries a cancellation signal and a deadline down a call tree. A deadline is a point in time, not an idle timeout, and the cancel function it returns must always be called.
  • http.ResponseWriter buffers. Streaming means flush after every write; the tests hold the upstream’s second chunk back until the first one reaches the client, so a buffering proxy cannot pass.
Terminal window
ol start lang.06 # writes primers/lang.06/proxy.go with stub bodies
ol tests lang.06 # read the test catalog first
cd primers/lang.06 && go mod init lang06 && cd - # optional: lets you run go build/vet there yourself
ol check lang.06 # exit code is the verdict

ol start writes only proxy.go, with both function bodies replaced by panic("todo: lang.06"). The check does not need your go.mod: it copies your .go files (not your own _test.go files) into a scratch module named lang06, adds the course tests, and runs go vet -tags primer and go test -race -tags primer there. It also writes go/go.mod if your repo has none, because ol start prepares the Go module that gw.00 builds in next.


Your Pass 1 system so far is a Python model, a C kernel, and a Rust engine that serves completions over HTTP with no authentication: anyone who can reach the engine’s port can use it, and nothing stands between a client and a stalled engine. The next module, gw.00, puts a gateway in front of the engine, and the gateway is written in Go, as is every control-plane component after it (the durable engine dur.*, the load generator load.*, the agent SDK ag.*). A gateway is, at its core, a streaming reverse proxy: it receives a request, makes a second request to the engine, and copies the answer back as it arrives. This primer teaches Go by building exactly that, without the API keys and trace headers, so that in gw.00 the only new ideas are the gateway’s.

Every term is defined before it is used. You have written Python, C (lang.03), and Rust (lang.04), and you can read an HTTP exchange off the wire (lang.05); Go is introduced by comparison with those.

A module is a directory tree with a go.mod file at its root. go.mod names the module path (for example tinyllm in your repo, or lang06 in the check’s scratch module) and the Go version. A package is one directory of .go files that all begin with the same package <name> line. A package is imported by its import path: the module path plus the directory, so the directory go/gateway/proxy of module tinyllm is imported as "tinyllm/gateway/proxy" and used as proxy.NewProxy.

A name is exported (visible to other packages) exactly when it starts with an upper-case letter: New and CopyFlush are exported, copyHeaders is not. There are no public or pub keywords.

CommandWhat it does
go build ./...compiles every package under the current directory (./... means “this directory and below”)
go vet ./...runs static checks for real bugs: a context’s cancel function never called, a Printf format that does not match its arguments, a struct copied with its lock
go test ./...compiles each package with its _test.go files and runs every func TestXxx(t *testing.T)
go test -raceadds the race detector: two goroutines touching the same memory without synchronization, with at least one writing, make the test fail
gofmt -l .lists files not in the one standard layout; Go code is never formatted by hand

Unlike Python, an unused import or an unused local variable is a compile error. Unlike C, there are no header files: a package’s exported names are its interface.

Go is statically typed with type inference: x := 3 declares an int. A struct is a C struct with methods attached. A slice ([]byte) is a pointer, a length, and a capacity over an array: exactly the dynamic array you wrote in C for lang.03. buf[:n] is a slice of the first n bytes that shares the same memory; nothing is copied.

Functions return several values. Failure is a value of the built-in interface type error, returned last, never thrown:

n, err := r.Read(buf)
if err != nil {
return total, err
}

errors.Is(err, target) reports whether err is target or wraps it, which matters because errors are often wrapped with context (fmt.Errorf("read upstream: %w", err)). A few errors are sentinels, values you compare against: io.EOF means “the stream ended normally”, which is success, not failure.

defer f() schedules f to run when the surrounding function returns, whichever return it takes. It is how Go closes response bodies and calls cancel functions:

resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close() // runs on every path out of the function

An interface type lists methods. Any type that has those methods satisfies the interface, with no declaration that it does (Rust needs impl Trait for Type; Go does not). Three interfaces carry everything in this primer:

type Reader interface { Read(p []byte) (n int, err error) } // io.Reader: fill p, report how much
type Writer interface { Write(p []byte) (n int, err error) } // io.Writer: consume p
type Handler interface { ServeHTTP(w ResponseWriter, r *Request) } // http.Handler: answer one request

The Read contract is worth reading slowly, because every streaming bug starts here: Read may return fewer bytes than len(p), and it may return n > 0 and a non-nil error in the same call. So the correct loop writes the n bytes first and then looks at the error.

http.HandlerFunc is a function type with a ServeHTTP method that calls the function itself, so any func(w http.ResponseWriter, r *http.Request) becomes a Handler by conversion: http.HandlerFunc(myFunc). That is the usual way to return a handler that closes over configuration, as New does.

An interface value can be asked what else it is. http.ResponseWriter is an interface; the concrete writer net/http passes you also has a Flush() method. The modern way to reach it, even through wrappers, is a response controller: http.NewResponseController(w).Flush().

go f(x) starts f(x) in a new goroutine: a function running concurrently with its caller, scheduled by the Go runtime onto OS threads. Goroutines are cheap (a few KiB of stack), so net/http starts one per incoming request. Your handler therefore runs on many goroutines at once, which is why the check runs the race detector.

A channel is a typed pipe between goroutines. ch := make(chan string) makes an unbuffered channel: a send ch <- v blocks until another goroutine receives <-ch, so the two meet at that point. make(chan string, 1) holds one value without a waiting receiver. close(ch) says no more values will come; every receive on a closed channel returns at once, which makes close a broadcast. select waits on several channel operations and runs whichever is ready first:

select {
case got := <-result: // the work finished
use(got)
case <-time.After(3 * time.Second): // a channel that receives one value after 3 s
t.Fatal("gave up after 3 s")
}

The course tests are built from exactly these pieces: a fake upstream blocks on a channel until the test has seen the first chunk, then the test closes the channel to let it continue. Nothing in them sleeps for a fixed time.

A context.Context carries three things down a call chain: a Done channel that is closed when the work should stop, the error that says why (context.Canceled or context.DeadlineExceeded), and optionally a deadline, an absolute point in time. Contexts form a tree: a child is cancelled when its parent is.

ctx, cancel := context.WithTimeout(parent, 300*time.Millisecond) // deadline = now + 300 ms
defer cancel() // always: frees the timer
  • r.Context() is the context of an incoming request; net/http cancels it when the client disconnects. Deriving from it is what makes a client hang-up reach the upstream.
  • context.Background() is the empty root. Using it for the upstream request cuts the link to the client.
  • WithTimeout returns a cancel function; forgetting to call it leaks a timer until the deadline, and go vet reports it (lostcancel).
  • A deadline is fixed when it is created. An idle timeout restarts after every byte. They behave differently on an upstream that trickles one byte every 20 ms: an idle timeout of 300 ms never fires, a deadline of 300 ms always does.

As a server, net/http calls your Handler once per request, on its own goroutine. You answer through the http.ResponseWriter, in a fixed order: set headers with w.Header().Set(...), then the status with w.WriteHeader(code), then the body with w.Write(...). The first Write sends status 200 if WriteHeader was not called, and headers set after that are ignored.

The writer buffers the body (4 KiB by default) and sends it when the buffer fills or the handler returns. For an ordinary response that is efficient. For a stream it is wrong: a 10-byte chunk sits in the buffer until 4086 more bytes arrive. Flush sends what is buffered now.

As a client, you build a request with http.NewRequestWithContext(ctx, method, url, body) and send it with client.Do(req). Do returns when the response headers have arrived; the body is a stream (resp.Body, an io.ReadCloser) that you read and must close. If ctx is cancelled or its deadline passes, Do (or a later resp.Body.Read) returns an error that errors.Is matches against context.DeadlineExceeded or context.Canceled.

A reverse proxy is both roles at once: a server for the client and a client for the upstream. Two status codes name its own failures: 502 Bad Gateway (the upstream could not be reached or answered with garbage) and 504 Gateway Timeout (the upstream did not answer in time).

A test is func TestXxx(t *testing.T) in a file ending _test.go. t.Fatalf fails and stops the test, t.Helper() makes a failure point at the caller, and t.Cleanup(f) runs f when the test ends, last registered first. A test file may be in the package it tests or in <pkg>_test, a separate package that sees only exported names; the course tests use the second form, so they test only the contract.

net/http/httptest.NewServer(handler) starts a real HTTP server on a free local port and returns its URL. Both ends of a proxy test are real servers: a fake upstream and your proxy in front of it.

A build tag is a first line //go:build primer; the file is compiled only when the tag is given (go test -tags primer). The course tests for this primer carry it so they stay out of the course’s own Go module.

One request, followed through a proxy built with New("http://127.0.0.1:9000", 300*time.Millisecond). The upstream on port 9000 answers GET /tick with status 200 and then writes . and flushes every 20 ms, forever.

Time (ms)ClientProxy handler (one goroutine)Upstream
0sends GET /tickstarts; ctx deadline = 0 + 300 = 300
0Do(req) with ctx; waits for headersreceives the request
1headers arrive: copies them, WriteHeader(200)sends 200 OK
20Read returns 1 byte .; Write; Flushwrites .
21receives .
40 … 280receives one . per 20 mssame loopone . per 20 ms
300deadline: Read returns context.DeadlineExceeded; CopyFlush returns; handler returnsits request context is cancelled; it stops
300body ends after 14 or 15 dots

Counting the dots: ticks happen at 20, 40, …, 280 ms inside the window, which is 280 / 20 = 14 ticks, and the 300 ms tick races the deadline, so the client sees 14 or 15 dots. The test TestDeadlineCountsFromTheStart asserts only that some arrived and that the body ends long before the 1.5 s it is willing to wait. An idle timeout of 300 ms would never end this body, because no gap is longer than 20 ms.

Now the three ways the same handler can fail, decided in this order:

Situation at client.DoerrAnswer
upstream answered headers in timenilcopy status, headers, body
client already gone (r.Context().Err() != nil)context.Cancelednothing: nobody is listening
upstream silent until the deadlinewraps context.DeadlineExceeded504
nothing listening on the portconnect: connection refused502

The second row must be checked before the third: a client that hangs up makes Do fail too, and answering it would write to a closed connection. These rows are TestSlowHeadersGet504, TestClientGoneCancelsUpstream, and TestUnreachableIs502.

The artifact is one file, primers/lang.06/proxy.go, in package proxy, importing only the standard library:

// New returns a handler that forwards every request to upstream (a base URL such as
// "http://127.0.0.1:8080") with the same method, path, query, headers, and body, and
// streams the upstream's status, headers, and body back, flushing each chunk as it
// arrives. Every forwarded request has a deadline of timeout from the moment the
// handler starts: no upstream headers by then is 504; a deadline mid-body ends the
// body; a client that goes away cancels the upstream request; an unreachable
// upstream is 502.
func New(upstream string, timeout time.Duration) http.Handler
// CopyFlush copies src to w until src is exhausted, flushing w after every chunk it
// writes. It returns the bytes written and the error that stopped it: nil when src
// ended with io.EOF.
func CopyFlush(w http.ResponseWriter, src io.Reader) (int64, error)

The check is course/tests/lang.06/check: go vet -tags primer and then go test -race -tags primer over your file plus course/tests/go/lang_06/proxy_test.go, in a scratch module. Both must succeed.

TestKINDChecksWhy it matters downstream
TestForwardsAndCopiesBackunitmethod, path, query, headers, and body reach the upstream; status 201, a header, and the body come backthe gateway is invisible to both sides (gw.00)
TestFlushesEachChunkunitthe first chunk reaches the client while the upstream is still holding the secondtoken streaming through the gateway (gw.00, gw.04)
TestSlowHeadersGet504boundaryno upstream headers in 100 ms gives 504, and the upstream request is cancelleda stalled engine does not stall the client
TestDeadlineCutsALongStreamboundarya stream that stalls after one chunk ends at the deadline, keeping the chunkstreams have a ceiling
TestDeadlineCountsFromTheStartboundarythe section 3 example: a trickling upstream still ends at the deadlinea deadline, not an idle timeout
TestClientGoneCancelsUpstreamfaultcancelling the client’s request cancels the upstream’s within 3 sabandoned completions stop generating (gw.04)
TestUnreachableIs502boundarya refused connection gives 502the gateway’s “engine down” answer (gw.00 turns it into 503 no_capacity)
TestCopyFlushunitone-byte reads give (3, nil) and at least 3 flushes; a reader failing after ab gives (2, that error)the copy loop gw.00 reuses
PitfallSymptomCaught by
copying with io.Copy(w, resp.Body) and no flushthe client sees nothing until 4 KiB accumulate or the upstream finishesTestFlushesEachChunk, TestCopyFlush
context.WithTimeout(context.Background(), ...) instead of r.Context()a client that hangs up leaves the upstream working until the deadlineTestClientGoneCancelsUpstream
ctx, _ := context.WithTimeout(...) (the cancel function discarded)a timer leaks per requestgo vet (its lostcancel check), run by the check before the tests
a per-read idle timeout (a timer reset on every Read) instead of a deadlinea trickling upstream holds the client foreverTestDeadlineCountsFromTheStart
answering 502 for every Do errora slow upstream and a dead one look the same to the clientTestSlowHeadersGet504
treating io.EOF as a failure, or checking err before writing the n bytesthe last chunk is dropped, or a clean end reports an errorTestCopyFlush
setting response headers after WriteHeader or the first Writethey are silently droppedTestForwardsAndCopiesBack
DirectionModuleHow it uses this
Backlang.05the HTTP request, a response body that ends when the connection closes, and the SSE framing this proxy forwards
Backlang.03pointers and the dynamic array that slices generalize
Forwardgw.00the gateway’s request path is this proxy plus an API key, trace context, and contract errors
Forwarddur.01goroutines, channels, and testing for the durable event log
Forwardload.01an open-loop load generator: goroutines, context deadlines, net/http clients
Forwardag.01the agent SDK’s OpenAI-compatible provider reads SSE through net/http
Your pieceProduction equivalentWhat it addsWhere to look
Newnet/http/httputil.ReverseProxyhop-by-hop header removal, X-Forwarded-For, FlushInterval: -1 for streaming, an error handler, buffer poolshttputil.ReverseProxy (free)
the deadlinehttp.Server ReadTimeout, WriteTimeout, IdleTimeout; http.Client.Timeoutserver-side limits per connection; a client-side whole-exchange deadlinenet/http (free)
goroutines and channelsgolang.org/x/sync/errgroupa group of goroutines that share a context and return the first errorerrgroup (free)
the race detectorThreadSanitizerthe algorithm under -raceGo race detector (free)