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.
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.
| Method | What it means | Notes |
|---|---|---|
| GET | Read a resource | Safe and idempotent. Never changes anything on the server, so it can be cached, retried, and prefetched. Carries no body. |
| POST | Create a resource, or submit work | Neither 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. |
| PUT | Replace a resource entirely | Idempotent. 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. |
| PATCH | Change part of a resource | Not idempotent in general. The body describes the change rather than the whole object. |
| DELETE | Remove a resource | Idempotent. Deleting something already gone is still a success in most designs, answered with 204 or 404. |
| HEAD | The headers of a GET, with no body | Used to check whether something exists, or how big it is, without downloading it. |
| OPTIONS | Ask what a resource allows | Answers 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.
| Operation | Request | What comes back |
|---|---|---|
| Create | POST /tasks | Body carries the new object. Answer 201 with the created object and where it lives. |
| Read | GET /tasks, GET /tasks/1 | One path for the collection, one for a member. Answer 200, or 404 when there is no such member. |
| Update | PUT /tasks/1 | Body carries the whole replacement. Answer 200 with the stored result. |
| Delete | DELETE /tasks/1 | No body either way. Answer 204 No Content. |
Status codes
The first digit is the whole story. The other two narrow it down.
| Range | Meaning | How to read it |
|---|---|---|
| 1xx | Informational | The request was received and work continues. You will rarely write one. |
| 2xx | It worked | The request was received, understood, and accepted. |
| 3xx | Look somewhere else | Further action is needed, usually following a Location header. |
| 4xx | The caller got it wrong | Bad syntax, missing permission, or a resource that is not there. Repeating the same request gives the same answer. |
| 5xx | The server got it wrong | The 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.
| Code | Go constant | When to send it |
|---|---|---|
| 200 OK | http.StatusOK | The default success. A GET, a PUT, or any request whose answer has a body. |
| 201 Created | http.StatusCreated | A POST made something. Send a Location header pointing at it. |
| 204 No Content | http.StatusNoContent | It worked and there is nothing to send back. The usual answer to DELETE. Write no body at all. |
| 301 Moved Permanently | http.StatusMovedPermanently | This URL is retired. Caches and search engines update their records. |
| 302 Found | http.StatusFound | A temporary redirect. Keep using the original URL. |
| 304 Not Modified | http.StatusNotModified | The copy the caller already has is current. Answers a conditional GET. |
| 400 Bad Request | http.StatusBadRequest | The server could not parse it. Malformed JSON, a path parameter that is not a number. |
| 401 Unauthorized | http.StatusUnauthorized | No credentials, or credentials that failed. The name is misleading: it means unauthenticated. |
| 403 Forbidden | http.StatusForbidden | The server knows who you are and you still may not do this. |
| 404 Not Found | http.StatusNotFound | No resource at that path. |
| 405 Method Not Allowed | http.StatusMethodNotAllowed | The path exists, the method does not apply. Gorilla Mux sends this on its own once a route declares its methods. |
| 409 Conflict | http.StatusConflict | The request fights the current state. A duplicate unique key, an edit against a stale version. |
| 422 Unprocessable Entity | http.StatusUnprocessableEntity | The syntax parsed and the content is still wrong. A required field left empty. |
| 429 Too Many Requests | http.StatusTooManyRequests | Rate limited. Send a Retry-After header. |
| 500 Internal Server Error | http.StatusInternalServerError | The catch-all failure. Log the detail, send the caller something vague. |
| 502 Bad Gateway | http.StatusBadGateway | A server upstream of this one returned garbage. |
| 503 Service Unavailable | http.StatusServiceUnavailable | Overloaded or down for maintenance. Temporary by definition. |
| 504 Gateway Timeout | http.StatusGatewayTimeout | A server upstream took too long to answer. |
Headers worth knowing
| Header | What it carries | Notes |
|---|---|---|
| Content-Type | What the body is | application/json, text/html; charset=utf-8. Set it before you write the body or Go guesses from the first bytes. |
| Content-Length | How many bytes the body has | Go sets it for you when the whole body is buffered. |
| Accept | What the caller wants back | The client's side of Content-Type. |
| Authorization | Credentials | Bearer <token> for most APIs. |
| Location | Where the thing is now | Pairs with 201 after a create, and with every 3xx. |
| X-Forwarded-For | The original caller's address | Added by a proxy. The first entry is the client, the rest are hops. Trust it only from a proxy you run. |
| User-Agent | Who is calling | Set one on any client you write, so the server's logs name your program. |
| Retry-After | When to come back | Seconds, 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/jsonA 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) // 201A 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/muxr := 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
}
// ...
}| Call | What it does | Notes |
|---|---|---|
| mux.NewRouter() | Makes a router | Satisfies http.Handler, so it goes straight into http.ListenAndServe or an http.Server. |
| r.HandleFunc(path, fn) | Registers a handler for a path | Returns a *mux.Route, so the matchers below chain onto it. |
| .Methods(...) | Restricts a route to given methods | Two routes can share a path and split by method. Anything else on that path gets 405. |
| {id} | Captures a path segment | Matches one segment and stores it under that name. |
| {id:[0-9]+} | Captures a segment matching a pattern | The part after the colon is a regular expression, so a non-numeric id never reaches the handler. |
| mux.Vars(r) | Reads what the path captured | Returns 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 header | Patterns capture too, so {sub}.example.com puts the subdomain in Vars. |
| r.PathPrefix(p) | Matches everything under a prefix | The usual way to mount a file server or an API version. |
| .Subrouter() | Groups routes under a prefix | Middleware and matchers set on the subrouter apply only to its routes. |
| r.Use(mw) | Wraps every route on the router | A mux.MiddlewareFunc takes an http.Handler and returns one. Logging, auth, and recovery go here. |
| r.NotFoundHandler | Handles unmatched paths | Set it to send your own JSON 404 instead of the plain-text default. |
| r.MethodNotAllowedHandler | Handles a known path with a wrong method | Same 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 route | Name 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.