Go: packages, interfaces, goroutines, channels, context, net/http, testing
Overview
Section titled “Overview”| Module | lang.06 · practice · Go · Pass 1 · 4 to 6 h |
| You build | primers/lang.06/proxy.go: New (a streaming HTTP reverse proxy with a deadline) and CopyFlush (the copy loop at its heart) |
| Contract | none: the two signatures and the rules in section 4 are the contract |
| Tests | course/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) |
| Needs | lang.05 HTTP and SSE (reading), lang.03 C (reading: pointers and the dynamic array, which Go’s slices echo) |
| Used by | no call site (a primer): gw.00 applies it next, then dur.01, load.01, and ag.01 |
| Milestone | MS-P1 |
| Optional depth | A 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 |
Key Takeaways
Section titled “Key Takeaways”- 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 vetandgofmtare part of the language’s culture, and the check runsgo vet. - An interface is satisfied implicitly: anything with a
Read([]byte) (int, error)method is anio.Reader.http.Handler,io.Reader, andio.Writerare 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/httpruns 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.ResponseWriterbuffers. 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.
How to work this chapter
Section titled “How to work this chapter”ol start lang.06 # writes primers/lang.06/proxy.go with stub bodiesol tests lang.06 # read the test catalog firstcd primers/lang.06 && go mod init lang06 && cd - # optional: lets you run go build/vet there yourselfol check lang.06 # exit code is the verdictol 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.
1. Why now
Section titled “1. Why now”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.
2. Principles
Section titled “2. Principles”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.
2.1 Modules, packages, and the toolchain
Section titled “2.1 Modules, packages, and the toolchain”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.
| Command | What 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 -race | adds 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.
2.2 Values, errors, and defer
Section titled “2.2 Values, errors, and defer”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 function2.3 Interfaces
Section titled “2.3 Interfaces”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 muchtype Writer interface { Write(p []byte) (n int, err error) } // io.Writer: consume ptype Handler interface { ServeHTTP(w ResponseWriter, r *Request) } // http.Handler: answer one requestThe 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().
2.4 Goroutines and channels
Section titled “2.4 Goroutines and channels”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.
2.5 context: cancellation and deadlines
Section titled “2.5 context: cancellation and deadlines”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 msdefer cancel() // always: frees the timerr.Context()is the context of an incoming request;net/httpcancels 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.WithTimeoutreturns a cancel function; forgetting to call it leaks a timer until the deadline, andgo vetreports 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.
2.6 net/http: one request, two roles
Section titled “2.6 net/http: one request, two roles”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).
2.7 testing
Section titled “2.7 testing”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.
3. Worked example by hand
Section titled “3. Worked example by hand”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) | Client | Proxy handler (one goroutine) | Upstream |
|---|---|---|---|
| 0 | sends GET /tick | starts; ctx deadline = 0 + 300 = 300 | |
| 0 | Do(req) with ctx; waits for headers | receives the request | |
| 1 | headers arrive: copies them, WriteHeader(200) | sends 200 OK | |
| 20 | Read returns 1 byte .; Write; Flush | writes . | |
| 21 | receives . | ||
| 40 … 280 | receives one . per 20 ms | same loop | one . per 20 ms |
| 300 | deadline: Read returns context.DeadlineExceeded; CopyFlush returns; handler returns | its request context is cancelled; it stops | |
| 300 | body 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.Do | err | Answer |
|---|---|---|
| upstream answered headers in time | nil | copy status, headers, body |
client already gone (r.Context().Err() != nil) | context.Canceled | nothing: nobody is listening |
| upstream silent until the deadline | wraps context.DeadlineExceeded | 504 |
| nothing listening on the port | connect: connection refused | 502 |
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.
4. The interface
Section titled “4. The interface”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.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
TestForwardsAndCopiesBack | unit | method, path, query, headers, and body reach the upstream; status 201, a header, and the body come back | the gateway is invisible to both sides (gw.00) |
TestFlushesEachChunk | unit | the first chunk reaches the client while the upstream is still holding the second | token streaming through the gateway (gw.00, gw.04) |
TestSlowHeadersGet504 | boundary | no upstream headers in 100 ms gives 504, and the upstream request is cancelled | a stalled engine does not stall the client |
TestDeadlineCutsALongStream | boundary | a stream that stalls after one chunk ends at the deadline, keeping the chunk | streams have a ceiling |
TestDeadlineCountsFromTheStart | boundary | the section 3 example: a trickling upstream still ends at the deadline | a deadline, not an idle timeout |
TestClientGoneCancelsUpstream | fault | cancelling the client’s request cancels the upstream’s within 3 s | abandoned completions stop generating (gw.04) |
TestUnreachableIs502 | boundary | a refused connection gives 502 | the gateway’s “engine down” answer (gw.00 turns it into 503 no_capacity) |
TestCopyFlush | unit | one-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 |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
copying with io.Copy(w, resp.Body) and no flush | the client sees nothing until 4 KiB accumulate or the upstream finishes | TestFlushesEachChunk, TestCopyFlush |
context.WithTimeout(context.Background(), ...) instead of r.Context() | a client that hangs up leaves the upstream working until the deadline | TestClientGoneCancelsUpstream |
ctx, _ := context.WithTimeout(...) (the cancel function discarded) | a timer leaks per request | go 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 deadline | a trickling upstream holds the client forever | TestDeadlineCountsFromTheStart |
answering 502 for every Do error | a slow upstream and a dead one look the same to the client | TestSlowHeadersGet504 |
treating io.EOF as a failure, or checking err before writing the n bytes | the last chunk is dropped, or a clean end reports an error | TestCopyFlush |
setting response headers after WriteHeader or the first Write | they are silently dropped | TestForwardsAndCopiesBack |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | lang.05 | the HTTP request, a response body that ends when the connection closes, and the SSE framing this proxy forwards |
| Back | lang.03 | pointers and the dynamic array that slices generalize |
| Forward | gw.00 | the gateway’s request path is this proxy plus an API key, trace context, and contract errors |
| Forward | dur.01 | goroutines, channels, and testing for the durable event log |
| Forward | load.01 | an open-loop load generator: goroutines, context deadlines, net/http clients |
| Forward | ag.01 | the agent SDK’s OpenAI-compatible provider reads SSE through net/http |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
New | net/http/httputil.ReverseProxy | hop-by-hop header removal, X-Forwarded-For, FlushInterval: -1 for streaming, an error handler, buffer pools | httputil.ReverseProxy (free) |
| the deadline | http.Server ReadTimeout, WriteTimeout, IdleTimeout; http.Client.Timeout | server-side limits per connection; a client-side whole-exchange deadline | net/http (free) |
| goroutines and channels | golang.org/x/sync/errgroup | a group of goroutines that share a context and return the first error | errgroup (free) |
| the race detector | ThreadSanitizer | the algorithm under -race | Go race detector (free) |