Chapter 05Lesson 02~185 minutes

HTTP Request Samplers, Defaults, Headers, Cookies, and Cache Managers: Guided Hands-On Workflow

Build one controlled HTTP journey against a local HTTP/1.1 fixture. The server exposes session cookies, cache headers, redirect chains, static embedded resources, connection-port evidence, request counters, and a bounded JSONL event log so each JMeter configuration choice can be observed independently.

GET/POSTCookie sessionCache hitRedirectsKeep-alive

Learning objectives

  • Run a disposable HTTP/1.1 fixture with bounded request logging and state counters.
  • Centralize scheme/host/port/implementation/timeouts in HTTP Request Defaults.
  • Add safe synthetic headers and observe them at the target.
  • Prove a server-issued session cookie is carried by the same virtual user.
  • Compare repeated GET behavior with and without HTTP Cache Manager.
  • Inspect redirect-chain, keep-alive, and embedded-resource effects without retaining unnecessary load-mode bodies.

1. Safety envelope

Hard ceiling: all mandatory traffic stays on http://127.0.0.1:8000. Baseline experiments use one thread × one loop. The cookie-isolation proof may use two threads × one loop. No experiment may exceed 24 target HTTP requests. Abort on target mismatch, unexpected 5xx/errors, or unsafe generator pressure.

2. Start the synthetic HTTP fixture

Save this as fixtures/http_fixture.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from pathlib import Path
import argparse
import json
import threading
import time

HOST = "127.0.0.1"
PORT = 8000

state_lock = threading.Lock()
active = 0
max_active = 0
total = 0
sessions_created = 0
by_path = {}
client_ports = set()
log_path = None

def record_event(event):
    if log_path is None:
        return
    line = json.dumps(event, sort_keys=True)
    with state_lock:
        with log_path.open("a", encoding="utf-8") as handle:
            handle.write(line + "\n")

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _start_request(self):
        global active, max_active, total
        path = urlparse(self.path).path
        with state_lock:
            active += 1
            max_active = max(max_active, active)
            total += 1
            by_path[path] = by_path.get(path, 0) + 1
            client_ports.add(self.client_address[1])
            request_no = total
            active_now = active
        return path, request_no, active_now

    def _finish_request(self, path, request_no, active_now):
        global active
        event = {
            "ts_ms": int(time.time() * 1000),
            "method": self.command,
            "path": path,
            "request_no": request_no,
            "active_at_start": active_now,
            "client_port": self.client_address[1],
            "connection": self.headers.get("Connection", ""),
            "x_lab_client": self.headers.get("X-Lab-Client", ""),
            "cookie": self.headers.get("Cookie", ""),
        }
        record_event(event)
        with state_lock:
            active -= 1

    def _send(self, status, body=b"", content_type="application/json", extra_headers=None):
        if isinstance(body, str):
            body = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        if extra_headers:
            for name, value in extra_headers:
                self.send_header(name, value)
        self.end_headers()
        if body:
            self.wfile.write(body)

    def _json(self, status, payload, extra_headers=None):
        body = json.dumps(payload, sort_keys=True).encode("utf-8")
        self._send(status, body, "application/json", extra_headers)

    def do_GET(self):
        path = urlparse(self.path).path

        if path == "/health":
            self._json(200, {"status": "ok"})
            return

        if path == "/stats":
            with state_lock:
                payload = {
                    "active": active,
                    "max_active": max_active,
                    "total": total,
                    "sessions_created": sessions_created,
                    "by_path": dict(by_path),
                    "unique_client_ports": len(client_ports),
                }
            self._json(200, payload)
            return

        path, request_no, active_now = self._start_request()
        try:
            if path == "/profile":
                cookie = self.headers.get("Cookie", "")
                session = ""
                for part in cookie.split(";"):
                    part = part.strip()
                    if part.startswith("session="):
                        session = part.split("=", 1)[1]
                if not session:
                    self._json(401, {"status": "missing_session"})
                else:
                    self._json(
                        200,
                        {
                            "status": "ok",
                            "session": session,
                            "client_port": self.client_address[1],
                            "x_lab_client": self.headers.get("X-Lab-Client", ""),
                        },
                    )

            elif path == "/cache":
                etag = '"ch05-cache-v1"'
                if self.headers.get("If-None-Match") == etag:
                    self._send(
                        304,
                        b"",
                        "application/json",
                        [
                            ("ETag", etag),
                            ("Cache-Control", "public, max-age=60"),
                        ],
                    )
                else:
                    self._json(
                        200,
                        {
                            "status": "ok",
                            "resource": "cache-v1",
                            "request_no": request_no,
                        },
                        [
                            ("ETag", etag),
                            ("Cache-Control", "public, max-age=60"),
                        ],
                    )

            elif path == "/redirect":
                self._send(
                    302,
                    b"",
                    "text/plain",
                    [("Location", "/final")],
                )

            elif path == "/final":
                self._json(
                    200,
                    {
                        "status": "ok",
                        "redirect_final": True,
                        "client_port": self.client_address[1],
                    },
                )

            elif path == "/page":
                body = """<!doctype html>
<html>
<head>
  <link rel="stylesheet" href="/asset.css">
  <script src="/asset.js"></script>
</head>
<body>
  <img src="/pixel.svg" alt="">
  <main id="app">Static HTML only</main>
</body>
</html>"""
                self._send(200, body, "text/html; charset=utf-8")

            elif path == "/asset.css":
                self._send(200, "body{font-family:sans-serif}", "text/css")

            elif path == "/asset.js":
                self._send(
                    200,
                    "document.getElementById('app').textContent='JS ran in a browser';",
                    "application/javascript",
                )

            elif path == "/pixel.svg":
                self._send(
                    200,
                    '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
                    "image/svg+xml",
                )

            elif path == "/echo":
                self._json(
                    200,
                    {
                        "status": "ok",
                        "method": "GET",
                        "client_port": self.client_address[1],
                        "x_lab_client": self.headers.get("X-Lab-Client", ""),
                        "cookie": self.headers.get("Cookie", ""),
                    },
                )

            else:
                self._json(404, {"error": "not_found", "path": path})
        finally:
            self._finish_request(path, request_no, active_now)

    def do_POST(self):
        global sessions_created
        path, request_no, active_now = self._start_request()
        try:
            length = int(self.headers.get("Content-Length", "0") or "0")
            body = self.rfile.read(min(length, 4096))
            if length > 4096:
                self._json(413, {"error": "payload_too_large"})
                return

            if path == "/session":
                with state_lock:
                    sessions_created += 1
                    session = f"session-{sessions_created:03d}"
                self._json(
                    200,
                    {
                        "status": "ok",
                        "session": session,
                        "received_bytes": len(body),
                        "client_port": self.client_address[1],
                        "x_lab_client": self.headers.get("X-Lab-Client", ""),
                    },
                    [
                        ("Set-Cookie", f"session={session}; Path=/; HttpOnly; SameSite=Lax"),
                        ("Cache-Control", "no-store"),
                    ],
                )
            elif path == "/echo":
                self._json(
                    200,
                    {
                        "status": "ok",
                        "method": "POST",
                        "received_bytes": len(body),
                        "content_type": self.headers.get("Content-Type", ""),
                        "x_lab_client": self.headers.get("X-Lab-Client", ""),
                    },
                )
            else:
                self._json(404, {"error": "not_found", "path": path})
        finally:
            self._finish_request(path, request_no, active_now)

    def log_message(self, format, *args):
        return

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--log", default="server-events.jsonl")
    args = parser.parse_args()
    log_path = Path(args.log).resolve()
    log_path.parent.mkdir(parents=True, exist_ok=True)
    log_path.write_text("", encoding="utf-8")
    print(f"fixture=http://{HOST}:{PORT}")
    print(f"event_log={log_path}")
    ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

Start it from the project root:

python fixtures/http_fixture.py --log results/server-events.jsonl

PowerShell uses the same Python command. Preflight in a second terminal:

curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent http://127.0.0.1:8000/stats

Expected initial stats show zero measured journey requests. /health and /stats are intentionally excluded from the fixture's measured counters.

3. Build the baseline JMeter tree

Test Plan — Chapter 05 HTTP Journey
└── Thread Group — 1 user × 1 loop
    ├── HTTP Request Defaults — HttpClient4 / http / 127.0.0.1 / 8000
    ├── HTTP Header Manager
    │   ├── Accept: application/json
    │   └── X-Lab-Client: devops-academy-ch05
    ├── HTTP Cookie Manager — standard policy
    ├── HTTP Cache Manager — Cache-Control/Expires enabled
    ├── POST Session — /session
    ├── GET Profile — /profile
    ├── GET Cache First — /cache
    ├── GET Cache Second — /cache
    ├── GET Redirect — /redirect (Follow Redirects ON)
    ├── GET Page — /page (embedded resources OFF for baseline)
    └── View Results Tree — AUTHORING DEBUG ONLY

Save the authoring copy as plans/http-debug.jmx. Create a second plans/http-load.jmx with View Results Tree disabled before CLI execution.

4. Configure HTTP Request Defaults

  • Implementation: HttpClient4
  • Protocol: http
  • Server Name or IP: 127.0.0.1
  • Port: 8000
  • Connect Timeout: 1000 ms
  • Response Timeout: 2000 ms

Leave sampler server/protocol/port blank so the inherited destination is visible in one place. Keep sampler paths explicit.

5. Add safe synthetic headers

Add one HTTP Header Manager under the Thread Group:

Accept: application/json
X-Lab-Client: devops-academy-ch05

For the POST Session sampler, add a narrow Header Manager child only for Content-Type: application/json. This demonstrates broad defaults plus a request-specific override without copying browser-only headers.

8. Compare repeated GETs with Cache Manager

Add one HTTP Cache Manager at Thread Group scope and enable Use Cache-Control/Expires header when processing GET requests. Set Max Number of elements to 100 for this tiny lab.

Run GET Cache First and GET Cache Second, both /cache. The server returns Cache-Control: public, max-age=60 and an ETag.

Prediction: the first GET contacts the fixture and populates that thread's cache. The second GET may be satisfied by the fresh JMeter cache without another server request. Confirm with fixture by_path["/cache"] and the event log. If your exact client/cache path results in a validation request instead, inspect conditional headers/304 behavior; preserve the observation rather than forcing an expected outcome.

Then disable Cache Manager and rerun into a new evidence directory. Both GET samplers should now contact the target. This is a one-factor comparison.

9. Inspect Follow Redirects

Configure GET Redirect path /redirect, Follow Redirects = on, Redirect Automatically = off. In the bounded View Results Tree run, inspect the parent and redirect/final child results.

The target event log should show /redirect followed by /final. The parent sampler's elapsed time includes the redirect chain. Do not compare that parent directly with a one-hop endpoint and call the difference “application handler latency.”

10. Observe connection reuse without assuming it

The fixture returns client_port in Session/Profile/Final JSON and logs it for every measured request. With HttpClient4 and Use KeepAlive enabled, sequential same-thread requests can reuse a persistent connection, often showing the same client port.

Record what happened on your run. Then copy the one-thread debug plan and disable Use KeepAlive on Session/Profile. Rerun only those two requests. A changed/more numerous client-port pattern is connection evidence; it is not a universal guarantee because pools/server behavior can vary.

11. Compare HTML-only versus embedded-resource retrieval

Baseline GET Page uses /page with Retrieve All Embedded Resources off. The target should record only /page.

Copy the plan and enable Retrieve All Embedded Resources from HTML Files. Keep concurrent pool off for deterministic teaching. The fixture page references /asset.css, /asset.js, and /pixel.svg, so the target log should now show those additional HTTP requests.

Browser boundary: although JMeter downloads /asset.js, the JavaScript never executes. The HTML text remains protocol payload to JMeter; there is no DOM mutation or rendered page.

12. Run the lean CLI copy

For load evidence, disable View Results Tree and use one thread × one loop. Keep only the samplers needed for the specific experiment. Use unique artifacts:

mkdir -p results/run-001
jmeter -n   -t plans/http-load.jmx   -l results/run-001/results.jtl   -j results/run-001/jmeter.log   -Jjmeter.save.saveservice.print_field_names=true   -Jjmeter.save.saveservice.connect_time=true   -Jjmeter.save.saveservice.response_data=false   -Jjmeter.save.saveservice.response_data.on_error=false   -Jjmeter.save.saveservice.requestHeaders=false   -Jjmeter.save.saveservice.responseHeaders=false   -Jjmeter.save.saveservice.samplerData=false

PowerShell:

New-Item -ItemType Directory -Force results\run-001 | Out-Null
jmeter.bat -n `
  -t plans\http-load.jmx `
  -l results\run-001\results.jtl `
  -j results\run-001\jmeter.log `
  -Jjmeter.save.saveservice.print_field_names=true `
  -Jjmeter.save.saveservice.connect_time=true `
  -Jjmeter.save.saveservice.response_data=false `
  -Jjmeter.save.saveservice.response_data.on_error=false `
  -Jjmeter.save.saveservice.requestHeaders=false `
  -Jjmeter.save.saveservice.responseHeaders=false `
  -Jjmeter.save.saveservice.samplerData=false

CSV JTL does not store response bodies, and current defaults already leave request/response headers and sampler data disabled. The explicit flags document the chapter's privacy/performance intent.

13. Analyze the lean JTL

import csv
import math
import sys
from collections import Counter
from pathlib import Path

path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/run-001/results.jtl")
rows = list(csv.DictReader(path.open(encoding="utf-8")))

if not rows:
    raise SystemExit("No sample rows found")

required = {"timeStamp", "elapsed", "label", "success", "responseCode", "bytes"}
missing = required.difference(rows[0])
if missing:
    raise SystemExit(f"Missing JTL columns: {sorted(missing)}")

def percentile(values, p):
    values = sorted(values)
    rank = max(1, math.ceil((p / 100) * len(values)))
    return values[rank - 1]

elapsed = [int(r["elapsed"]) for r in rows]
success = [r["success"].lower() == "true" for r in rows]
labels = Counter(r["label"] for r in rows)
codes = Counter(r["responseCode"] for r in rows)

print(f"samples={len(rows)}")
print(f"failures={sum(not ok for ok in success)}")
print(f"p50_elapsed_ms={percentile(elapsed, 50)}")
print(f"p95_elapsed_ms={percentile(elapsed, 95)}")
print(f"total_bytes={sum(int(r['bytes']) for r in rows)}")
print(f"labels={dict(labels)}")
print(f"response_codes={dict(codes)}")
if "connect" in rows[0]:
    print(f"max_connect_ms={max(int(r['connect']) for r in rows)}")

Use the server event log—not response-body retention—to prove cookie/cache/redirect/embedded-resource behavior in the load copy.

14. Challenge: stateful web journey or stateless API?

You are testing a JSON API whose clients send a fresh bearer token on each request and the service explicitly does not use cookies or client caching. Should you add Cookie and Cache Managers because browsers usually have them?

No. Model the protocol/session behavior that actually exists. Cookie/cache managers are useful only when those semantics are part of the workload question.

Knowledge check

What proves cookie persistence in this lab without saving response bodies in the load JTL?

Why use one broad Header Manager plus a narrow Content-Type manager?

What target evidence suggests a fresh Cache Manager satisfied the second /cache GET locally?

Why keep concurrent embedded-resource retrieval off in the beginner experiment?

What does the server client_port observation prove?

Next lesson

Choose HTTP realism deliberately

Lesson 3 compares reusable defaults, stateful and stateless traffic, redirect models, embedded-resource retrieval, connection reuse, and response retention by maintainability, validity, privacy, generator cost, and CI portability.

Official references and version notes

  • Component Reference — current HTTP Request, HTTP Request Defaults, HTTP Header Manager, HTTP Cookie Manager, HTTP Cache Manager, redirect, keep-alive, and embedded-resource semantics.
  • Elements of a Test Plan — scope and execution architecture around samplers and configuration elements.
  • Properties Reference — current CSV/JTL save fields and defaults such as response/request header retention and connect time.
  • Functions and Variables — thread-local runtime values and function boundaries.
  • Getting Started — GUI authoring/debugging versus CLI load execution.
  • Best Practices — lean load generation and result/listener guidance.
  • Apache JMeter downloads — current production release and release-specific Java requirement.
Version and compatibility note

Version-sensitive statements were rechecked against current Apache JMeter primary documentation on 2026-09-04. The course baseline remains Apache JMeter 5.6.3 with a Java 17 JDK for labs and no third-party plugins; JMeter 5.6.3 itself requires Java 8+. HTTP labs explicitly select HttpClient4 in HTTP Request Defaults rather than relying on inherited/default implementation selection. Current docs state that response cookies handled by one HTTP Cookie Manager are stored per JMeter thread, while manually configured cookies are shared by threads. HTTP Cache Manager keeps a separate cache per virtual-user thread. Redirect Automatically hides intermediate redirects from JMeter and should only be used for GET/HEAD; Follow Redirects lets JMeter follow the chain and retain redirect child samples while the parent elapsed/bytes include the chain. Use KeepAlive is controllable with the Apache HttpComponents implementation. Embedded-resource retrieval parses HTML/CSS references and sends additional HTTP requests; it does not execute a browser DOM, render pixels, run application JavaScript, or reproduce Selenium/browser behavior.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.