SeriesPart 7 of 7 // Go 1.27
GoWriting
Aug 24, 2026
19 min read
net/http

Go 1.27: net/http, and a Tour of the Testing Toolchain

The last post in this series is a survey, not a thesis: RFC 9218 client priority on an HTTP/2 server, automatic body draining proven by a counting listener, what actually limits request headers, and six pieces of the testing and toolchain surface from a synctest-driven retry client to go fix and go mod tidy.

A workshop wall hung with a dense, orderly array of hand tools, each resting in its own painted shadow outline that matches it exactly: wrenches, calipers, gauges and files in graduated sizes. One tool near the centre, a precision dial indicator, is saturated burnt orange against the sepia-toned rest. Bench light rakes across the wall from the left.

This is the last post in the series, and it does not have a single argument to make. It is a survey of two companion directories: net/http behaviour in go1.27rc1, and six separate pieces of the testing and toolchain surface. Some of these are small. None of them are connected to each other. That is the point of a survey post: read the section you need.

Written and benchmarked against go1.27rc1, cut from release-branch.go1.27 on 18 June 2026. The release notes carried a draft warning at the time of writing. Final release is 25 August 2026.

The net/http half lives in 06-net-http. The testing and toolchain half lives in 07-testing-toolchain. Both directories pass in full under go1.27rc1: twelve tests (counting subtests) in 06-net-http, and every test across 07-testing-toolchain's main module and its four nested fixture modules.

One correction before the substance. This directory's original brief pointed me at a field called MaxHeaderValueCount as the thing that rejects an oversized request. I grepped the entire go1.27rc1 GOROOT, api/go1.27.txt, net/http, net/textproto, and the bundled net/http/internal/http2 for that string. Zero matches. It does not exist in any real go1.27 draft I could find. What actually does the rejecting, and has done for years before this release, is http.Server.MaxHeaderBytes. I cover that below instead, and I pinned the absence with a reflect check in the test suite so a future release candidate that adds the field would be caught rather than leaving this post quietly wrong.

net/http: priority, draining, and header limits

RFC 9218 client priority and DisableClientPriority

go1.27rc1's HTTP/2 server understands RFC 9218 priority hints. A client sends a priority: u=0 header on a request it wants served first and priority: u=7, i on one it is happy to have trickle in, and priorityWriteSchedulerRFC9218 (src/net/http/internal/http2/server.go:304) uses that to decide which stream gets the next slice of a shared, congested connection. Setting http.Server.DisableClientPriority swaps in a round-robin scheduler that ignores the header entirely.

The scenario I built for this is an API gateway multiplexing a latency-critical receipt fetch (priority: u=0) against a background ledger export (priority: u=7, i) on one HTTP/2 connection, with the connection-level flow control window deliberately starved. Here is the setup, straight from priority.go:

// ---------------------------------------------------------------------------
// The scenario
//
// A public API gateway terminates HTTP/2 for a finance backend. Two very
// different workloads share one connection per client:
//
//   - GET /v1/checkout/receipt  a receipt a human is waiting for. The client
//     marks it "priority: u=0", the most urgent RFC 9218 level.
//   - GET /v1/exports/ledger    a bulk ledger export a batch job pulls in the
//     background. The client marks it "priority: u=7, i", the least urgent
//     level, and says it can process the response incrementally.
//
// Both responses are large enough that the HTTP/2 flow control window cannot
// hold either of them, so the server's write scheduler has to choose which
// stream gets the next DATA frame. That choice is the whole point.
//
// In go1.27rc1 the choice is made by newPriorityWriteSchedulerRFC9218
// (src/net/http/internal/http2/server.go:304). Setting
// http.Server.DisableClientPriority swaps in newRoundRobinWriteScheduler
// instead, which ignores the priority header entirely.
// ---------------------------------------------------------------------------

Getting a test to actually observe the scheduler making a choice took two corrections I did not expect, and both are worth carrying past this one experiment.

The first is a methodology finding, and it is the more interesting of the two. My first attempt compared how much of each response body had arrived by the time the fastest stream finished. On loopback, once a reader starts consuming a body, WINDOW_UPDATE frames round-trip in tens of microseconds, so once any reading starts at all a multi-megabyte transfer completes in single-digit milliseconds regardless of which scheduler is running. The gap between the two schedulers gets diluted almost to nothing, because nearly all of the transfer happens after flow control has stopped mattering. So I stopped racing readers against finish time and instead measured the server's own write progress at a moment I controlled directly: every probe reads its response headers but never touches the body, the server keeps writing until its flow control window is exhausted and every w.Write call is blocked, and only then do I take a snapshot of how many bytes each stream had actually gotten out. That snapshot is BytesWrittenDuringStall, credited inside the handler itself:

// ProgressTracker records how many response body bytes [ReportHandler] has
// actually written for each labelled request, keyed by the value of the
// [HeaderProbeLabel] request header.
//
// This is what makes RFC 9218 prioritisation observable server-side rather
// than inferred from client timing. w.Write only returns once the HTTP/2
// server has accepted those bytes for sending, which for a handler that is
// outrunning its flow control window means the call blocks until the write
// scheduler picks this stream and window credit exists. A snapshot taken
// while every client reader is deliberately not reading anything therefore
// shows exactly how the scheduler divided a scarce, stalled connection window
// between competing streams.

The second correction is about which window has to be scarce. Go's HTTP/2 transport defaults to a 1GiB connection window and a 4MiB stream window, comfortably more than any of these responses needs, so the server never blocks and the scheduler never has to arbitrate. Shrinking both windows together does not fix this: if every stream's own window is smaller than the connection window split across the streams, each stream fills its own cap independently and the scheduler still never has to choose. It has to be the shared, connection-level window that runs out. My test client keeps the per-stream window generous, at 4MiB against a 1MiB body, and drops only the connection-level window to net/http's own minimum of 65535 bytes, which forces all four concurrent streams to compete for one small shared budget.

With that in place, the committed Phase B run shows the direction clearly. Four streams, two u=0 receipt fetches and two u=7, i ledger exports, share a connection whose window is stalled deliberately:

=== RUN   TestClientPriorityChangesServeOrder/rfc9218_scheduler_honours_client_priority
    priority_test.go:205: connections accepted: 1
    priority_test.go:205: body size: 1048576 bytes
    priority_test.go:221: attempt 1: urgent group stall share: 73.3%, bulk group stall share: 26.7%
=== RUN   TestClientPriorityChangesServeOrder/DisableClientPriority_falls_back_to_round_robin
    priority_test.go:205: connections accepted: 1
    priority_test.go:205: body size: 1048576 bytes
    priority_test.go:221: attempt 1: urgent group stall share: 53.3%, bulk group stall share: 46.7%

With prioritisation active, the urgent group held 73.3% of the bytes the server managed to write during the stall. With DisableClientPriority set, that dropped to 53.3%, close to the even split a priority-blind four-stream connection should produce. I am not going to write that as a fixed percentage claim. The exact split moves run to run, because which goroutine's HEADERS frame the server's single connection write loop serialises first is a genuine, uncontrolled race that neither this test nor net/http controls. Sampled directly while building the suite: prioritised runs landed between 66.7% and 80% urgent share across repeated attempts, and round-robin runs landed between 40% and 60%. The direction is reliable. The exact number is not, and the test retries up to five times against a threshold rather than asserting one specific figure, which is a defence against scheduler noise, not against the feature actually being broken: if RFC 9218 prioritisation, or the round-robin fallback, genuinely stopped working, every attempt would fail identically.

One more thing worth knowing if you run this yourself: http.Server.DisableClientPriority is read once per connection, when the server builds serverConn, so setting it after a connection is already open does nothing for that connection. And a request carrying a Via, Forwarded, or X-Forwarded-For header is treated as proof that an intermediary is in the path, which latches on the whole connection and forces round-robin regardless of what DisableClientPriority says:

=== RUN   TestIntermediaryHeaderDisablesClientPriority
    priority_test.go:286: connections accepted: 1
    priority_test.go:286: body size: 1048576 bytes
    priority_test.go:293: attempt 1: bulk group stall share: 46.7%

With DisableClientPriority left false, adding X-Forwarded-For to a request still pushes the bulk share to 46.7%, the same round-robin range as the explicit opt-out above. Nearly every reverse proxy adds X-Forwarded-For. A server sitting behind one gets round-robin whether or not it ever set DisableClientPriority, and that is not called out in the field's doc comment.

HTTP/1 automatic body draining, and the connection reuse it buys

A status-polling client that reads only the response headers and closes the body without ever reading it, the way a real "is this job still running" poller behaves, does not have to pay for a fresh TCP connection on every poll. net/http's HTTP/1 transport drains an unread body for the caller when Close is called, up to a 256KiB budget (maxPostCloseReadBytes, src/net/http/transport.go:2418), and that draining is what lets the connection go back into the idle pool instead of being torn down.

I proved that server-side, with a countconn.Listener counting accepted TCP connections, rather than trusting anything the client believes about its own connection reuse:

// One more piece of timing turned out to matter, and it is a genuine finding
// rather than scenario colour: Close returning does not mean the drain has
// finished and the connection is back in the idle pool. See the doc comment
// on [PollWithoutDraining] for the mechanism and the numbers behind it.

Five sequential polls, body never read, only closed:

=== RUN   TestAutomaticBodyDrainingControlsConnectionReuse/small_status_body_drains_and_the_connection_is_reused
    drain_test.go:73: body size: 32768 bytes, polls: 5, connections accepted: 1
=== RUN   TestAutomaticBodyDrainingControlsConnectionReuse/oversized_status_body_is_not_drained_and_every_poll_pays_for_a_new_connection
    drain_test.go:73: body size: 327680 bytes, polls: 5, connections accepted: 5

A 32KiB body, well under the 256KiB budget, drains on every close and one connection carries all five polls. A 320KiB body, over the budget, forces a fresh connection on every single poll: resp.ContentLength alone decides whether draining is even attempted, before any read happens, so this half of the result does not depend on timing at all.

The half that does depend on timing is the interesting finding. Close on a response body that still needs draining returns as soon as the drain has been handed off to the transport's read loop, not once the drain has actually finished and the connection has been returned to the idle pool:

// interval exists because of a real timing finding, not for pacing: Close on
// a response whose body still needs draining returns as soon as
// maybeDrainBody has been handed off to run (transport.go's readLoop sends on
// the eofc channel the moment tryDrain is true, unblocking earlyCloseFn's
// receive on that same channel), not once draining has actually finished and
// the connection has been returned to the idle pool. Firing the next request
// immediately after Close returns, with no gap at all, races that
// still-running drain: the pool has nothing idle to hand back yet, so the
// transport dials a fresh connection instead of waiting for one. Measured
// directly: with interval=0, ten out of ten trials of five polls against a
// drainable body opened more than one connection; with interval>=1ms, zero
// out of ten did, across every duration tried from 1ms to 5ms.

"Close the body and the connection goes back into the pool" is the headline behaviour, but it is not instantaneous. A caller firing requests back to back with no gap at all can still open extra connections even for a body comfortably under the drain budget. PollWithoutDraining in drain.go takes an explicit interval parameter for exactly this reason.

What actually rejects an oversized request header set

MaxHeaderValueCount does not exist, so headerlimits.go builds and tests against the field that does the rejecting in go1.27rc1: http.Server.MaxHeaderBytes, a total byte budget across the request line and every header key and value combined, not a count of values on any one header. It has been there since long before this release.

The scenario is a proxy chain that keeps appending to a distributed-tracing header instead of replacing it, which is a realistic way to blow a header budget by accident rather than by attack. With MaxHeaderBytes set to 8KiB:

=== RUN   TestMaxHeaderBytesRejectsOversizedRequest/a_single_hop's_trace_context_fits_easily
    headerlimits_test.go:105: hopCount=1 status=200 body=""
=== RUN   TestMaxHeaderBytesRejectsOversizedRequest/a_handful_of_hops_still_fits_under_8KiB
    headerlimits_test.go:105: hopCount=10 status=200 body=""
=== RUN   TestMaxHeaderBytesRejectsOversizedRequest/a_proxy_chain_that_kept_appending_instead_of_replacing_blows_the_budget
    headerlimits_test.go:105: hopCount=100 status=431 body="431 Request Header Fields Too Large"

One hop's worth of trace context (around 200 bytes) and ten hops (around 2000 bytes) both reach the handler with a normal 200 OK. A hundred hops, comfortably past the budget, never reach a handler at all: the connection reader returns errTooLarge while it is still parsing the request, before a Request value exists to hand to anything, and the server writes a fixed 431 Request Header Fields Too Large and closes the connection. I pinned the absence of the field the brief pointed me at with a reflect check so a future release candidate that adds it would break this test loudly instead of leaving the claim stale:

func TestMaxHeaderValueCountDoesNotExist(t *testing.T) {
	_, ok := reflect.TypeOf(http.Server{}).FieldByName("MaxHeaderValueCount")
	if ok {
		t.Fatal("http.Server now has a MaxHeaderValueCount field; the finding in headerlimits.go and NOTES.md is stale and this directory's header-limit test and documentation need to be rebuilt around the real field")
	}
}

That covers 06-net-http. The rest of this post moves to the testing and toolchain half.

Six pieces of the testing and toolchain surface

1. A retry client, tested with httptest and synctest.Sleep

This is the centrepiece of 07-testing-toolchain, and the reason is worth stating plainly: until testing/synctest matured, a test for exponential backoff had exactly two honest options. Sleep for real, which makes the suite slow and turns every assertion into a tolerance band. Or inject a clock, which tests the injected clock rather than the code that actually ships. retry.Client calls time.Sleep directly, no clock parameter, no interface to fake. Inside a synctest.Test bubble that sleep runs against the bubble's fake clock, so retry_test.go asserts the exact microsecond offset of every request the client makes, for ten different upstream behaviours, and the whole suite runs in well under a second no matter how long the schedule under test is.

The client itself is an ordinary exponential-backoff loop with Retry-After support and a context-aware wait:

// This is a plain time.Sleep raced against the context. Inside a
// synctest bubble both the timer and the context's Done channel belong
// to the bubble, so the whole select is durably blocking and the fake
// clock jumps straight to whichever fires first.
timer := time.NewTimer(delay)
select {
case <-timer.C:
case <-ctx.Done():
	timer.Stop()
	return nil, &Error{
		Attempts:   attempt,
		LastStatus: lastStatus,
		Elapsed:    time.Since(start),
		Err:        ctx.Err(),
	}
}

The test doubles up on two go1.27rc1 features to make this durably blocking rather than a source of flakes. httptest.NewTestServer serves over an in-memory network instead of a loopback socket, and a goroutine blocked reading a real socket is not "durably blocked" as far as the bubble is concerned, so a bubble containing one never goes idle and its fake clock never advances. synctest.Sleep is time.Sleep followed by synctest.Wait, and without the Wait a test that sleeps for exactly as long as the code under test races it to the wakeup with an unpredictable winner.

Here is the schedule the test derives and then asserts exactly, straight from the comment above TestClientBackoffSchedule:

// The arithmetic is worth writing out once. With Base=250ms, Multiplier=2 and
// Cap=2s the delays after attempts 1..4 are 250ms, 500ms, 1s and 2s (the
// fourth is 2s capped down from 2s exactly, the fifth would be capped from
// 4s). Cumulative offsets are therefore 0, 250ms, 750ms, 1.75s, 3.75s.

And two of the ten table cases, exactly as written:

{
	// Two 503s then a success. This is the common shape of a rolling
	// deploy: the ingest fleet is briefly short of healthy backends.
	name: "two 503s then accepted",
	responses: []response{
		{status: http.StatusServiceUnavailable},
		{status: http.StatusServiceUnavailable},
		{status: http.StatusAccepted},
	},
	wantArrivalsMicros: []int64{0, 250_000, 750_000},
	wantStatus:         http.StatusAccepted,
},
{
	// The upstream never recovers. All five attempts are spent and the
	// exponential curve flattens at the 2s cap on the last delay.
	name:               "exhausts all attempts on persistent 503",
	responses:          []response{{status: http.StatusServiceUnavailable}},
	wantArrivalsMicros: []int64{0, 250_000, 750_000, 1_750_000, 3_750_000},
	wantErrAttempts:    5,
},

Those are not approximate. wantArrivalsMicros is compared for exact equality against the bubble clock's offset when each request actually reached the fake server, in microseconds, and it passes on every run because the bubble's clock only moves when something asks it to. The same file asserts a context deadline that cuts a wait short mid-backoff (a 900ms deadline lands the third request at exactly 750,000 microseconds and returns the caller at exactly 900,000, not 1.75 seconds later), a pure transport failure with no HTTP response at all (errors.Is unwraps to the underlying dial error, and the client still backs off on schedule, ending at exactly 300,000 microseconds for a 100ms-then-200ms schedule), and an OnRetry observability hook firing with the exact delay and reason string for each retry.

Ten subtests under TestClientBackoffSchedule, plus TestClientTotalElapsed, TestClientContextDeadline, TestClientOnRetryCallback, TestClientRetriesTransportErrors, TestPolicyDelay, TestPolicyDelayJitter, TestParseRetryAfter and TestIsRetryableStatus, all pass:

--- PASS: TestClientBackoffSchedule (0.01s)
    --- PASS: TestClientBackoffSchedule/two_503s_then_accepted (0.00s)
    --- PASS: TestClientBackoffSchedule/exhausts_all_attempts_on_persistent_503 (0.00s)
...
--- PASS: TestClientRetriesTransportErrors (0.00s)
PASS
ok  	github.com/asahasrabuddhe/go-1-27-bench/07-testing-toolchain/retry	0.473s

One genuine trap along the way, worth flagging since it is a go1.27rc1 quirk rather than a bug in the retry client. An early version of the transport-error test called srv.Close() on an httptest.NewTestServer before sending any request, expecting the next dial to fail immediately. Instead the whole synctest.Test call panicked with panic: deadlock: all goroutines in bubble are blocked. internal/nettest.Listener.NewConn does not check whether the listener has already been closed: it creates a connection pair and queues the server side for Accept regardless, but the server's Serve loop already exited, so nothing will ever call Accept again and both ends of the pipe block forever with no timer anywhere to give the bubble's clock somewhere to go. Closing an in-memory test server mid-flight is not a reliable way to simulate a broken connection; it deadlocks the bubble instead. I worked around it with a one-method stub http.RoundTripper that returns a canned error synchronously, no goroutines, no network, which is arguably a better test of a transport failure anyway.

2. The stdversion vet check, and its floor

stdversion is a vet analyzer that catches a module using a standard library symbol newer than the Go version its go.mod declares. The compiler alone will not catch this, because a go1.27rc1 toolchain happily compiles slices.Concat (added in Go 1.22) regardless of what the go directive says. stdversion/toonew/ is a fixture module declaring go 1.21 and calling slices.Concat:

// Combine concatenates two batches of metric samples for a single upload.
// Written with slices.Concat, which does not exist at the go 1.21 language
// version this file claims.
func Combine(a, b []int) []int {
	return slices.Concat(a, b)
}

Vetting it gives a real, single-line diagnostic:

$ cd 07-testing-toolchain/stdversion/toonew
$ GOTOOLCHAIN=local go1.27rc1 vet ./...
main.go:26:16: slices.Concat requires go1.22 or later (module is go1.21)
 
Exit code: 1

The finding worth carrying past this example is that stdversion has a floor, and it is not documented anywhere I could find outside the analyzer's own source. golang.org/x/tools/go/analysis/passes/stdversion/stdversion.go skips modules whose go.mod declares go 1.20 or lower entirely, because before go1.21 the go directive was not clearly specified as a toolchain requirement. I checked this directly: the identical slices.Concat call, in a module declaring go 1.20 instead of go 1.21, produces no diagnostic at all, and go vet ./... exits 0. Not a weaker check. No check. stdversion does not catch every too-new stdlib reference regardless of a module's declared Go version; below go 1.21 it is silently inert.

3. go test -json, and the undocumented frame OutputType

go test -json streams structured events instead of plain text, and each output-action event carries an OutputType field. The obvious value to expect is error, for a failing assertion, and that value does appear. What I did not expect, and did not find spelled out anywhere I looked before running this myself, is that every other line of test output, === RUN, --- PASS, the whole ordinary chatter of a test run, also carries an explicit OutputType, and its value is frame, not an absent field or an empty string. A table-driven test with one deliberately wrong expectation makes both values appear in the same run:

{"Time":"2026-08-14T14:32:14.265524+05:30","Action":"output","Package":"github.com/asahasrabuddhe/go-1-27-bench/07-testing-toolchain/jsonevents/demo","Test":"TestClamp","Output":"=== RUN   TestClamp\n","OutputType":"frame"}
{"Time":"2026-08-14T14:32:14.265828+05:30","Action":"output","Package":"github.com/asahasrabuddhe/go-1-27-bench/07-testing-toolchain/jsonevents/demo","Test":"TestClamp/deliberately_wrong_expectation,_kept_to_force_an_error_OutputType_event","Output":"    demo_test.go:27: Clamp(5, 0, 10) = 5, want 999\n","OutputType":"error"}
{"Time":"2026-08-14T14:32:14.265836+05:30","Action":"output","Package":"github.com/asahasrabuddhe/go-1-27-bench/07-testing-toolchain/jsonevents/demo","Test":"TestClamp/deliberately_wrong_expectation,_kept_to_force_an_error_OutputType_event","Output":"--- FAIL: TestClamp/deliberately_wrong_expectation,_kept_to_force_an_error_OutputType_event (0.00s)\n","OutputType":"frame"}

The === RUN line and the --- FAIL summary line both carry "OutputType":"frame". Only the actual failure message, the t.Errorf text itself, carries "OutputType":"error". Anything consuming this stream to build a custom test reporter or CI annotation needs to know frame exists as a value at all, rather than filtering only for error and assuming everything else is untyped.

4. go fix modernisers: four fired on one small file

go fix in go1.27rc1 runs the modernisers formerly reached through gopls's modernize analyzer directly from the go command. modernize/sample/ is a small file written deliberately in pre-modernisation idiom, run through go fix -diff to preview and go fix to apply. Four modernisers fired: any (replacing interface{}), minmax (replacing an if/else assignment with the max builtin), slices (replacing sort.Slice with slices.Sort), and rangeint (replacing a three-clause counting loop with range-over-int). The real diff:

--- /Users/ajitem/go-1-27-bench-wt/07-testing-toolchain/07-testing-toolchain/modernize/sample/sample.go (old)
+++ /Users/ajitem/go-1-27-bench-wt/07-testing-toolchain/07-testing-toolchain/modernize/sample/sample.go (new)
@@ -6,21 +6,17 @@
 // Companion to https://ajitem.com/blog/go-1-27-net-http-testing-toolchain
 package sample
 
-import "sort"
+import "slices"
 
 // Tags holds arbitrary metadata attached to a metric sample. Written with
 // interface{} rather than any, the pre-1.18 idiom.
-type Tags map[string]interface{}
+type Tags map[string]any
 
 // Max returns the larger of a and b, written as the pre-1.21 if/else idiom
 // that min and max as builtins made unnecessary.
 func Max(a, b int) int {
 	var m int
-	if a > b {
-		m = a
-	} else {
-		m = b
-	}
+	m = max(a, b)
 	return m
 }
 
@@ -28,7 +16,7 @@
 // the type-safe slices.Sort added in Go 1.21.
 func SortedCopy(xs []int) []int {
 	out := append([]int(nil), xs...)
-	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+	slices.Sort(out)
 	return out
 }
 
@@ -36,7 +24,7 @@
 // three-clause loop rather than a range-over-int.
 func SumFirst(xs []int, n int) int {
 	total := 0
-	for i := 0; i < n; i++ {
+	for i := range n {
 		total += xs[i]
 	}
 	return total

Four small, mechanical rewrites, none of them changing behaviour, all of them the kind of thing that used to sit in a code review comment. go fix -diff shows exactly this without touching the file; go fix applies it.

5. go doc -ex and go doc pkg@version

Two flags on go doc, tested directly against the standard library and a cached module rather than against any fixture in this directory.

-ex's help text reads "Include executable examples," which sounds like it should inline example source wherever a documented symbol has one. It does not, for the query form most people reach for first:

$ GOTOOLCHAIN=local go1.27rc1 doc -ex strings.Builder
(-ex adds a one-line "func ExampleBuilder()" pointer, not the example body)
 
package strings // import "strings"
 
type Builder struct {
	// Has unexported fields.
}
    A Builder is used to efficiently build a string using Builder.Write methods.
    It minimizes memory copying. The zero value is ready to use. Do not copy a
    non-zero Builder.
 
    func ExampleBuilder()
func (b *Builder) Cap() int
...

-ex adds exactly one line, func ExampleBuilder(), a pointer rather than a body. To see the actual example code, query the example function directly, and no -ex flag is needed for that at all:

$ GOTOOLCHAIN=local go1.27rc1 doc strings.ExampleBuilder
(querying the Example function directly prints its full body and Output
comment, with no -ex flag needed at all)
 
package main
 
import (
	"fmt"
	"strings"
)
 
func main() {
	var b strings.Builder
	for i := 3; i >= 1; i-- {
		fmt.Fprintf(&b, "%d...", i)
	}
	b.WriteString("ignition")
	fmt.Println(b.String())
 
}
 
Output: 3...2...1...ignition

Two independent code paths are behind this, both in cmd/go/internal/doc/pkg.go: exampleSummary prints only the one-line pointer and is gated on -ex's showEx bool, while findExamples/emitExample prints the full body and fires whenever the queried symbol itself starts with Example, gated by nothing at all. Adding -ex to a go doc command aimed at the parent symbol does not make example source appear. It only appears when you name the example function directly.

pkg@version has a separate surprise. With golang.org/x/[email protected]'s zip and go.mod already sitting in GOMODCACHE, verified before running this, go doc golang.org/x/sync/[email protected] under GOPROXY=off still fails:

doc: golang.org/x/sync/[email protected]: loading deprecation for golang.org/x/sync: module lookup disabled by GOPROXY=off

The @version form checks the module's deprecation status, the notice go list -m -u would show if the module's go.mod carried a // Deprecated: comment on the module directive, and that check goes to the proxy regardless of what is already cached locally. A plain go doc pkg, no version, never has this problem, because it never needs to fetch a deprecation notice the module graph has not already resolved. "Run this offline for a quick lookup" is the obvious reason to reach for pkg@version, and it silently is not fully offline-capable even once the module is cached.

6. go mod tidy consolidating scattered require blocks

modtidy/sample/'s go.mod started with three separate require statements, out of order, plus a stale indirect requirement on golang.org/x/crypto that nothing in the module imports any more:

module github.com/asahasrabuddhe/go-1-27-bench/07-testing-toolchain/modtidy/sample
 
go 1.27
 
require golang.org/x/text v0.33.0
 
require (
	golang.org/x/sync v0.19.0
)
 
// Stale: nothing in this module imports x/crypto any more, but the
// requirement was never removed by hand. go mod tidy should drop it.
require golang.org/x/crypto v0.47.0 // indirect

go mod tidy -diff previews the fix, and go mod tidy applies it, against a small errgroup-and-language fan-out (FetchAll) that keeps the two real dependencies genuinely in use rather than decorative:

diff current/go.mod tidy/go.mod
--- current/go.mod
+++ tidy/go.mod
@@ -2,12 +2,7 @@
 
 go 1.27
 
-require golang.org/x/text v0.33.0
-
 require (
 	golang.org/x/sync v0.19.0
+	golang.org/x/text v0.33.0
 )
-
-// Stale: nothing in this module imports x/crypto any more, but the
-// requirement was never removed by hand. go mod tidy should drop it.
-require golang.org/x/crypto v0.47.0 // indirect

Three scattered requirements collapse into one alphabetised block, and the stale x/crypto requirement is gone. The comment explaining why it was stale goes with it, which is the expected behaviour: go mod tidy tidies the machine-readable requirement list, not prose living next to it, so a comment justifying a requirement disappears along with the requirement it was justifying.

Takeaways

  • MaxHeaderValueCount does not exist in go1.27rc1. If you are looking for what limits request header size, it is http.Server.MaxHeaderBytes, a total byte budget, unchanged from long before this release.
  • RFC 9218 client priority works, and DisableClientPriority turns it off, but a request carrying X-Forwarded-For turns it off too, silently, even when DisableClientPriority is left false.
  • Measuring HTTP/2 priority on loopback needs a deliberate stall and a server-side snapshot. Comparing finish times does not work, because window-update round trips are microseconds once any reading starts.
  • Closing an unread HTTP/1 response body lets net/http drain it and reuse the connection, up to 256KiB, but Close returning is not the same moment as the connection reaching the idle pool. A zero-gap follow-up request can still open a second connection.
  • testing/synctest's fake clock turns a backoff schedule from a tolerance band into an exact assertion in microseconds, and it needs httptest.NewTestServer's in-memory network alongside it, because a goroutine blocked on a real socket keeps a bubble from ever going idle.
  • Closing an in-memory httptest server before a client dials it does not simulate a broken connection. It deadlocks the synctest bubble instead.
  • stdversion catches a too-new stdlib call, but only in a module declaring go 1.21 or later. Below that it produces no diagnostic and a zero exit code, not a weaker one.
  • go test -json's OutputType field carries more than error for failures. Ordinary test output, === RUN and --- PASS included, carries "OutputType":"frame" on every line.
  • go doc -ex adds a one-line pointer to an example, not the example's body. The body only appears when you query the example function by name, with no flag needed at all.
  • go doc pkg@version needs the proxy for a deprecation check even when the requested version is already in the local module cache, so it is not a reliable offline lookup.
  • None of the percentages in the priority section are fixed constants of go1.27rc1. They are one machine's measurements of a real scheduling race, and the direction they show is the durable finding, not the exact figure.

Full source for both directories, tests included, is at 06-net-http and 07-testing-toolchain in the companion repository. That closes out this series: seven posts against go1.27rc1, from generic methods through the encoding/json engine swap to this survey of what net/http and the toolchain actually do differently.

Series contents