JavaRush /Courses /Go SELF /http.Client and the request lifecycle

http.Client and the request lifecycle

Go SELF
Level 58, Lesson 0
Available

1. HTTP client and http.Client

When you first see HTTP in Go, there’s a natural temptation: “Oh, there’s http.Get(url) — so we can live happily ever after.” And sometimes you really can. But as soon as you have at least one of these items — “headers”, “methods other than GET”, “context”, “reusing settings”, “several requests in a row” — you very quickly end up with a proper client: http.Client.

The main idea of the lecture is simple and practical: an HTTP request is not only “get the status code”, it’s also about carefully closing resources, otherwise your application will start to degrade (and, what’s especially tricky, not necessarily right away).

http.Client: why it’s usually created once

http.Client is an object that can execute HTTP requests. At the “skeleton” level, it has a key method Do(req), which takes a pre-built *http.Request and returns a *http.Response.

It’s important to get used to this idea: http.Client is not a disposable napkin, but more like a reusable mug. You typically create it once and use it many times. Why is this common? Because inside the client and the transport there’s connection management that can be reused, saving time and resources (you don’t need to know the internals yet, but you do need to adopt the style).

Often you’ll see two variants:

  • http.DefaultClient — a ready-made “default” client.
  • &http.Client{...} — your own client when you need settings (timeouts are a separate topic; today we only mention them, we don’t implement them).

Mini example “we have a client”:


package main

import (
	"net/http"
)

func main() {
	client := &http.Client{}
	_ = client
}

The code is boring — but honest: the client is an object you can store in a struct and reuse.

2. Request and response: Request → Do → Response

Lifecycle: Request → Do → Response → Close

Now comes the central diagram of the lecture. I’m deliberately going to say it “like a mantra”, because it should become a reflex.

flowchart TD
    A[Built *http.Request] --> B["client.Do(req)"]
    B -->|err != nil| C[Transport error: network/DNS/connection]
    B -->|resp != nil| D[*http.Response]
    D --> E["Required: defer resp.Body.Close()"]
    E --> F[Read/decode Body]
    F --> G[Check StatusCode and data]

Here is the most important distinction: err and resp.StatusCode are different “axes” of the result.

err from Do is not equal to 404

When you call client.Do(req), Go returns two values:

  • resp *http.Response
  • err error

A beginner’s intuition is often: “if the server returned 404, then err != nil.” But in net/http that’s not the case: 404 is a normal HTTP response, it just means “not found”. In other words, the request reached the server, the server responded, the protocol was followed — so err can be nil, while the status code will be 404.

err usually means a problem on the “could not execute the request” level: DNS doesn’t resolve, connection couldn’t be established, TLS didn’t negotiate, the context was canceled, and so on.

Here’s a mini example that shows “err ≠ status code”:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	resp, err := http.DefaultClient.Get("http://localhost:8080/definitely-missing")
	if err != nil {
		fmt.Println("transport error:", err)
		return
	}
	defer resp.Body.Close()

	fmt.Println("status:", resp.StatusCode) // status: 404 (for example)
}

Notice two things. First, defer resp.Body.Close() is placed after checking err. Second, we read the status from resp.StatusCode, not by “looking for it” inside the error.

Why defer resp.Body.Close() must be placed strictly after checking err

A very typical beginner mistake is to write this:

resp, err := http.DefaultClient.Do(req)
defer resp.Body.Close() // ❌ dangerous
if err != nil {
	return err
}

The problem is that when err != nil, the value of resp can be nil. Then you’ll get a panic like “nil pointer dereference”, and that’s especially frustrating: you were trying to “do the right thing”, but you built yourself a trap.

The correct order is always: first check err, then set defer.

3. resp.Body: a stream that must be closed

What resp.Body is, and why it must be closed

http.Response has a field Body, and its type is not a string and not []byte. It’s a stream: io.ReadCloser.

In human terms: “it’s a thing you can read response bytes from, and then you need to close it”.

Why can’t you “just not close it”? Because behind Body there’s a real resource: a connection, a file descriptor, a buffer, internal runtime structures. If you don’t release them, you create resource leaks. Network leaks are especially unpleasant because at first “everything works”, and then suddenly things become slow/crash/stop opening new connections.

A real-life comparison: you can avoid returning a shopping cart at a supermarket. Once — nobody notices. But if everyone does that, the supermarket suddenly runs out of carts, and chaos begins.

The correct minimal HTTP call “skeleton”

This skeleton is worth learning by hand. You’ll repeat it hundreds of times, and that’s normal (like if err != nil — we’ve already gotten used to “errors are values”).

package main

import (
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest(http.MethodGet, "http://localhost:8080/health", nil)
	if err != nil {
		fmt.Println("build request error:", err)
		return
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("transport error:", err)
		return
	}
	defer resp.Body.Close()

	fmt.Println("status:", resp.StatusCode) // status: 200
}

There’s no JSON here yet, no timeouts, no context — and that’s good. Today we’re practicing the basic mechanics: make the request, get the response, close the body.

“But I’m not reading the body!” — still close it

Sometimes you only need the status code or headers, and the body seems unnecessary. For example, you call /health to check whether a service is alive.

Even then, the rule remains the same: if resp is obtained, Body must be closed.

package main

import (
	"fmt"
	"net/http"
)

func ping(url string) error {
	resp, err := http.DefaultClient.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

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

Yes, we don’t read the body. But we do close the resource.

Nuance: “close” and “read to the end” are not always the same thing

Now for a careful nuance, without going deep into the weeds. Sometimes (especially with keep-alive connections) it’s beneficial not only to close Body, but also to read it to the end or “drain it to nowhere”, so that the connection can be reused.

For this lecture, a practical rule is enough: if you’re not reading the body but want to be polite to connections, you can do a “drain”.

package main

import (
	"io"
	"net/http"
)

func drainAndClose(resp *http.Response) {
	_, _ = io.Copy(io.Discard, resp.Body)
	_ = resp.Body.Close()
}

This helper is useful when the body is small or you expect the server to have sent something small anyway. But again: the minimum that is always required is Close(). Drain is a “nice bonus” you’ll apply consciously.

4. Example: a client for the Task API

TaskClient as a wrapper over http.Client

So the code isn’t a set of abstract “walks to /health”, let’s continue with the course’s single domain: tasks (tasks). Earlier we built an API on the server, and now we start writing a client that can talk to it.

Let’s make a small TaskClient struct. For now, without timeouts and without context (those are separate lectures). Our goal is to properly organize the request lifecycle and the closing of resp.Body.

package main

import (
	"net/http"
)

type TaskClient struct {
	BaseURL string
	Client  *http.Client
}

func NewTaskClient(baseURL string) *TaskClient {
	return &TaskClient{
		BaseURL: baseURL,
		Client:  http.DefaultClient,
	}
}

Method Health()

Now let’s add a Health() method that checks whether the server is alive. It’s a simple operation, but very illustrative.

package main

import (
	"fmt"
	"net/http"
)

func (c *TaskClient) Health() error {
	resp, err := c.Client.Get(c.BaseURL + "/health")
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("health check failed: %d", resp.StatusCode)
	}
	return nil
}

Here you see the same mantra: got resp — close Body.

http.NewRequest and context inside the Request

In the next steps we’ll build requests in a more controlled way (headers, query, context). Today it’s enough for you to understand one constructive idea: in modern Go, the “life” of a request (in particular cancellation/deadlines) is tied to http.Request. This was done historically to avoid breaking compatibility: Client.Do accepts only *http.Request.

You’ve already seen http.NewRequest(...). There’s also http.NewRequestWithContext(...) — but we won’t dig deeply into context details in this lecture. What matters is the frame: a request is an object that carries everything needed to execute it, and the client “runs” that request.

Local debugging: print “what did we actually get”

When something goes wrong, a beginner often does a “magic dance”: change the URL, restart the server, get mad at life. It’s much more useful to learn to quickly look at simple fields of Response.

Here’s a short “diagnostic” snippet:

package main

import (
	"fmt"
	"net/http"
)

func debugResponse(resp *http.Response) {
	fmt.Println("status:", resp.StatusCode)          // status: 200
	fmt.Println("content-type:", resp.Header.Get("Content-Type"))
}

The idea is simple: headers are part of the response, and they often make it obvious that the server returned HTML instead of JSON, or text/plain instead of what you expected.

5. Typical mistakes when working with http.Client

Mistake No. 1: forgot to close resp.Body.
This is the most expensive mistake, because it might not show up immediately. One request will “pass”, ten will “pass”, and then the application starts behaving strangely: hangs, “can’t open a connection”, growing resource usage. The fix is simple: as soon as you get resp, immediately set defer resp.Body.Close().

Mistake No. 2: placing defer resp.Body.Close() before checking err.
This is the “but I did it right” trap. But if err != nil, then resp is often nil, and you crash with a panic. Train your hands to follow the order: if err != nil { return ... } and only then defer.

Mistake No. 3: thinking 404/500 will come via err.
In net/http, non-2xx is a normal protocol response, not a transport error. So the “server said ‘no’” branch should analyze resp.StatusCode, not expect a request execution error.

Mistake No. 4: trying to read resp.Body “twice”.
resp.Body is a stream. If you read it once, you can’t “rewind” it (unless you saved the bytes in advance). So you can’t do “first I’ll print the body to the log, and then I’ll decode again” from the same Body. You need a strategy: either read into a buffer once, or decode once.

Mistake No. 5: creating a new http.Client for every request.
Formally the code will work, but the style is bad: you lose the benefit of reusing the client and you complicate configuration. Keep the client in a struct (TaskClient), pass it as a dependency, and reuse it.

1
Task
Go SELF, level 58, lesson 0
Locked
Request status
Request status
1
Task
Go SELF, level 58, lesson 0
Locked
Response passport
Response passport
1
Task
Go SELF, level 58, lesson 0
Locked
Graceful closing
Graceful closing
1
Task
Go SELF, level 58, lesson 0
Locked
Health check
Health check
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION