Skip to content

fix(network): preserve header case in the Dio driver - #151

Merged
anilcancakir merged 3 commits into
masterfrom
fix/dio-preserve-header-case
Sep 10, 2026
Merged

fix(network): preserve header case in the Dio driver#151
anilcancakir merged 3 commits into
masterfrom
fix/dio-preserve-header-case

Conversation

@anilcancakir

Copy link
Copy Markdown
Contributor

What

DioNetworkDriver's BaseOptions never set preserveHeaderCase, whose default is false. The IO adapter forwards that flag straight into HttpClientRequest.headers.set(...) (dio-5.9.2 lib/src/adapters/io_adapter.dart:109), which lowercases every header key on the wire. A case-sensitive consumer (ExoPlayer's User-Agent lookup, in a downstream app) silently receives the wrong value instead of an error, because the wrong casing is not a request failure, it just ships the player's default agent instead of the caller's.

Setting preserveHeaderCase: true on the driver's BaseOptions fixes it: the flag is verified at the resolved dio version (5.9.2, not 5.11.1) to default to false in both Headers (headers.dart:11) and BaseOptions (options.dart:151), and to be forwarded by the IO adapter at io_adapter.dart:109. The browser adapter (dio_web_adapter) has no equivalent: it writes headers via XMLHttpRequest.setRequestHeader, which is a browser API with no case-preservation hook at all, so this fix only reaches non-web targets. That is a platform limitation, not something this PR can close.

Behaviour change for existing consumers

Every header sent through Http now reaches the socket with the exact casing the caller passed, instead of being normalised to lower-case. HTTP header names are case-insensitive by spec, so no consumer should depend on the previous lower-casing, but a consumer parsing raw header bytes directly (rather than through a case-insensitive header map) would see a different string.

Testing

test/network/preserve_header_case_test.dart opens a loopback ServerSocket and asserts the raw request bytes contain User-Agent: Watchools/1.0 rather than user-agent: .... A dart:io HttpHeaders-based assertion on the receiving side cannot prove this: incoming headers are parsed without preserveHeaderCase, so they lowercase on receipt regardless of what the client actually sent on the wire.

Red phase confirmed before the fix: the same test failed with user-agent: Watchools/1.0 in the captured raw request.

flutter test (full suite, 1441 tests), dart analyze, dart format --set-exit-if-changed all clean on the touched files.

Not done in this PR

The CHANGELOG.md / doc/ / skills/ post-change sync this repo's CLAUDE.md calls for is not included; this PR's scope was fixed to the driver plus its test by the calling task, and the sync would need to be added before release regardless.

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kodizm

kodizm Bot commented Sep 9, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The one-line driver fix is correct and genuinely covered by the new test; the only real gap is the post-change sync this repo's CLAUDE.md requires for a consumer-visible behaviour change.

Major

lib/src/network/drivers/dio_network_driver.dart:34 — maintainability / repo rule: CLAUDE.md golden rule 5 plus the post-change sync section require a CHANGELOG.md entry under ## [Unreleased] in the same change set for any lib/ change, and this one changes wire behaviour for every existing Http consumer. Nothing lands in CHANGELOG.md, doc/, or skills/, so the change would ship silently; the platform caveat (web still lowercases, dio_web_adapter writes headers through XMLHttpRequest.setRequestHeader) is exactly the kind of thing that belongs in doc/ rather than only in the PR body.

Minor

test/network/preserve_header_case_test.dart:22 — correctness/flake: the response write and socket.close() sit inside the per-chunk socket.listen callback with no guard, unlike the rawRequest completer above them. If the request ever arrives in two TCP segments (larger header set once an auth or localisation interceptor is in the pipeline, or a request with a body if this test is later extended past get), the second event writes to an already-closed socket and the resulting exception surfaces as a test failure unrelated to header casing. Moving the write/close under the same contains('\r\n\r\n') condition removes the window. It is not flaky as written today - it passed for me on a plain GET.

Tests

test/network/preserve_header_case_test.dart covers the change directly, and it is the right shape: asserting on the raw socket bytes is the only thing that can prove outgoing casing, since dart:io lowercases header names on receipt. Coverage is get only, but every verb shares the same BaseOptions, so that is sufficient. I did not re-run the red phase, so the claim that it fails without the flag is unverified by me - the dio source is consistent with it.

Checks I ran

  • dart analyze on both touched files: No issues found!
  • dart format --set-exit-if-changed: Formatted 2 files (0 changed), exit 0
  • flutter test test/network/: All tests passed! (77 tests, includes the new one)
  • Verified the flag semantics against the version actually resolved by pubspec.lock, which is dio 5.11.1, not the 5.9.2 cited in the description: lib/src/headers.dart:11 still defaults preserveHeaderCase to false, lib/src/options.dart:727 likewise, and lib/src/adapters/io_adapter.dart:109 still forwards it into request.headers.set(...). So the fix holds at the resolved version too.
  • Checked the response side for collateral damage: io_adapter.dart:209 reads response headers via HttpHeaders.forEach, which hands back lowercase names regardless of this flag, and Headers keeps a caseInsensitiveKeyMap either way, so MagicResponse.headers keys are unchanged.
  • Grepped test/ for lowercase outgoing-header assertions ('authorization', 'content-type', 'accept-language', 'x-timezone'): no hits, so nothing in the suite depended on the old normalisation.

I read both changed files in full; nothing was listed as changed_without_diff or dropped for size. No full-suite run - the network directory is what this change can affect.

Dio's preserveHeaderCase defaults to false (dio-5.9.2 lib/src/options.dart:151),
and the IO adapter forwards that default straight into dart:io's
HttpClientRequest.headers.set (io_adapter.dart:109), which lowercases every
header key on the wire. A case-sensitive consumer (ExoPlayer's User-Agent
lookup) silently receives the wrong value instead of an error.

Set preserveHeaderCase: true on the driver's BaseOptions so a caller-supplied
key such as User-Agent reaches the socket unchanged.

This changes what goes on the wire for every existing consumer of magic's Http
facade: any header whose casing previously arrived normalised to lower-case
now arrives exactly as the caller wrote it. No consumer should depend on the
old behaviour, since HTTP header names are case-insensitive by spec, but a
consumer parsing raw header bytes rather than going through a case-insensitive
lookup would be affected.

Added test/network/preserve_header_case_test.dart, which opens a loopback
socket and asserts the raw request bytes carry 'User-Agent:' rather than
'user-agent:'; a HttpHeaders-based assertion cannot prove this because the
receiving side's HttpHeaders lowercases on parse regardless of what the
client sent.
The response write and the close sat in the per-chunk callback with no
guard, so a request arriving in two TCP segments would write to an
already closed socket and fail the test for a reason unrelated to header
casing. Both now sit behind the same completed-head condition as the
completer above them.
The driver change alters wire behaviour for every Http consumer, so it
needs the CHANGELOG entry the post-change sync requires. The platform
split belongs in doc/ rather than only in the PR body: casing is
preserved where the IO adapter runs, and the web still lowercases
because dio_web_adapter writes through XMLHttpRequest.setRequestHeader.
Response headers stay lowercase either way.
@anilcancakir
anilcancakir force-pushed the fix/dio-preserve-header-case branch from ae15036 to 3e65da4 Compare September 10, 2026 20:58
@anilcancakir

Copy link
Copy Markdown
Contributor Author

Both findings addressed, plus a rebase onto master (the branch was two commits behind).

Major, the post-change sync. CHANGELOG.md now carries a ### Fixed entry under ## [Unreleased]. The platform caveat moved into the docs rather than staying in the PR body: doc/basics/http-client.md gains a Header Casing section under Configuration (with the three Configuration subsections added to the TOC, since the anchor check runs both directions), and skills/magic-framework/references/http-network.md gets the short version beside the headers config option. SKILL.md stamped to v0.1.16.

Your reading of the web path is right, and I verified it against the resolved package rather than the docs: dio_web_adapter-2.1.0/lib/src/adapter.dart:52 writes every header through xhr.setRequestHeader(key, ...), and preserveHeaderCase appears nowhere in that package. So the flag is inert on web, and the docs say so with a NOTE rather than a footnote.

Minor, the socket write window. Fixed as suggested. The write and the close now sit behind the same completed-head guard as the completer, written as an early return so the two conditions are not duplicated:

if (rawRequest.isCompleted || !buffer.toString().contains('\r\n\r\n')) {
  return;
}

One correction on your side. pubspec.lock here resolves dio 5.9.2, not 5.11.1, so you were reading a lock file from a different tree. It does not change your conclusion: the flag has the same default and the same IO adapter forwarding in both versions, and you confirmed 5.11.1 independently, so the fix holds across the range.

Gates after the change: dart analyze on both touched code files clean, dart format no diff over 317 files, flutter test test/network/ green at 77 tests, and the workflow's own TOC anchor script run locally reports 44 pages and 0 issues.

@kodizm

kodizm Bot commented Sep 10, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Both earlier findings are settled and the new commits are documentation plus a test-server guard, so nothing new to flag: this looks ready.

What changed since my last pass: the socket handler now returns early unless the head has landed and the completer is unfilled (test/network/preserve_header_case_test.dart:22), and the post-change sync arrived - CHANGELOG.md ### Fixed, a Header Casing section in doc/basics/http-client.md with the three Configuration anchors added to the TOC, the paragraph in skills/magic-framework/references/http-network.md, and the SKILL.md stamp. No production code moved; lib/src/network/drivers/dio_network_driver.dart is byte-identical to what I reviewed.

The incremental diff also listed the vault and plugin-reference files. Those came in with the rebase onto master (83a260a, 820d316), not from this branch - this PR's own delta against master is the six files above, so the vault change is out of scope here and I did not review it.

Tests

Unchanged from my last pass and still the right shape: raw-socket assertion on the outgoing bytes is the only thing that can prove casing. The new guard closes the split-segment window exactly as described.

Checks I ran

  • Docs TOC/anchor script from .github/workflows/docs-link-check.yml, run locally: checked 44 doc pages; 0 issues - matches your number, and the three new Configuration anchors pass both directions.
  • dart format --set-exit-if-changed on the two touched code files: Formatted 2 files (0 changed), exit 0.
  • Verified the docs' claims against the resolved dio source rather than the prose. options.dart:331-351 seeds the request headers with caseInsensitiveKeyMap(baseOpt.headers), which is a LinkedHashMap with case-insensitive equality that stores keys as written, so config-map defaults keep their casing too, not just per-request ones - the doc's "in the config map above or per request" holds. io_adapter.dart:105-112 forwards the flag into request.headers.set. Response side confirmed unchanged: Headers keeps a case-insensitive map either way, so the "read them with a lowercase key" line is right.
  • Grepped lib/ for header-key literals: magic writes Content-Type, Authorization, Accept-Language, X-Timezone in canonical casing throughout, so nothing internal seeds a lowercase key that would win the casing over a caller's.
  • flutter test test/network/ and dart analyze: not re-run this pass. The test run hit my 500s ceiling twice (also with -j 1) and the analysis server crashed out with Bad state: The analysis server crashed unexpectedly - environment limits here, not signal about your change. I observed both green on the previous head, and the code file has not changed since.
  • On the dio version: this checkout resolves 5.11.1 on a fresh flutter pub get (no pubspec.lock is committed, which is correct for a package), so we each read a legitimately resolved version. Your point stands either way - same default, same adapter forwarding.

@anilcancakir

Copy link
Copy Markdown
Contributor Author

One correction, mine this time. I told you your dio version came from a different tree; it did not. pubspec.lock is gitignored here (.gitignore:27-28, per dart.dev's guidance that a library should not commit one), so the 5.9.2 I quoted was my own stale local resolve and 5.11.1 is what a fresh flutter pub get gives. Your reading was right and mine was the outdated one. It changes nothing about the fix, which we both verified holds at either version.

@anilcancakir
anilcancakir merged commit 71d3ae5 into master Sep 10, 2026
6 checks passed
@anilcancakir
anilcancakir deleted the fix/dio-preserve-header-case branch September 10, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant