Skip to content

Commit 5c1dfbc

Browse files
committed
feat(session): allow S3SessionManager to reuse a pre-built S3 client
Adds a new optional `s3_client` keyword argument to `S3SessionManager`. When provided, the manager reuses the caller's boto3 S3 client directly instead of constructing a new `boto3.Session` and S3 client, avoiding the per-instance HTTP connection pool + endpoint discovery overhead. Callers also retain full control of the client (credentials, retry config, custom endpoints). When `s3_client` is supplied, the existing `boto_session`, `boto_client_config`, and `region_name` parameters are ignored (documented in the docstring). Backward-compatible: callers that do not pass `s3_client` get the existing behavior unchanged, including the `strands-agents` user-agent tag on the auto-built client. Tests added (moto-backed): - test_s3_client_kwarg_reuses_supplied_client - test_s3_client_kwarg_ignores_session_and_config - test_s3_client_kwarg_supports_session_round_trip - test_default_path_still_works Full session/s3 suite: 50 passed. Refs #1163
1 parent 1232230 commit 5c1dfbc

2 files changed

Lines changed: 96 additions & 16 deletions

File tree

src/strands/session/s3_session_manager.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
boto_session: boto3.Session | None = None,
5252
boto_client_config: BotocoreConfig | None = None,
5353
region_name: str | None = None,
54+
s3_client: Any = None,
5455
**kwargs: Any,
5556
):
5657
"""Initialize S3SessionManager with S3 storage.
@@ -60,29 +61,42 @@ def __init__(
6061
ID is not allowed to contain path separators (e.g., a/b).
6162
bucket: S3 bucket name (required)
6263
prefix: S3 key prefix for storage organization
63-
boto_session: Optional boto3 session
64-
boto_client_config: Optional boto3 client configuration
65-
region_name: AWS region for S3 storage
64+
boto_session: Optional boto3 session. Ignored if ``s3_client`` is supplied.
65+
boto_client_config: Optional boto3 client configuration. Ignored if ``s3_client`` is supplied.
66+
region_name: AWS region for S3 storage. Ignored if ``s3_client`` is supplied.
67+
s3_client: Optional pre-built boto3 S3 client. When provided, S3SessionManager
68+
reuses it directly instead of constructing a new boto3.Session and S3 client.
69+
This avoids per-instance boto initialization overhead (HTTP connection pool,
70+
endpoint discovery) when many managers are created in the same process, and
71+
gives callers full control over the underlying client (credentials, retry
72+
config, custom endpoints). When ``s3_client`` is provided, ``boto_session``,
73+
``boto_client_config``, and ``region_name`` are ignored.
6674
**kwargs: Additional keyword arguments for future extensibility.
6775
"""
6876
self.bucket = bucket
6977
self.prefix = prefix
7078

71-
session = boto_session or boto3.Session(region_name=region_name)
72-
73-
# Add strands-agents to the request user agent
74-
if boto_client_config:
75-
existing_user_agent = getattr(boto_client_config, "user_agent_extra", None)
76-
# Append 'strands-agents' to existing user_agent_extra or set it if not present
77-
if existing_user_agent:
78-
new_user_agent = f"{existing_user_agent} strands-agents"
79-
else:
80-
new_user_agent = "strands-agents"
81-
client_config = boto_client_config.merge(BotocoreConfig(user_agent_extra=new_user_agent))
79+
if s3_client is not None:
80+
# Reuse the caller's pre-built client. We deliberately do not modify
81+
# its user_agent_extra; the caller owns the client's configuration.
82+
self.client = s3_client
8283
else:
83-
client_config = BotocoreConfig(user_agent_extra="strands-agents")
84+
session = boto_session or boto3.Session(region_name=region_name)
85+
86+
# Add strands-agents to the request user agent
87+
if boto_client_config:
88+
existing_user_agent = getattr(boto_client_config, "user_agent_extra", None)
89+
# Append 'strands-agents' to existing user_agent_extra or set it if not present
90+
if existing_user_agent:
91+
new_user_agent = f"{existing_user_agent} strands-agents"
92+
else:
93+
new_user_agent = "strands-agents"
94+
client_config = boto_client_config.merge(BotocoreConfig(user_agent_extra=new_user_agent))
95+
else:
96+
client_config = BotocoreConfig(user_agent_extra="strands-agents")
97+
98+
self.client = session.client(service_name="s3", config=client_config)
8499

85-
self.client = session.client(service_name="s3", config=client_config)
86100
super().__init__(session_id=session_id, session_repository=self)
87101

88102
def _get_session_path(self, session_id: str) -> str:

tests/strands/session/test_s3_session_manager.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,69 @@ def test_update_nonexistent_multi_agent(s3_manager, sample_session):
510510
nonexistent_mock.id = "nonexistent"
511511
with pytest.raises(SessionException):
512512
s3_manager.update_multi_agent(sample_session.session_id, nonexistent_mock)
513+
514+
515+
# --- s3_client reuse (issue #1163) ---
516+
517+
518+
def test_s3_client_kwarg_reuses_supplied_client(mocked_aws, s3_bucket):
519+
"""When ``s3_client`` is passed, S3SessionManager must reuse it directly
520+
instead of building a new boto3.Session + client.
521+
"""
522+
supplied = boto3.client("s3", region_name="us-west-2")
523+
manager = S3SessionManager(
524+
session_id="test-reuse",
525+
bucket=s3_bucket,
526+
prefix="sessions/",
527+
s3_client=supplied,
528+
)
529+
assert manager.client is supplied
530+
531+
532+
def test_s3_client_kwarg_ignores_session_and_config(mocked_aws, s3_bucket):
533+
"""When ``s3_client`` is supplied, boto_session / boto_client_config /
534+
region_name are ignored. We assert by checking that no extra boto3.Session
535+
is constructed when those args are also passed.
536+
"""
537+
supplied = boto3.client("s3", region_name="us-west-2")
538+
539+
# boto_session is the sentinel that would otherwise become self.client;
540+
# supplying it together with s3_client should NOT override s3_client.
541+
bogus_session = Mock(spec=boto3.Session)
542+
manager = S3SessionManager(
543+
session_id="test-reuse-2",
544+
bucket=s3_bucket,
545+
prefix="sessions/",
546+
boto_session=bogus_session,
547+
boto_client_config=BotocoreConfig(retries={"max_attempts": 99}),
548+
region_name="us-east-1",
549+
s3_client=supplied,
550+
)
551+
assert manager.client is supplied
552+
bogus_session.client.assert_not_called()
553+
554+
555+
def test_s3_client_kwarg_supports_session_round_trip(mocked_aws, s3_bucket, sample_session):
556+
"""End-to-end smoke: a manager built with s3_client= can write and read."""
557+
supplied = boto3.client("s3", region_name="us-west-2")
558+
manager = S3SessionManager(
559+
session_id="test-roundtrip",
560+
bucket=s3_bucket,
561+
prefix="sessions/",
562+
s3_client=supplied,
563+
)
564+
manager.create_session(sample_session)
565+
fetched = manager.read_session(sample_session.session_id)
566+
assert fetched.session_id == sample_session.session_id
567+
568+
569+
def test_default_path_still_works(mocked_aws, s3_bucket):
570+
"""Sanity: omitting s3_client still goes through the boto3.Session path."""
571+
manager = S3SessionManager(
572+
session_id="test-default",
573+
bucket=s3_bucket,
574+
prefix="sessions/",
575+
region_name="us-west-2",
576+
)
577+
# client is built by session.client("s3", ...); only check it's there
578+
assert manager.client is not None

0 commit comments

Comments
 (0)