JavaRush /Courses /Go SELF /http.NewRequestWithContext: headers, query params

http.NewRequestWithContext: headers, query params

Go SELF
Level 58, Lesson 2
Available

1. http.NewRequestWithContext and the role of context

If you are just getting started, it is easy to think: “Why do I need some NewRequestWithContext when there is http.Get(url)?” And it is true: http.Get is like “order pizza with one button.” But as soon as you need timeouts, headers, a non-standard method, or predictable URL building, that “one button” turns into a trap.

In Go, context (context.Context) has become the standard way to control the lifetime of an operation, and net/http baked it right into the request object. This was done to avoid breaking compatibility: Client.Do historically accepted *http.Request, and context “moved” inside the request struct rather than into the Do signature.

Key idea: you create a *http.Request, “attach” context, headers, and URL to it, and only then send it via client.Do(req).

Context: deadline and cancellation

People often try to think of context as “something for concurrency” (and yes, it is needed there too), but in an HTTP client it is primarily about time boundaries and cancelling an operation. Context carries a deadline/timeout and a cancellation signal; it can also carry request-scoped data (but you should do that carefully).

At a practical level, you can think of it like this: the request is an envelope you mail. The context is a timer: “if it does not arrive in 700 ms, stop trying to hunt down the mail carrier around the city,” plus a “cancel” button if the user changes their mind.

Mini example: create a request with a timeout at the context level.

package main

import (
	"context"
	"net/http"
	"time"
)

func buildReq(url string) (*http.Request, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
	defer cancel()

	return http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
}

Notice the defer cancel() discipline: even if the request succeeds, cancellation releases resources associated with the context (timers, etc.). This is not “superstition,” it is a useful habit.

Why context “lives” inside Request

If it seemed strange that client.Do does not take ctx as a separate parameter, that is a normal reaction. In the Go standard library, they decided to embed context into http.Request for compatibility so old code would not break. Therefore, the correct approach is to always create requests via NewRequestWithContext so the context is used “throughout the lifetime of the request.”

This is a good engineering decision: you “assemble” the request once (URL, headers, context), and then it behaves predictably.

2. Query params: do not glue strings, build via net/url

Query parameters are the part of the URL after ?, for example:

/api/v1/tasks?done=true&limit=10

Beginners often do this:

url := base + "/api/v1/tasks?done=" + done + "&limit=" + limit

And it works… until the first space, ampersand, or question mark inside a value, or until you introduce a parameter like contains="Go & coffee", and suddenly you get a URL that the server reads as “contains = Go ” and “ coffee = (what?)”.

In Go, net/url exists for this. It does two important things: correctly parses/builds URLs and correctly escapes values.

Base + path + query (no magic and no pain)

Building a URL is convenient when you have a base API address and a set of parameters. For this learning domain, we will imagine we have a Task API with the endpoint:

  • GET /api/v1/tasks — a list of tasks; you can filter it.

Let’s make a small function that builds the tasks list URL from a base URL and adds a query.

package main

import (
	"net/url"
	"strconv"
)

func buildListTasksURL(base string, done *bool, limit int) (string, error) {
	u, err := url.Parse(base)
	if err != nil {
		return "", err
	}
	u.Path = "/api/v1/tasks"

	q := u.Query()
	if done != nil {
		if *done {
			q.Set("done", "true")
		} else {
			q.Set("done", "false")
		}
	}
	if limit > 0 {
		q.Set("limit", strconv.Itoa(limit))
	}
	u.RawQuery = q.Encode()

	return u.String(), nil
}

There are a few subtle points here that directly affect the quality of your code.

First, u.Query() returns a copy of query values (type url.Values), and for changes to be applied back to the URL we explicitly assign u.RawQuery = q.Encode().

Second, q.Encode() will sort keys by itself and escape everything correctly. That is nice: less “manual work,” fewer bugs.

3. Create the request as an object: method + URL + body

Once the URL is built, it is time to make the request. And here we finally use the “hero of the episode” — http.NewRequestWithContext.

It is important to understand the signature:

http.NewRequestWithContext(ctx, method, url, body)

In this call, body is an io.Reader. That is, it is not a “string” and not “bytes,” but a data source that net/http will read the request body from (if it is needed at all).

Example: GET request for a task list

package main

import (
	"context"
	"net/http"
)

func newListTasksRequest(ctx context.Context, fullURL string) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
	if err != nil {
		return nil, err
	}
	return req, nil
}

Why do we pass a nil body? Because GET usually has no body (it can happen, but it is extremely rare and almost always a bad idea in an application-level API).

4. Headers: Accept, Content-Type, User-Agent

Headers are request metadata. If query params are “notes on the envelope” (visible right in the address), headers are a “service label” the postal system reads: what is inside, what format, who to consider the sender, and what we want to get back in the response.

In JSON APIs, you will most often see these headers:

Header Example Why
Accept
application/json
“I want the response in JSON”
Content-Type
application/json
“I am sending the body as JSON” (needed if there is a body)
User-Agent
tasks-cli/1.0
Client identification (useful for logs and diagnostics)
Authorization
Bearer <token>
or
ApiKey <key>
Authorization (if the API requires it)
X-Request-ID
...
Request correlation in logs (common in microservices)

In Go, headers are set via req.Header.Set(key, value).

Accept and Content-Type: what they actually mean

This confusion is so common that you can consider it an “HTTP initiation ritual.”

Accept is about the response: “what response format I can understand.”

Content-Type is about the request: “what format I am sending the request body in.”

A GET without a body usually requires only Accept.

package main

import "net/http"

func addJSONAccept(req *http.Request) {
	req.Header.Set("Accept", "application/json")
}

POST/PUT/PATCH with a JSON body usually requires both Accept and Content-Type.

package main

import "net/http"

func addJSONHeaders(req *http.Request) {
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
}

If you forget Content-Type when sending JSON, the server (if it is strict) may say: “I do not understand what you brought me, friend.”

User-Agent: a small string that saves hours

It feels like a minor detail: “why do I need this User-Agent.” But when you have server logs and five different clients (CLI, tests, integration, browser, neighboring service), User-Agent becomes a quick filter: who is making weird requests and why.

Let’s make a small helper:

package main

import "net/http"

func addUserAgent(req *http.Request, ua string) {
	if ua == "" {
		return
	}
	req.Header.Set("User-Agent", ua)
}

If ua is empty, we do not set the header. This is more convenient than setting an “empty default.”

5. Build the request end-to-end: query + headers

Now let’s assemble everything into one neat function that takes a context, a URL, and client settings, and returns a ready *http.Request.

Notice: we are not sending the request, we are only building it. This is good decomposition: building and executing are different responsibilities.

package main

import (
	"context"
	"net/http"
)

func buildListTasksReq(ctx context.Context, fullURL string, userAgent string) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Accept", "application/json")
	addUserAgent(req, userAgent)

	return req, nil
}

And this is where the Go style starts to show: small building-block functions that do one clear thing.

Debug printing: quickly see what you “assembled wrong”

A very typical situation: the server returns 400/404, and you are sure everything is correct. Until you print the final URL and see what you ended up with:

  • ...?done=true%20 (a space),
  • or you forgot to assign RawQuery,
  • or Path ate part of the path.

Mini example: print key parts of the request (without sending it).

package main

import (
	"fmt"
	"net/http"
)

func debugReq(req *http.Request) {
	fmt.Println("method:", req.Method)       // method: GET
	fmt.Println("url:", req.URL.String())    // url: https://...
	fmt.Println("accept:", req.Header.Get("Accept"))
}

This is “printf-debugging” in the best sense: cheap, fast, effective.

6. JSON body and Content-Type

Even though the lecture topic is headers and query params, one “classic” case is still worth showing: how Content-Type is related to NewRequestWithContext when you send a JSON body.

Imagine a POST /api/v1/tasks with body { "title": "..." }.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
)

type createTaskRequest struct {
	Title string `json:"title"`
}

func buildCreateTaskReq(ctx context.Context, fullURL, title string) (*http.Request, error) {
	b, err := json.Marshal(createTaskRequest{Title: title})
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(b))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
	return req, nil
}

What matters here is that bytes.NewReader(b) is exactly the io.Reader that NewRequestWithContext expects. And the Content-Type header becomes a promise to the server: “I sent you JSON.”

7. Diagram: how a request is logically assembled

When everything is mixed into one 50 line function, your head turns into a mess: where is the URL, where is the timeout, where are the headers. That is why it is useful to keep a simple mental model.

flowchart TD
    A["Function parameters: baseURL, filters, ctx"] --> B["Build URL: net/url + query.Encode()"]
    B --> C["Create Request: http.NewRequestWithContext"]
    C --> D["Set headers: Accept, User-Agent, Content-Type..."]
    D --> E["Send: client.Do(req)"]

This diagram suggests both the order of actions and the points where it is convenient to add debug printing.

8. Common mistakes

Mistake #1: building query via string concatenation.
At first it seems faster: append "?a=" + v. But as soon as the value contains a space, &, ?, =, or non-ASCII characters, the URL starts to “break” and the server reads something else entirely. net/url solves this with escaping and proper assembly.

Mistake #2: changing q := u.Query() and forgetting to assign u.RawQuery.
q.Set(...) looks like you are modifying the URL directly, but in practice without u.RawQuery = q.Encode() the changes stay “in the local variable.” Result: the request goes out without parameters, and then you argue with the monitor for half an hour.

Mistake #3: mixing up Accept and Content-Type.
When you set Content-Type: application/json on a GET with no body, it is usually not harmful, but it is weird. But when you do not set Content-Type on a POST with a JSON body, that is a real reason for the server to reject the request. Remember: Accept is about the response, Content-Type is about the request body.

Mistake #4: creating a request without context and hoping for “it will work out somehow.”
A request without context is technically possible (http.NewRequest), but then you more often forget about deadlines and cancellation. And requests without timeouts in network code are like saying “I will step out for a minute” without specifying from which country and for how many weeks.

Mistake #5: setting headers after client.Do(req).
Sometimes beginners think headers can be changed “on the fly,” but the request is already gone. Headers and URL must be fully assembled before calling Do, otherwise you are changing an object in memory, not what already flew over the network.

1
Task
Go SELF, level 58, lesson 2
Locked
Request draft
Request draft
1
Task
Go SELF, level 58, lesson 2
Locked
Search URL
Search URL
1
Task
Go SELF, level 58, lesson 2
Locked
Task list
Task list
1
Task
Go SELF, level 58, lesson 2
Locked
Create task
Create task
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION