Skip to content

feat: sticky session routing via X-Session-ID header#1998

Open
lichengzhe wants to merge 2 commits intorouter-for-me:mainfrom
lichengzhe:feat/sticky-session
Open

feat: sticky session routing via X-Session-ID header#1998
lichengzhe wants to merge 2 commits intorouter-for-me:mainfrom
lichengzhe:feat/sticky-session

Conversation

@lichengzhe
Copy link

Summary

  • Route requests with the same X-Session-ID header to the same auth account, leveraging the existing pinnedAuthID mechanism
  • Bindings expire after 1 hour and are automatically cleared on 429/5xx errors for failover
  • No breaking changes — requests without the header behave exactly as before

Why

In multi-account setups, the same logical conversation can bounce between different auth accounts across requests. This causes issues with providers that track conversation state per-account. Sticky sessions give clients a simple opt-in mechanism to keep a session pinned to one account.

How

  • New stickyStore (in-memory, sync.RWMutex) with TTL-based expiration in sdk/cliproxy/auth/sticky.go
  • Integrated into both executeMixedOnce and executeStreamMixedOnce — resolves binding before pick loop, binds on success, clears on 429/5xx
  • Explicit pinned_auth_id takes precedence over sticky binding
  • Periodic cleanup piggybacked on existing autoRefresh ticker (~every 5 min)
  • X-Session-ID header extracted in handler metadata layer (sdk/api/handlers/handlers.go)

Files Changed

File Change
sdk/cliproxy/auth/sticky.go New — stickyStore implementation
sdk/cliproxy/auth/sticky_test.go New — 7 unit tests
sdk/cliproxy/auth/conductor.go Integrate sticky routing into execute paths
sdk/cliproxy/executor/types.go Add StickySessionMetadataKey constant
sdk/api/handlers/handlers.go Extract X-Session-ID header into metadata

Test plan

  • go build ./cmd/server — compiles cleanly (Go 1.26)
  • go test ./sdk/cliproxy/auth/ -run TestSticky — 7/7 pass
  • go test ./sdk/cliproxy/auth/ — all auth tests pass (no regression)
  • Manual: curl -H "X-Session-ID: test-123" verifies routing affinity
  • CI: pr-test-build.yml auto-triggered

🤖 Generated with Claude Code

Route requests with the same X-Session-ID to the same auth account,
leveraging the existing pinnedAuthID mechanism. Bindings expire after
1 hour and are cleared on 429/5xx errors for automatic failover.

- New stickyStore (in-memory, sync.RWMutex) with TTL-based expiration
- Integrated into executeMixedOnce and executeStreamMixedOnce
- Explicit pinned_auth_id takes precedence over sticky binding
- Periodic cleanup piggybacked on existing autoRefresh ticker
- X-Session-ID header extracted in handler metadata layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a sticky session routing mechanism to improve consistency in multi-account setups. By allowing clients to opt-in via an X-Session-ID header, requests can be consistently routed to the same authentication account, preventing issues where conversational state might be lost due to requests bouncing between different accounts. This enhances reliability for services that track conversation state per account.

Highlights

  • Sticky Session Routing: Implemented sticky session routing using the X-Session-ID header to ensure requests with the same ID are routed to the same authentication account, leveraging the existing pinnedAuthID mechanism.
  • Session Binding Expiration and Failover: Configured session bindings to expire after 1 hour and automatically clear upon receiving 429 (Too Many Requests) or 5xx (Server Error) responses, enabling failover to other authentication accounts.
  • No Breaking Changes: Ensured that requests without the X-Session-ID header continue to behave exactly as they did before, maintaining backward compatibility.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • sdk/api/handlers/handlers.go
    • Added logic to extract the X-Session-ID header from incoming requests.
    • Included the extracted session ID in the request execution metadata.
  • sdk/cliproxy/auth/conductor.go
    • Introduced a stickyStore field to the Manager for managing session-to-auth bindings.
    • Initialized the stickyStore when creating a new Manager.
    • Modified wrapStreamResult to accept and utilize the stickySessionID for error handling.
    • Updated executeStreamWithModelPool to pass stickySessionID to wrapStreamResult.
    • Integrated sticky session resolution into executeMixedOnce and executeStreamMixedOnce before the authentication pick loop, prioritizing explicit pinned_auth_id.
    • Added logic to bind a session to a successful authentication account and clear bindings on 429/5xx errors in both executeMixedOnce and executeStreamMixedOnce.
    • Implemented periodic cleanup for the stickyStore piggybacked on the existing auto-refresh ticker (approximately every 5 minutes).
    • Defined stickySessionTTL and stickySessionIDFromMetadata helper functions.
  • sdk/cliproxy/auth/sticky.go
    • Created a new stickyStore type to manage session-to-auth bindings.
    • Implemented Get, Set, Delete, Cleanup, and Len methods for the stickyStore to handle TTL-based expiration and garbage collection.
  • sdk/cliproxy/auth/sticky_test.go
    • Added comprehensive unit tests for the stickyStore to verify Set, Get, Delete, Overwrite, Cleanup, and Len functionalities, including expiration scenarios.
  • sdk/cliproxy/executor/types.go
    • Added StickySessionMetadataKey constant to identify sticky session IDs within execution metadata.
Activity
  • go build ./cmd/server compiled cleanly (Go 1.26).
  • go test ./sdk/cliproxy/auth/ -run TestSticky passed all 7 unit tests.
  • go test ./sdk/cliproxy/auth/ passed all authentication-related tests without regressions.
  • Manual testing is pending to verify routing affinity using curl -H "X-Session-ID: test-123".
  • CI pipeline pr-test-build.yml was auto-triggered.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@xkonjin xkonjin left a comment

Choose a reason for hiding this comment

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

Automated review focusing on bugs, security, and test coverage.
PR: feat: sticky session routing via X-Session-ID header (#1998)

Key observations:

  • No test files changed. Consider adding or updating tests for modified code paths.

Suggestions:

  • Add or update unit/integration tests for changed code paths.
  • Run dependency and static security scans (pip-audit/npm audit/Semgrep) and address findings.
  • Double check error handling and input validation on new code paths.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces sticky session routing using the X-Session-ID header, a valuable feature for multi-account setups, with a new stickyStore for session bindings and integrated logic in conductor.go. However, the current stickyStore implementation is vulnerable to memory exhaustion and denial-of-service (DoS) attacks due to its unbounded in-memory map. Additionally, the review identifies opportunities to improve maintainability and robustness by addressing code duplication and brittle logic in the conductor. It is recommended to implement a maximum size for the stickyStore and sanitize the X-Session-ID input header to mitigate these risks.

Comment on lines +38 to +41
func (s *stickyStore) Set(sessionID, authID string, ttl time.Duration) {
s.mu.Lock()
s.entries[sessionID] = stickyEntry{authID: authID, expiresAt: time.Now().Add(ttl)}
s.mu.Unlock()
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The stickyStore uses an unbounded in-memory map to store session-to-auth bindings. Since the sessionID is derived from the user-controlled X-Session-ID header, an attacker can send a large number of requests with unique session IDs to exhaust the server's memory.

Furthermore, the Cleanup function (lines 52-61) iterates over the entire map while holding a write lock. If the map grows very large due to an attack, this cleanup process will block all other operations on the stickyStore for an extended period, leading to a denial of service.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed. Added maxEntries cap (10k) — new entries are silently dropped at capacity. Overwriting existing keys still works. Added TestStickyStore_MaxEntries covering both cases.

Comment on lines +260 to +269
func stickySessionIDFromHeader(ctx context.Context) string {
if ctx == nil {
return ""
}
ginCtx, ok := ctx.Value("gin").(*gin.Context)
if !ok || ginCtx == nil || ginCtx.Request == nil {
return ""
}
return strings.TrimSpace(ginCtx.GetHeader("X-Session-ID"))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The X-Session-ID header is extracted and used directly as a key in the stickyStore without any length validation or sanitization. This untrusted input contributes to the potential memory exhaustion vulnerability in the stickyStore.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed. Added 256-char length limit — headers exceeding it return empty (no binding created).

Comment on lines +992 to +1001
// Sticky session: resolve session→auth binding before pick loop.
// An explicit pinned_auth_id takes precedence over sticky binding.
stickySessionID := stickySessionIDFromMetadata(opts.Metadata)
if stickySessionID != "" {
if _, alreadyPinned := opts.Metadata[cliproxyexecutor.PinnedAuthMetadataKey]; !alreadyPinned {
if boundAuth, found := m.sticky.Get(stickySessionID); found {
opts.Metadata[cliproxyexecutor.PinnedAuthMetadataKey] = boundAuth
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This block of logic for resolving sticky sessions is duplicated in executeStreamMixedOnce (lines 1160-1169). To improve maintainability and reduce code duplication, consider extracting this logic into a private helper method on the Manager.

For example:

func (m *Manager) resolveStickySession(meta map[string]any) string {
    stickySessionID := stickySessionIDFromMetadata(meta)
    if stickySessionID != "" {
        if _, alreadyPinned := meta[cliproxyexecutor.PinnedAuthMetadataKey]; !alreadyPinned {
            if boundAuth, found := m.sticky.Get(stickySessionID); found {
                meta[cliproxyexecutor.PinnedAuthMetadataKey] = boundAuth
            }
        }
    }
    return stickySessionID
}

You could then call this helper in both executeMixedOnce and executeStreamMixedOnce like so: stickySessionID := m.resolveStickySession(opts.Metadata).

Copy link
Author

Choose a reason for hiding this comment

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

Extracted resolveStickySession helper — both executeMixedOnce and executeStreamMixedOnce now call it.

Comment on lines +1205 to +1209
sc := 0
if se, ok := errors.AsType[cliproxyexecutor.StatusError](errStream); ok && se != nil {
sc = se.StatusCode()
}
if sc == 429 || sc >= 500 {
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This logic to extract the status code from an error appears to be a re-implementation of the existing statusCodeFromError helper function. Using the helper function directly would improve consistency and readability.

if sc := statusCodeFromError(errStream); sc == 429 || sc >= 500 {

Copy link
Author

Choose a reason for hiding this comment

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

Good catch — switched to statusCodeFromError(errStream).

Comment on lines +2424 to +2428
stickyCleanupCounter++
if stickyCleanupCounter >= 60 { // ~every 5 min at default 5s interval
m.sticky.Cleanup()
stickyCleanupCounter = 0
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation for triggering m.sticky.Cleanup() relies on a counter and a magic number 60. As the comment indicates, this is tied to the default 5s interval and is brittle if the interval parameter changes. A more robust approach would be to base the cleanup on elapsed time, making it independent of the ticker's interval.

For example:

// Before the loop
lastStickyCleanup := time.Now()
const stickyCleanupInterval = 5 * time.Minute

// Inside the loop
if time.Since(lastStickyCleanup) >= stickyCleanupInterval {
    m.sticky.Cleanup()
    lastStickyCleanup = time.Now()
}

Copy link
Author

Choose a reason for hiding this comment

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

Replaced counter with time.Since(lastStickyCleanup) — now interval-independent.

- Cap stickyStore at 10k entries to prevent memory exhaustion (DoS)
- Reject X-Session-ID headers longer than 256 chars
- Extract resolveStickySession helper to reduce duplication
- Use existing statusCodeFromError instead of manual extraction
- Replace counter-based cleanup with time.Since for interval independence
- Add TestStickyStore_MaxEntries covering capacity + overwrite behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@luispater luispater left a comment

Choose a reason for hiding this comment

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

Summary:
This is a useful feature, but I found two blocking correctness issues in the sticky routing behavior.

Key findings:

  • Blocking: sticky bindings are keyed only by session ID, not by provider/model. Reusing the same X-Session-ID across different providers or incompatible models can inject a stale pinned_auth_id and cause auth_not_found instead of falling back to normal selection.
  • Blocking: the streaming path pins the session before the stream has actually succeeded. executeStreamWithModelPool can return a wrapped error stream with err == nil for bootstrap/empty-stream failures, and executeStreamMixedOnce still stores the sticky binding. Those bindings are only cleared for 429/5xx, so empty-stream and transport/bootstrap failures can poison the session.

Test plan:

  • Ran: go test ./sdk/cliproxy/auth/...

Follow-ups:

  • Add coverage for cross-provider/model reuse of X-Session-ID.
  • Add coverage for bootstrap/empty-stream failures in the streaming path.

@luispater
Copy link
Collaborator

Additional concern: X-Session-ID is a very generic header name and is likely to collide with conventions used by gateways, proxies, SDKs, or application code outside this project.

I recommend switching to a namespaced header such as X-CLIProxyAPI-Session-ID to make ownership explicit and reduce the chance of external conflicts. If backward compatibility matters, X-Session-ID could be kept temporarily as a deprecated alias.

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.

3 participants