JavaRush /Courses /Go SELF /request_id: the X-Request-ID header and propagation via c...

request_id: the X-Request-ID header and propagation via context

Go SELF
Level 62, Lesson 1
Available

1. request_id and the X-Request-ID contract

When a server starts handling more than one request, life suddenly stops being linear. Logs get mixed up, errors show up “here and there,” and a user insists, “I definitely sent this exact JSON,” but you only see “internal error” and dead silence. At that moment, you really want a small marker that lets you tie together: the incoming request, all logs for it, and the response to the client. That’s exactly what request_id does.

request_id is a unique identifier for a specific HTTP request. It is usually passed in the X-Request-ID header. If the client sent X-Request-ID, we can accept it and “carry it along” downstream. If they didn’t send it, we generate one ourselves. In the response, we also return X-Request-ID so the client can say, “the request with id=… failed on your side,” and it’s not guesswork, but a concrete lead.

You can think of request_id like a shipping label on a package: packages (requests) look similar, boxes (JSONs) are the same, but the label lets you track a specific one.

Mini-contract for X-Request-ID

So that request_id doesn’t turn into “everyone does whatever they want,” it’s useful to pin down a simple contract. It’s not about cryptography and not about distributed tracing; it’s about basic HTTP service discipline.

The idea of the contract is: we accept X-Request-ID from the client if it is valid; otherwise, we generate a new one. Then we must return the final request_id back to the client in the response header and store it in context, so any code deeper in the stack can fetch it.

It’s convenient to see it in a table:

Situation What’s in the request What the server does What’s in the response
The client sent request_id
X-Request-ID: abc
Use
abc
(after minimal validation)
X-Request-ID: abc
The client did not send request_id (no header) Generate a new one (for example,
f3a2...
)
X-Request-ID: f3a2...
The client sent “garbage”
X-Request-ID:
(empty/spaces) or too long
Ignore it and generate a new one
X-Request-ID: f3a2...

Why do we even allow that a client might send “garbage”? Because the internet is a place where even headers sometimes look like they were written by a cat walking on a keyboard. So a minimum of checks is not paranoia; it’s hygiene.

2. Passing request_id via context

Why context and not a global variable

A naive idea looks like this: “let’s put request_id into a global variable, and everyone will be able to read it.” This is almost guaranteed to break. An HTTP server processes requests concurrently: different goroutines, different requests, different values. A global variable will be overwritten, and in the logs you’ll get the request_id from one request, and the error from another.

In Go, context.Context is the standard way to pass values related to a specific request across function boundaries and application layers. Handlers and their sub-operations often need request-scoped values, and context is designed exactly for passing such data across API boundaries.

One more important point: context.WithValue does not “modify” an existing context; it creates a new (derived) context. That’s why we always follow the pattern “take current → make new → put it back into the request.” And yes, in net/http the context is stored right inside http.Request: that’s how context was added to the HTTP API without breaking backward compatibility with old code.

Context keys: why you can’t write ctx.Value("request_id")

There’s a subtlety with context that often feels like “nitpicking for the sake of nitpicking” to beginners, but in practice it saves hours of pain. Technically, you can do this:

ctx = context.WithValue(ctx, "request_id", rid)

But a string key can easily conflict with someone else’s code. One package will store "request_id" as a string key, another package will also use "request_id", but maybe for a different type or a different meaning. As a result, ctx.Value("request_id") will return an unexpected value.

The correct practice is to use a typed key, usually as an empty struct that can’t be accidentally “reused” in another package.

An example key and helper functions (try to keep this in a single package, for example internal/httpapi/requestid):


package requestid

import "context"

type ctxKey struct{} // unique key type

func With(ctx context.Context, rid string) context.Context {
	return context.WithValue(ctx, ctxKey{}, rid)
}

func From(ctx context.Context) (string, bool) {
	rid, ok := ctx.Value(ctxKey{}).(string)
	return rid, ok
}

Note: the key is ctxKey{}, i.e., a value of a unique type. Even if another package defines type ctxKey struct{}, it will be a different type, and the keys won’t collide.

3. Generating request_id

Generating request_id is that rare moment when you don’t need to invent a distributed UUID generator. We only need a value that, with very high probability, won’t repeat across requests during the server’s lifetime.

For a learning project, there are two reasonable options:

  1. “Cheap and cheerful”: take time.Now().UnixNano() and convert it to a string. It’s simple, but values are predictable and could theoretically collide under very high load.
  2. “Good and safe”: take a few random bytes from crypto/rand and encode them as hex. This looks like an “adult” request_id, while the code is still short.

We’ll go with the second option. A small utility:

package requestid

import (
	"crypto/rand"
	"encoding/hex"
)

func New() string {
	var b [16]byte
	_, _ = rand.Read(b[:])          // in learning code, we simplify error handling
	return hex.EncodeToString(b[:]) // 32 hex characters
}

Here we intentionally keep the example short. In production code, the rand.Read error should be handled more carefully, but for this lecture it’s more important to understand the mechanics of request_id and context than to build a “zoo” of error-handling branches.

4. Request ID middleware and ordering in the chain

Middleware: read the header, put it into context, add it to the response

Middleware is the perfect place to implement request_id once for all handlers at once. Because request_id is a cross-cutting requirement, not logic for a specific endpoint.

Let’s build middleware that:

  1. takes rid from X-Request-ID if it exists and looks reasonable;
  2. otherwise generates a new one;
  3. puts rid into context;
  4. sets X-Request-ID in the response headers;
  5. passes control downstream.

Code (for example, internal/httpapi/mw/request_id.go):

package mw

import (
	"net/http"
	"strings"

	"example.com/taskapi/internal/httpapi/requestid"
)

const headerRequestID = "X-Request-ID"

func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rid := strings.TrimSpace(r.Header.Get(headerRequestID))
		if rid == "" || len(rid) > 64 {
			rid = requestid.New()
		}

		r = r.WithContext(requestid.With(r.Context(), rid))
		w.Header().Set(headerRequestID, rid)

		next.ServeHTTP(w, r)
	})
}

Three details here are critical.

First, strings.TrimSpace: even if the client sent " abc ", we normalize it. Second, the length limit: 64 characters is usually enough, and we definitely don’t need a “10 MB header.” Third, r = r.WithContext(...): if you forget this assignment, you’ll create a new context, but it won’t be attached anywhere, and lower layers won’t see it.

Why request_id middleware should be the outermost

Middleware order is not “aesthetics”; it’s logic. The outer middleware sees the request first and can prepare what everyone else needs. request_id is exactly such a case: it’s needed by logging, panic recovery, error formatting, and access checks (at least so denial logs also have the id).

If request_id middleware sits somewhere “inside,” then part of the code above it (i.e., the outer middleware) will run without request_id. And that is usually the code where request_id is needed most: the “top boundary” where you want to tie everything together.

Let’s imagine the chain like nesting dolls:

flowchart TD
    A[RequestID middleware samyy vneshniy] --> B[drugie middleware]
    B --> C[handler]

More concretely: request_id must appear before all other wrappers so they can fetch it from r.Context() and, if needed, add it to their logs/errors.

If you use a helper chain(...) that “wraps” handlers, then it’s logical to list request_id first, because the list is read “outside → inside.”

An example assembly (simplified, directly in main):

mux := http.NewServeMux()
// mux.HandleFunc(...)

h := chain(mux,
	mw.RequestID, // <- the outermost
	// mw.Auth(...),
	// mw.Recover,
)

_ = http.ListenAndServe(":8080", h)

The point is not “request_id must be first because that’s how it’s done,” but that the first middleware is your “entry into the coordinate system”: it must create the request identifier, and then all code will live in the same reality.

5. How to use request_id in handlers and JSON responses

Once request_id is in context, you can fetch it anywhere: in a handler, in a helper, in the application layer. The main thing is not to turn this into a “secret dependency”: the code should remain readable, so request_id is usually used at the boundary (for logs and for responses to the client), not inside “business math.”

Let’s make a small handler that returns JSON and adds request_id into the body purely for demonstration. This is not a mandatory rule (often a header is enough), but it’s easier to see.

package handlers

import (
	"encoding/json"
	"net/http"

	"example.com/taskapi/internal/httpapi/requestid"
)

func Ping(w http.ResponseWriter, r *http.Request) {
	rid, _ := requestid.From(r.Context())

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"ok":         true,
		"request_id": rid,
	})
}

If request_id middleware is wired correctly, rid will almost always be non-empty. If it’s empty somewhere, that’s a great signal that the middleware chain is assembled incorrectly, and request_id is not the outermost layer (or you forgot r = r.WithContext(...)).

6. Common mistakes

Mistake #1: created a new context but didn’t put it back into *http.Request.
The most common bug looks like this: you did ctx := requestid.With(r.Context(), rid), and then forgot r = r.WithContext(ctx). As a result, request_id “exists,” but only in your local variable, while other middleware and handlers keep living with the old context.

Mistake #2: request_id lives only in the response header but is not available from context.
Sometimes a developer sets w.Header().Set("X-Request-ID", rid) and thinks that’s it. But other layers (for example, logging) need r.Context(), because you are not required (and often can’t conveniently) “read back” what you already wrote into headers from the ResponseWriter.

Mistake #3: a string key in context.WithValue.
A key like "request_id" seems convenient at first, and then suddenly breaks when a second package appears with the same key. A typed key is literally two lines of code, but it eliminates an entire class of conflicts.

Mistake #4: request_id middleware is not the outermost, and part of the system doesn’t see it.
This shows up in a very nasty way: “sometimes request_id is there, sometimes it’s not.” As a rule, “sometimes” is when the request gets cut off by an early middleware (for example, access was denied), and request_id was created below. The fix is simple: request_id is the outermost layer, no debate.

Mistake #5: blindly accepting any X-Request-ID from the client.
If you accept the header as-is, you can get weird values: empty strings, spaces, huge strings. Minimal validation (TrimSpace, length limiting) is a good compromise between simplicity and common sense.

1
Task
Go SELF, level 62, lesson 1
Locked
Passing values in context
Passing values in context
1
Task
Go SELF, level 62, lesson 1
Locked
Honest identifier
Honest identifier
1
Task
Go SELF, level 62, lesson 1
Locked
Trace logs
Trace logs
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION