fix(cookie_manager): deduplicate cookies when request is retried#2498
fix(cookie_manager): deduplicate cookies when request is retried#2498ersanKolay wants to merge 6 commits into
Conversation
When a request is retried with the same RequestOptions, loadCookies merges previousCookies from headers with savedCookies from the jar without deduplication, causing cookies to accumulate on each retry. Filter out previousCookies that already exist in savedCookies by name, so jar cookies take precedence and no duplicates are produced. Closes cfug#2442
|
Is it possible to deduplicate cookies by checking their equality instead of rely on their name only? Will that (and current) consent any RFCs? |
|
I've check further with RFC 6265, which declared:
The current deduplicate behavior doesn't seem to be aligned, correct? |
Use (name, domain, path) identity per RFC 6265 Section 5.3 for cookie deduplication. Cookies parsed from the Cookie header lack domain/path metadata, so those fall back to name-only matching against saved cookies which are already URI-scoped by cookieJar.loadForRequest.
|
Good catch, thanks for referencing RFC 6265 Section 5.3. I've updated the deduplication to use One practical note: cookies parsed from the For cookies that do carry full metadata (domain and path set), the deduplication uses the full |
…aths Verifies that cookies with the same name but different paths (per RFC 6265 Section 5.3 identity) are both preserved and not incorrectly deduplicated on retry.
Minimal reproductionimport 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
void main() async {
final cookieJar = CookieJar();
final dio = Dio()..interceptors.add(CookieManager(cookieJar));
// Server sets two cookies
await cookieJar.saveFromResponse(
Uri.parse('https://example.com'),
[
Cookie('a', '1')..path = '/',
Cookie('b', '2')..path = '/',
],
);
// Simulate what happens on retry:
// CookieManager.onRequest runs twice on the same RequestOptions.
final options = RequestOptions(baseUrl: 'https://example.com');
final mgr = CookieManager(cookieJar);
// First run: sets Cookie header to "a=1; b=2"
final cookies1 = await mgr.loadCookies(options);
options.headers[HttpHeaders.cookieHeader] = cookies1;
print('1st: $cookies1');
// → a=1; b=2
// Second run (retry): header already has "a=1; b=2"
final cookies2 = await mgr.loadCookies(options);
print('2nd: $cookies2');
// Before fix: a=1; b=2; a=1; b=2 (duplicates!)
// After fix: a=1; b=2 ✓
// RFC 6265 case: same name, different paths — both preserved
final jar2 = CookieJar();
await jar2.saveFromResponse(
Uri.parse('https://example.com/api/endpoint'),
[
Cookie('session', 'abc')..path = '/',
Cookie('session', 'xyz')..path = '/api',
],
);
final mgr2 = CookieManager(jar2);
final opts2 = RequestOptions(baseUrl: 'https://example.com/api/endpoint');
final cookies3 = await mgr2.loadCookies(opts2);
print('RFC 6265: $cookies3');
// → session=xyz; session=abc (both kept, different paths)
} |
|
Looks like the coverage is dropping. |
Removed unreachable domain/path branch — per RFC 6265 Section 4.2, the Cookie header only carries name=value pairs without domain or path metadata. Name-only matching is sufficient since saved cookies are already URI-scoped by cookieJar.loadForRequest. Extracted the previous-cookie filtering into a named variable for better readability.
|
Updated — simplified the dedup logic and improved readability:
|
Code Coverage Report: Only Changed Files listed
Minimum allowed coverage is |
There was a problem hiding this comment.
Pull request overview
This PR addresses cookie header growth across request retries by preventing duplicate cookies from being appended when CookieManager.onRequest runs multiple times on the same RequestOptions (e.g., retry after token refresh).
Changes:
- Deduplicate “previous” cookie-header cookies against cookies loaded from the jar when building the
Cookieheader. - Add tests that simulate retry behavior and validate same-name/different-path cookies are preserved.
- Update the cookie_manager changelog entry under “Unreleased”.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| plugins/cookie_manager/lib/src/cookie_mgr.dart | Filters header-derived cookies whose names already exist in jar-loaded cookies to prevent retry accumulation. |
| plugins/cookie_manager/test/cookies_test.dart | Adds retry-focused tests to prevent regressions and ensure RFC6265 path behavior remains intact. |
| plugins/cookie_manager/CHANGELOG.md | Notes the fix in the Unreleased section. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| final savedCookies = await cookieJar.loadForRequest(options.uri); | ||
| final previousCookies = | ||
| options.headers[HttpHeaders.cookieHeader] as String?; | ||
| // Per RFC 6265 Section 4.2, the Cookie header carries only name=value | ||
| // pairs without domain or path. The saved cookies are already scoped | ||
| // to the request URI by cookieJar.loadForRequest, so matching by name | ||
| // is sufficient to detect duplicates from a retried request. | ||
| final savedCookieNames = savedCookies.map((c) => c.name).toSet(); | ||
| final previousList = previousCookies | ||
| ?.split(';') | ||
| .where((e) => e.isNotEmpty) | ||
| .map((c) => _fromSetCookieValue(c)) | ||
| .whereType<Cookie>() // Use .nonNulls when the minimum SDK is 3.0. | ||
| .where((c) => !savedCookieNames.contains(c.name)) | ||
| .toList(); |
| ## Unreleased | ||
|
|
||
| *None.* | ||
| - Fix duplicate cookies when requests are retried with the same `RequestOptions`. |
|
@ersanKolay Is there a chance you can address the above comments? Especially #2498 (comment) |
|
Replaced by #2572. The replacement starts from the current main branch and addresses the unresolved first-request compatibility concern in #2498 (comment). It keeps caller-provided same-name cookies, rebuilds reused RequestOptions from the latest jar state, and adds regression coverage for jar deletion and origin changes. Thank you @ersanKolay for the original reproduction and implementation work. Closing this PR so review and CI can continue on the replacement. |
…ons (#2572) Closes #2442. Supersedes #2498. ## Summary Reusing the same `RequestOptions` instance sends it through `CookieManager` again with the Cookie header produced by the previous pass. Merging that generated header with the cookie jar again makes jar cookies accumulate on every pass. This change keeps weak per-`RequestOptions` state containing the original source header and the last header generated by the manager. When the current header still exactly matches that generated value, `loadCookies` rebuilds it from the original caller input and the latest jar state. Unlike the name-only filtering proposed in #2498, this preserves the established behavior where caller-provided and jar cookies with the same name can coexist. It also removes stale jar values after updates, deletion, or an origin change. The state is intentionally scoped to the same `CookieManager` and `RequestOptions` instance reported in #2442. A copied request or a different manager instance is a new identity and is outside this fix. ## Implementation details ### Why an `Expando` [`Expando<T>`](https://api.dart.dev/dart-core/Expando-class.html) associates private data with an object identity without adding a field to that object's class. The association does not keep its property value alive after the key object becomes inaccessible. It has been part of `dart:core` since before Dart 1.0, so this use does not raise the package's Dart 2.18 lower bound. `CookieManager` owns one instance: ```dart final Expando<_CookieHeaderState> _cookieHeaderStates = Expando(); ``` Each key is a `RequestOptions` object, and each value contains two immutable strings: the header supplied as the source of the merge and the header last generated by this manager. Keeping this bookkeeping outside `RequestOptions.extra` avoids reserving a user-visible key, exposing internal state to loggers, or copying an internal marker through `RequestOptions.copyWith`. ### State transitions | Interceptor pass | Current Cookie header | Source used for the merge | Result | | --- | --- | --- | --- | | First pass | Caller header or `null` | Current header | Source + cookies currently loaded from the jar | | Reused options, unchanged header | Exactly equals this manager's last output | Stored original source | Original source + freshly loaded jar cookies | | Header replaced between passes | Does not equal the last output | New current header | New source + freshly loaded jar cookies | For example: ```text first pass: source=provided=value, jar=session=old -> provided=value; session=old second pass after the jar changes to session=new: source=provided=value, jar=session=new -> provided=value; session=new ``` The old jar contribution is not treated as caller input, so it is neither duplicated nor retained after an update, deletion, or origin change. ### Interceptor ordering Request interceptors run in registration order. `CookieManager` reads both `options.uri` and the current Cookie header at its position in that chain, so interceptors that mutate either value must run before it: ```dart dio.interceptors ..add(uriOrCookieMutatingInterceptor) ..add(CookieManager(cookieJar)) ..add(interceptorThatDoesNotChangeUriOrCookies); ``` Changes made by a later interceptor have these consequences: | Later mutation | Effect when the same options are reused | | --- | --- | | Authorization, body, method, `extra`, or unrelated headers | Does not affect Cookie state matching | | Replace the complete Cookie header | The replacement becomes the source for the next merge | | Append, reorder, or reformat the generated Cookie header | It no longer exactly matches the stored output; the embedded old jar cookies can be treated as source cookies and merged again | | Change `baseUrl` or path | The current request may carry cookies selected for the URI observed before that change | The URI case is an ordering requirement for the current request, not only for retries. Moving a request from one origin or path to another after `CookieManager` has selected cookies can send cookies outside the scope for which they were loaded. ### Why not deduplicate cookie names or values A serialized Cookie request header contains `name=value` pairs but not the domain and path metadata that identified the cookies in the jar. The same request can legitimately contain multiple cookies with the same name, and dio also deliberately preserves a caller-provided cookie alongside a same-name jar cookie. Filtering by name, or by a reconstructed cookie identity, therefore cannot distinguish a cookie generated by the previous interceptor pass from one explicitly supplied by the caller. Tracking the exact previous output preserves that provenance without changing first-pass merge behavior. ### API and identity boundaries - No public API, request header precedence, or cookie ordering is changed. - No state is added to `RequestOptions.extra`. - Entries are scoped to one `CookieManager` and one `RequestOptions` object identity. - A `copyWith` result or a different manager instance is a new identity and intentionally starts with its current header as a new source. ## Verification Added six regression tests covering three sequential `Dio.fetch` calls with the same options, caller-provided same-name cookies, jar update and deletion, origin changes, caller header replacement, and state isolation between options. All six fail against `origin/main` and pass with this change. The `dio_cookie_manager` package passes 21 tests. Scoped Melos format and fatal-info analysis checks are clean. Local coverage records 74 of 75 executable lines in `cookie_mgr.dart` (98.67%), including all new state logic. Implementation and tests by Codex; independent authenticity and adversarial review passes by two Codex subagents. ### New Pull Request Checklist - [ ] I have read the [Documentation](https://pub.dev/documentation/dio/latest/) - [x] I have read the [Agent Contribution Guidelines](https://github.com/cfug/dio/blob/main/AGENTS.md) (required if any part of the change was produced with AI assistance) - [ ] I have searched for a similar pull request in the [project](https://github.com/cfug/dio/pulls) and found none *(not applicable: this replaces #2498)* - [x] I have updated this branch with the latest `main` branch to avoid conflicts (via merge from master or rebase) - [x] I have added the required tests to prove the fix/feature I am adding - [ ] I have updated the documentation *(not applicable: no public API or usage changes)* - [x] I have run the affected package tests without failures - [x] I have updated the `CHANGELOG.md` in the corresponding package ### Additional context and info The previous implementation in #2498 used RFC 6265 cookie-store identity rules to justify filtering request-header cookies. Cookie-store replacement and request-header merging are different operations; this PR avoids changing first-pass merge semantics. --------- Co-authored-by: Codex <noreply@openai.com>
Summary
Fixes #2442.
When a request is retried (e.g. after token refresh),
loadCookiesmergespreviousCookiesfrom the existing header withsavedCookiesfrom the cookie jar without checking for duplicates. This causes cookies to accumulate on each retry attempt:The fix filters out
previousCookiesentries whose names already exist insavedCookies, so jar cookies take precedence and no duplicates are produced.The deduplication is done in
loadCookiesrather thangetCookiesto preserve the existing RFC 6265 behavior where same-name cookies with different paths are valid.Test plan
no duplicate cookies on retrytest that simulates the retry scenariodart formatanddart analyzeclean