HowtoGo
Home / Notes / How Go Speaks HTTP
Notes

How Go Speaks HTTP

The protocol in three parts: what goes over the wire, how Go sends a request, and how Go serves one.

HTTP is a text protocol with a simple shape: a client sends a request, a server sends back one response, and the exchange is over. Everything else is agreement about what goes in the two messages.

Go ships all of it in net/http, client and server both. This page covers the protocol first, then the calls that send a request, then the ones that answer one. To build a working service with them, start with one endpoint, then follow the JSON Task API tutorial.

Pick a method and watch the two messages change. One request goes out, one response comes back, and the status code on the way home is the server's whole verdict. PATCH is there to show a refusal: the router never declared it, so it never reaches a handler.

One HTTP request and the response it gets back, by method
request 2xx answer 4xx answer

The protocol

What actually goes over the wire

A request is a start line, some headers, a blank line, and an optional body. A response is the same shape with a status line on top.

GET /tasks/1 HTTP/1.1
Host: api.example.com
Accept: application/json
User-Agent: howtogo-demo/1.0

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 42

{"id": 1, "title": "Write the handler", "done": true}

The blank line is what separates headers from body, and it is the one piece of the format you never type yourself.

The methods

Two words decide how a method may be treated. Safe means it changes nothing, so a crawler may call it freely. Idempotent means calling it five times leaves the same state as calling it once, which is what lets a client retry after a dropped connection.

HTTP methods
MethodWhat it meansNotes
GETRead a resourceSafe and idempotent. Never changes anything on the server, so it can be cached, retried, and prefetched. Carries no body.
POSTCreate a resource, or submit workNeither safe nor idempotent. Sending it twice creates two things, which is why a double-clicked form makes two orders. Answers 201 Created with a Location header.
PUTReplace a resource entirelyIdempotent. The body is the whole new state, so any field you leave out is cleared. Sending it ten times leaves the same result as sending it once.
PATCHChange part of a resourceNot idempotent in general. The body describes the change rather than the whole object.
DELETERemove a resourceIdempotent. Deleting something already gone is still a success in most designs, answered with 204 or 404.
HEADThe headers of a GET, with no bodyUsed to check whether something exists, or how big it is, without downloading it.
OPTIONSAsk what a resource allowsAnswers with an Allow header. Browsers send it on their own as the CORS preflight.

CRUD over HTTP

The four database operations map onto four methods and two paths. Nouns go in the path, verbs go in the method, so a URL never contains a word like create or delete.

CRUD mapping
OperationRequestWhat comes back
CreatePOST /tasksBody carries the new object. Answer 201 with the created object and where it lives.
ReadGET /tasks, GET /tasks/1One path for the collection, one for a member. Answer 200, or 404 when there is no such member.
UpdatePUT /tasks/1Body carries the whole replacement. Answer 200 with the stored result.
DeleteDELETE /tasks/1No body either way. Answer 204 No Content.

Status codes

The first digit is the whole story. The other two narrow it down.

Families
RangeMeaningHow to read it
1xxInformationalThe request was received and work continues. You will rarely write one.
2xxIt workedThe request was received, understood, and accepted.
3xxLook somewhere elseFurther action is needed, usually following a Location header.
4xxThe caller got it wrongBad syntax, missing permission, or a resource that is not there. Repeating the same request gives the same answer.
5xxThe server got it wrongThe request looked fine and the server failed to handle it. Worth retrying.

Go names every code as a constant in net/http. Reach for the constant, so a reader sees http.StatusNoContent instead of a bare 204.

The codes you will actually send
CodeGo constantWhen to send it
200 OKhttp.StatusOKThe default success. A GET, a PUT, or any request whose answer has a body.
201 Createdhttp.StatusCreatedA POST made something. Send a Location header pointing at it.
204 No Contenthttp.StatusNoContentIt worked and there is nothing to send back. The usual answer to DELETE. Write no body at all.
301 Moved Permanentlyhttp.StatusMovedPermanentlyThis URL is retired. Caches and search engines update their records.
302 Foundhttp.StatusFoundA temporary redirect. Keep using the original URL.
304 Not Modifiedhttp.StatusNotModifiedThe copy the caller already has is current. Answers a conditional GET.
400 Bad Requesthttp.StatusBadRequestThe server could not parse it. Malformed JSON, a path parameter that is not a number.
401 Unauthorizedhttp.StatusUnauthorizedNo credentials, or credentials that failed. The name is misleading: it means unauthenticated.
403 Forbiddenhttp.StatusForbiddenThe server knows who you are and you still may not do this.
404 Not Foundhttp.StatusNotFoundNo resource at that path.
405 Method Not Allowedhttp.StatusMethodNotAllowedThe path exists, the method does not apply. Gorilla Mux sends this on its own once a route declares its methods.
409 Conflicthttp.StatusConflictThe request fights the current state. A duplicate unique key, an edit against a stale version.
422 Unprocessable Entityhttp.StatusUnprocessableEntityThe syntax parsed and the content is still wrong. A required field left empty.
429 Too Many Requestshttp.StatusTooManyRequestsRate limited. Send a Retry-After header.
500 Internal Server Errorhttp.StatusInternalServerErrorThe catch-all failure. Log the detail, send the caller something vague.
502 Bad Gatewayhttp.StatusBadGatewayA server upstream of this one returned garbage.
503 Service Unavailablehttp.StatusServiceUnavailableOverloaded or down for maintenance. Temporary by definition.
504 Gateway Timeouthttp.StatusGatewayTimeoutA server upstream took too long to answer.

Headers worth knowing

Common headers
HeaderWhat it carriesNotes
Content-TypeWhat the body isapplication/json, text/html; charset=utf-8. Set it before you write the body or Go guesses from the first bytes.
Content-LengthHow many bytes the body hasGo sets it for you when the whole body is buffered.
AcceptWhat the caller wants backThe client's side of Content-Type.
AuthorizationCredentialsBearer <token> for most APIs.
LocationWhere the thing is nowPairs with 201 after a create, and with every 3xx.
X-Forwarded-ForThe original caller's addressAdded by a proxy. The first entry is the client, the rest are hops. Trust it only from a proxy you run.
User-AgentWho is callingSet one on any client you write, so the server's logs name your program.
Retry-AfterWhen to come backSeconds, or an HTTP date. Pairs with 429 and 503.

Making a request

Sending a request

http.Get is the short form. It returns a *http.Response whose Body you have to close, or the connection stays checked out of the pool.

resp, err := http.Get("https://api.example.com/users/1")
if err != nil {
    return err
}
defer resp.Body.Close()

fmt.Println(resp.StatusCode)              // 200
fmt.Println(resp.Status)                  // 200 OK
fmt.Println(resp.Header.Get("Content-Type")) // application/json

A non-2xx answer is a normal response, not a Go error. err is set when the request never completed: no DNS, refused connection, timeout. Check the status yourself.

if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("unexpected status %s", resp.Status)
}

Reading the response

io.ReadAll gives you the whole body as bytes. For JSON, decode straight off the body and skip the intermediate slice.

// The whole body, as bytes.
body, err := io.ReadAll(resp.Body)

// Or straight into a struct.
type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
    return err
}
fmt.Printf("%+v\n", u) // {ID:1 Name:Ada}

Sending JSON

http.Post takes a content type plus any io.Reader to read the body from. Marshal first, wrap the bytes in a reader, send.

payload, err := json.Marshal(User{Name: "Grace"})
if err != nil {
    return err
}

resp, err := http.Post(
    "https://api.example.com/users",
    "application/json",
    bytes.NewReader(payload),
)
if err != nil {
    return err
}
defer resp.Body.Close()

fmt.Println(resp.StatusCode) // 201

A client you should actually use

http.Get and friends use http.DefaultClient, which has no timeout. A hung server holds your goroutine forever. Build a client with one, keep it for the life of the program so connections are reused, and use http.NewRequest when you need headers.

var client = &http.Client{Timeout: 10 * time.Second}

req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
    return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "howtogo-demo/1.0")
req.Header.Set("Authorization", "Bearer "+token)

resp, err := client.Do(req)

Use http.NewRequestWithContext instead when the caller should be able to cancel, which is every request made while serving another request.

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

Serving a request

Routing with Gorilla Mux

gorilla/mux is the router most Go services reach for. It adds path variables, method matching, host matching, subrouters, and middleware, and its router is an ordinary http.Handler, so everything else in net/http keeps working.

go get github.com/gorilla/mux
r := mux.NewRouter()

r.HandleFunc("/tasks", listTasks).Methods(http.MethodGet)
r.HandleFunc("/tasks", createTask).Methods(http.MethodPost)
r.HandleFunc("/tasks/{id:[0-9]+}", getTask).Methods(http.MethodGet)

log.Fatal(http.ListenAndServe(":8090", r))

Two routes share /tasks and split on the method. Once a route declares its methods, mux answers anything else on that path with 405 on its own.

// Path variables arrive as text, so convert them yourself.
func getTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(mux.Vars(r)["id"])
    if err != nil {
        http.Error(w, "bad id", http.StatusBadRequest)
        return
    }
    // ...
}
The mux calls you will use
CallWhat it doesNotes
mux.NewRouter()Makes a routerSatisfies http.Handler, so it goes straight into http.ListenAndServe or an http.Server.
r.HandleFunc(path, fn)Registers a handler for a pathReturns a *mux.Route, so the matchers below chain onto it.
.Methods(...)Restricts a route to given methodsTwo routes can share a path and split by method. Anything else on that path gets 405.
{id}Captures a path segmentMatches one segment and stores it under that name.
{id:[0-9]+}Captures a segment matching a patternThe part after the colon is a regular expression, so a non-numeric id never reaches the handler.
mux.Vars(r)Reads what the path capturedReturns map[string]string. Convert it yourself, since every value arrives as text.
.Queries(k, v)Requires a query parameter.Queries("sort", "{order}") both requires ?sort= and captures its value into Vars.
.Host(pattern)Matches on the Host headerPatterns capture too, so {sub}.example.com puts the subdomain in Vars.
r.PathPrefix(p)Matches everything under a prefixThe usual way to mount a file server or an API version.
.Subrouter()Groups routes under a prefixMiddleware and matchers set on the subrouter apply only to its routes.
r.Use(mw)Wraps every route on the routerA mux.MiddlewareFunc takes an http.Handler and returns one. Logging, auth, and recovery go here.
r.NotFoundHandlerHandles unmatched pathsSet it to send your own JSON 404 instead of the plain-text default.
r.MethodNotAllowedHandlerHandles a known path with a wrong methodSame idea, for the 405 case.
r.StrictSlash(true)Redirects between /x and /x/Sends a 301 to the canonical form rather than answering both.
route.URL(pairs...)Builds a URL from a named routeName a route with .Name("task"), then build links from it instead of pasting paths.

Subrouters group everything under a prefix, which is how an API gets a version without repeating it on every line.

api := r.PathPrefix("/api/v1").Subrouter()
api.Use(requireToken)

api.HandleFunc("/tasks", listTasks).Methods(http.MethodGet)   // /api/v1/tasks
api.HandleFunc("/tasks/{id}", getTask).Methods(http.MethodGet)

Middleware is a function that takes a handler and returns one. Anything you want on every request goes here.

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}

r.Use(logging)

Finding the caller's IP address

r.RemoteAddr is the address the connection came from, as host:port. Behind a proxy or a load balancer it is the proxy's address, and the original caller sits at the front of X-Forwarded-For.

// clientIP works out who sent a request.
func clientIP(r *http.Request) string {
    if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
        // "client, proxy1, proxy2" — the client is first.
        return strings.TrimSpace(strings.Split(fwd, ",")[0])
    }

    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return r.RemoteAddr
    }
    return host
}

Anyone can set X-Forwarded-For on a request they send you, so trust it only when the connection came from a proxy you run. Treat it as spoofable everywhere else.