Pin header merge order before any extract. Case folding is the usual silent break. Characterization tests freeze that contract first.
AI-assisted refactors often “simplify” HTTP clients. The edit looks smaller. The merge rules change. Production then sends the wrong Authorization line.
This workflow treats a messy client as a black box. You record merge outcomes. You change one helper after the pins hold.
Core conclusion
Do not extract merge_headers on vibe. Record three-layer precedence first. Then move ten lines. Leave the rest alone.
The method needs no vendor. A free coding model can draft tests. A free server can run them. The git pins remain the source of truth.
The failure this catches
Messy clients stack headers from three places. Session defaults sit at the bottom. Per-request kwargs sit in the middle. Auth plugins write last. Python dict update looks obvious. HTTP field names are case-insensitive. That mismatch is the bug.
A model often lowercases every key. Or it keeps the first duplicate. Or it joins Cookie with commas. Those three outcomes are not equivalent. Your characterization suite must name each one.
Artifact: a frozen merge matrix
Use this table as the contract. Fill cells from the live client. Do not fill them from memory.
| Layer A | Layer B | Field name pair | Expected winner | Multi-value rule |
|---|---|---|---|---|
| session | request |
Accept / accept
|
request value | last write wins |
| request | auth |
Authorization / authorization
|
auth value | last write wins |
| session | request |
Cookie / Cookie
|
request value | semicolon join |
| session | auth |
X-Request-Id / x-request-id
|
auth value | last write wins |
| request | request |
Accept-Encoding twice |
last kwargs value | last write wins |
Run the live function against each row. Store the exact mapping. That file is the golden merge log.
Step 1: isolate the messy entrypoint
Do not start inside helpers. Start at the public call the app uses. One function. One return. No network.
# client_legacy.py — characterization target, not the refactor
DEFAULTS = {"Accept": "application/json", "User-Agent": "reports/0.4"}
class Session:
def __init__(self, headers=None):
self.headers = dict(DEFAULTS)
if headers:
self.headers.update(headers)
def request(self, method, url, headers=None, auth_headers=None):
merged = {}
for src in (self.headers, headers or {}, auth_headers or {}):
for k, v in src.items():
merged[k] = v
return {"method": method, "url": url, "headers": merged}
That update loop is the defect. Keys keep original case. Later accept does not replace Accept. Two Accept lines can leave the client. Capture that now.
Step 2: record live outputs, not intentions
Write tests that call Session.request. Assert the mapping you observe. Do not assert the mapping you wish existed.
# test_header_merge_char.py
import json
from pathlib import Path
from client_legacy import Session
GOLDEN = Path("golden_header_merge.json")
CASES = [
{
"name": "accept_case_split",
"session": {},
"request": {"accept": "text/csv"},
"auth": {},
},
{
"name": "auth_overrides_authorization",
"session": {"Authorization": "Bearer session"},
"request": {"Authorization": "Bearer request"},
"auth": {"authorization": "Bearer plugin"},
},
{
"name": "cookie_same_case",
"session": {"Cookie": "a=1"},
"request": {"Cookie": "b=2"},
"auth": {},
},
]
def run_case(case):
sess = Session(case["session"])
out = sess.request("GET", "/export", case["request"], case["auth"])
return {"name": case["name"], "headers": out["headers"]}
def test_record_or_compare(tmp_path):
observed = [run_case(c) for c in CASES]
if not GOLDEN.exists():
GOLDEN.write_text(json.dumps(observed, indent=2, sort_keys=True))
raise AssertionError("recorded golden_header_merge.json; re-run")
expected = json.loads(GOLDEN.read_text())
assert observed == expected
First run writes the golden file. Second run freezes it. Commit both the test and the JSON. That commit is the safety rail.
Step 3: print the surprising keys
Add a one-shot probe. Humans miss mixed-case duplicates. The probe should not be clever.
python - <<'PY'
import json
from collections import defaultdict
from client_legacy import Session
s = Session({"Accept": "application/json"})
out = s.request("GET", "/", {"accept": "text/csv"}, {"AUTHORIZATION": "Bearer x"})
fold = defaultdict(list)
for k, v in out["headers"].items():
fold[k.lower()].append((k, v))
print(json.dumps(fold, indent=2))
PY
Expect two or three keys under accept and authorization. That print is the lesson. Models rarely volunteer it.
Step 4: bound where a free model may edit
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Use them as a constrained drafter. Do not let the model own the merge rule. Paste the golden JSON. Ask only for a helper that preserves every row.
A useful prompt stays narrow. Name the function. Name the forbidden changes. Name the test file.
Write merge_headers(session, request, auth) in Python.
Preserve golden_header_merge.json byte-for-byte.
Do not fold keys yet.
Do not join Cookie.
Keep last-write-wins with original case.
Do not edit test_header_merge_char.py.
Run the suite on the free server after the patch. If JSON drifts, reject the patch. The server is a runner. The golden file is the judge.
Step 5: extract one helper, nothing else
The smallest safe change is a move. Same loop. New name. Same tests.
def merge_headers(session_headers, request_headers, auth_headers):
merged = {}
for src in (session_headers, request_headers or {}, auth_headers or {}):
for k, v in src.items():
merged[k] = v
return merged
class Session:
def request(self, method, url, headers=None, auth_headers=None):
merged = merge_headers(self.headers, headers, auth_headers)
return {"method": method, "url": url, "headers": merged}
Stop here. Do not fold case in the same diff. Do not add Cookie joining. Do not rename kwargs. One behavior change per pull request.
Step 6: only then change folding, with new goldens
Case folding is a product change. Treat it as a new characterization set. Copy the JSON. Edit expectations in a second file. Keep the old file until callers migrate.
def fold_key(name):
return name.lower()
def merge_headers_folded(session_headers, request_headers, auth_headers):
merged = {}
original = {}
for src in (session_headers, request_headers or {}, auth_headers or {}):
for k, v in src.items():
fk = fold_key(k)
merged[fk] = v
original[fk] = k
return {original[k]: v for k, v in merged.items()}
That last-seen original case is a choice. Record it. Test it. Do not hide it in a comment.
def test_folded_auth_wins():
out = merge_headers_folded(
{"Authorization": "Bearer session"},
{"authorization": "Bearer request"},
{"AUTHORIZATION": "Bearer plugin"},
)
assert out == {"AUTHORIZATION": "Bearer plugin"}
Ship folded merge only after both suites pass. Old callers keep the unfolded goldens. New callers opt into the folded helper.
What the numbers on disk mean
Count golden rows, not feelings. Three cases are a start. Twelve rows covering case splits, Cookie, and auth override are a minimum for this client. If a row has two keys after fold, the live client is still split-brain. Do not delete that row to tidy JSON.
Track diff size the same way. The extract commit should touch two files. The folding commit should touch three. Larger diffs usually smuggle a second rule change.
Limitations
This harness does not send HTTP. It will not catch proxy rewrites. It will not catch httpx versus requests header canonicalization. It will not catch HTTP/2 lowercasing on the wire.
Golden JSON records observed mess. It does not prove the mess is correct. If production already depends on duplicate Accept keys, folding will break those callers. You need a traffic sample for that call.
Free model drafts can still invent join rules. Cookie joining is a separate RFC problem. Do not accept comma joins for Cookie. Do not accept semicolon joins for Accept. The table must say so.
Who should not use this
Skip this if you already have contract tests against a staging gateway. Skip this if the client is generated from OpenAPI and you do not own the generator. Skip this if headers carry secrets you cannot store in git. Redact those values before any golden file.
Do not use a free shared server for live credentials. Characterization belongs on fixtures. Auth tokens stay in a local secret store.
Checklist before the extract
- Public entrypoint identified. No network in the test path.
- Golden JSON committed. Second run is green.
- Probe printed mixed-case duplicates.
- Model, if used, received the freeze prompt.
- Extract diff moves one loop only.
- Folding waits for a second golden set.
Header merge looks like a five-line function. It is a protocol. Pin the protocol. Then move the lines. The tests stay useful if you never mention a coding product again.
If you run the harness on a free server, commit the JSON first. The model can propose the extract after the pins exist.
Top comments (0)