Skip to content

fix(cookie_manager): deduplicate cookies when request is retried#2498

Closed
ersanKolay wants to merge 6 commits into
cfug:mainfrom
ersanKolay:fix/cookie-dedup
Closed

fix(cookie_manager): deduplicate cookies when request is retried#2498
ersanKolay wants to merge 6 commits into
cfug:mainfrom
ersanKolay:fix/cookie-dedup

Conversation

@ersanKolay

Copy link
Copy Markdown
Contributor

Summary

Fixes #2442.

When a request is retried (e.g. after token refresh), loadCookies merges previousCookies from the existing header with savedCookies from the cookie jar without checking for duplicates. This causes cookies to accumulate on each retry attempt:

1st attempt: a=1; b=2
2nd attempt: a=1; b=2; a=1; b=2
3rd attempt: a=1; b=2; a=1; b=2; a=1; b=2

The fix filters out previousCookies entries whose names already exist in savedCookies, so jar cookies take precedence and no duplicates are produced.

The deduplication is done in loadCookies rather than getCookies to preserve the existing RFC 6265 behavior where same-name cookies with different paths are valid.

Test plan

  • Added no duplicate cookies on retry test that simulates the retry scenario
  • All existing tests pass (16/16)
  • dart format and dart analyze clean

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
@ersanKolay
ersanKolay requested a review from a team as a code owner March 11, 2026 00:07
@AlexV525

Copy link
Copy Markdown
Member

Is it possible to deduplicate cookies by checking their equality instead of rely on their name only? Will that (and current) consent any RFCs?

@AlexV525

Copy link
Copy Markdown
Member

I've check further with RFC 6265, which declared:

  1. If the cookie store contains a cookie with the same name, domain, and path as the newly created cookie:

    1. Let old-cookie be the existing cookie with the same name, domain, and path as the newly created cookie. (Notice that this algorithm maintains the invariant that there is at most one such cookie.)

    2. If the newly created cookie was received from a "non-HTTP" API and the old-cookie's http-only-flag is set, abort these steps and ignore the newly created cookie entirely.

    3. Update the creation-time of the newly created cookie to match the creation-time of the old-cookie.

    4. Remove the old-cookie from the cookie store.

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.
@ersanKolay

Copy link
Copy Markdown
Contributor Author

Good catch, thanks for referencing RFC 6265 Section 5.3.

I've updated the deduplication to use (name, domain, path) as the cookie identity, which aligns with the RFC.

One practical note: cookies parsed from the Cookie request header are plain name=value pairs — they don't carry domain or path metadata. So when comparing header cookies against saved cookies, the code falls back to name-only matching. This is safe because cookieJar.loadForRequest(uri) already scopes saved cookies to the request URI, meaning domain and path are implicitly matched.

For cookies that do carry full metadata (domain and path set), the deduplication uses the full (name, domain, path) identity per the RFC.

…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.
@ersanKolay

Copy link
Copy Markdown
Contributor Author

Minimal reproduction

import '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)
}

@AlexV525

Copy link
Copy Markdown
Member

Looks like the coverage is dropping.
Additionally, consider writing in a more readable/elegant pattern since the current modification makes the select query inlined too much.

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.
@ersanKolay

Copy link
Copy Markdown
Contributor Author

Updated — simplified the dedup logic and improved readability:

  • Removed the _isDuplicate helper and the domain/path branch. Per RFC 6265 Section 4.2, the Cookie header only carries name=value pairs without domain or path metadata, so that branch was unreachable and caused the coverage drop.
  • Extracted the previous-cookie filtering into a named variable instead of inlining everything in the spread.
  • Name-only matching is sufficient here since cookieJar.loadForRequest already scopes saved cookies to the request URI.

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
plugins/cookie_manager/lib/src/cookie_mgr.dart 🟢 98.57% 🟢 98.63% 🟢 0.06%
Overall Coverage 🟢 88.44% 🟢 88.46% 🟢 0.02%

Minimum allowed coverage is 0%, this run produced 88.46%

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Cookie header.
  • 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.

Comment on lines 149 to +163
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`.
@AlexV525

Copy link
Copy Markdown
Member

@ersanKolay Is there a chance you can address the above comments? Especially #2498 (comment)

@AlexV525

AlexV525 commented Jul 20, 2026

Copy link
Copy Markdown
Member

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.

@AlexV525 AlexV525 closed this Jul 20, 2026
AlexV525 added a commit that referenced this pull request Jul 21, 2026
…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>
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.

[Question][CookieManager] About override the old cookies by new cookies when they have the same keys?

3 participants