Skip to content

Commit ec77640

Browse files
committed
Add timeouts for botocore
1 parent 2eeb42c commit ec77640

7 files changed

Lines changed: 215 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
# v26.28.0
4+
5+
- Add support for specifying botore timeouts and retries for s3 transfers/executions
6+
- Fix issue in tidy throwing an exception when the s3 client was already closed
7+
38
# v26.18.0
49

510
- Fix a bug where the S3 client was not being properly closed and garbage collected, which could lead to resource leaks and issues with too many open connections. This was caused by the `tidy` method not setting the `s3_client` attribute to `None` after closing it, which meant that the botocore objects were not being garbage collected.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ JSON configs for transfers can be defined as follows:
140140
]
141141
```
142142

143+
### Default S3 client timeout behaviour
144+
145+
S3 transfers now apply botocore defaults even when not specified in the protocol:
146+
147+
- `botocoreReadTimeout`: `60`
148+
- `botocoreConnectTimeout`: `10`
149+
- `max_attempts`: `0`
150+
151+
These are socket/connect and retry settings, not a total transfer deadline. A large
152+
upload or download that is still making progress can continue past the batch timeout
153+
request and still complete successfully. The defaults mainly prevent stalled S3 calls
154+
from sitting in botocore retries for longer than necessary. All three values can still
155+
be overridden per protocol when needed.
156+
143157
## Example S3 upload with flag files
144158

145159
```json

src/opentaskpy/addons/aws/remotehandlers/s3.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import boto3
99
import opentaskpy.otflogging
10+
from botocore.config import Config
1011
from botocore.exceptions import ClientError
1112
from dateutil.tz import tzlocal
1213
from opentaskpy.remotehandlers.remotehandler import (
@@ -17,6 +18,32 @@
1718
from .creds import get_aws_client, set_aws_creds
1819

1920
MAX_OBJECTS_PER_QUERY = 100
21+
DEFAULT_S3_BOTOCORE_READ_TIMEOUT = 60
22+
DEFAULT_S3_BOTOCORE_CONNECT_TIMEOUT = 10
23+
DEFAULT_S3_BOTOCORE_MAX_ATTEMPTS = 0
24+
25+
26+
def _build_botocore_config(protocol: dict) -> Config:
27+
"""Build a botocore client config from the task protocol settings.
28+
29+
Defaults are applied so S3 transfers do not inherit botocore retry behaviour that
30+
can keep a batch blocked longer than expected after a timeout request.
31+
"""
32+
config_options = {
33+
"read_timeout": protocol.get(
34+
"botocoreReadTimeout", DEFAULT_S3_BOTOCORE_READ_TIMEOUT
35+
),
36+
"connect_timeout": protocol.get(
37+
"botocoreConnectTimeout", DEFAULT_S3_BOTOCORE_CONNECT_TIMEOUT
38+
),
39+
"retries": {
40+
"max_attempts": protocol.get(
41+
"max_attempts", DEFAULT_S3_BOTOCORE_MAX_ATTEMPTS
42+
)
43+
},
44+
}
45+
46+
return Config(**config_options)
2047

2148

2249
class S3Transfer(RemoteTransferHandler):
@@ -74,12 +101,15 @@ def validate_or_refresh_creds(self) -> None:
74101
if self.temporary_creds:
75102
self.logger.info("Renewing temporary credentials")
76103

104+
client_config = _build_botocore_config(self.spec["protocol"])
105+
77106
client_result = get_aws_client(
78107
"s3",
79108
self.credentials,
80109
token_expiry_seconds=self.token_expiry_seconds,
81110
assume_role_arn=self.assume_role_arn,
82111
assume_role_external_id=self.assume_role_external_id,
112+
config=client_config,
83113
)
84114
self.temporary_creds = (
85115
client_result["temporary_creds"]
@@ -484,7 +514,8 @@ def create_flag_files(self) -> int:
484514

485515
def tidy(self) -> None:
486516
"""Tidy up the S3 client."""
487-
self.s3_client.close()
517+
if self.s3_client and hasattr(self.s3_client, "close"):
518+
self.s3_client.close()
488519
self.s3_client = None # allow botocore objects to be garbage collected
489520

490521

@@ -593,5 +624,6 @@ def execute(self) -> bool:
593624

594625
def tidy(self) -> None:
595626
"""Tidy up the S3 client."""
596-
self.s3_client.close()
627+
if self.s3_client and hasattr(self.s3_client, "close"):
628+
self.s3_client.close()
597629
self.s3_client = None # allow botocore objects to be garbage collected

src/opentaskpy/addons/aws/remotehandlers/schemas/transfer/s3_destination/protocol.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
},
2828
"region_name": {
2929
"type": "string"
30+
},
31+
"botocoreReadTimeout": {
32+
"type": "integer"
33+
},
34+
"botocoreConnectTimeout": {
35+
"type": "integer"
36+
},
37+
"max_attempts": {
38+
"type": "integer"
3039
}
3140
},
3241
"required": ["name"],

src/opentaskpy/addons/aws/remotehandlers/schemas/transfer/s3_source/protocol.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
},
2828
"region_name": {
2929
"type": "string"
30+
},
31+
"botocoreReadTimeout": {
32+
"type": "integer"
33+
},
34+
"botocoreConnectTimeout": {
35+
"type": "integer"
36+
},
37+
"max_attempts": {
38+
"type": "integer"
3039
}
3140
},
3241
"required": ["name"],

tests/test_remotehandler_s3_transfer.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import botocore
1313
import freezegun
1414
import pytest
15+
from botocore.config import Config
1516
from opentaskpy.taskhandlers import transfer
1617
from pytest_shell import fs
1718

@@ -477,6 +478,139 @@ def test_remote_handler():
477478
assert transfer_obj.dest_remote_handlers is None
478479

479480

481+
def test_s3_transfer_passes_botocore_config(monkeypatch):
482+
captured = {}
483+
484+
class DummyClient:
485+
def close(self):
486+
return None
487+
488+
def fake_get_aws_client(
489+
client_type,
490+
credentials,
491+
token_expiry_seconds=900,
492+
assume_role_arn=None,
493+
assume_role_external_id=None,
494+
config=None,
495+
):
496+
captured["client_type"] = client_type
497+
captured["credentials"] = credentials
498+
captured["token_expiry_seconds"] = token_expiry_seconds
499+
captured["assume_role_arn"] = assume_role_arn
500+
captured["assume_role_external_id"] = assume_role_external_id
501+
captured["config"] = config
502+
return {"client": DummyClient(), "temporary_creds": None}
503+
504+
monkeypatch.setattr(
505+
"opentaskpy.addons.aws.remotehandlers.s3.get_aws_client", fake_get_aws_client
506+
)
507+
508+
handler = S3Transfer(
509+
{
510+
"task_id": "s3-config-test",
511+
"bucket": BUCKET_NAME,
512+
"directory": "src",
513+
"fileRegex": ".*\\.txt",
514+
"protocol": {
515+
"name": "opentaskpy.addons.aws.remotehandlers.s3.S3Transfer",
516+
"botocoreReadTimeout": 120,
517+
"botocoreConnectTimeout": 30,
518+
"max_attempts": 2,
519+
},
520+
}
521+
)
522+
523+
assert isinstance(captured["config"], Config)
524+
assert captured["client_type"] == "s3"
525+
assert captured["config"].read_timeout == 120
526+
assert captured["config"].connect_timeout == 30
527+
assert captured["config"].retries["max_attempts"] == 2
528+
handler.tidy()
529+
530+
531+
def test_s3_transfer_uses_default_botocore_config(monkeypatch):
532+
captured = {}
533+
534+
class DummyClient:
535+
def close(self):
536+
return None
537+
538+
def fake_get_aws_client(
539+
client_type,
540+
credentials,
541+
token_expiry_seconds=900,
542+
assume_role_arn=None,
543+
assume_role_external_id=None,
544+
config=None,
545+
):
546+
captured["config"] = config
547+
return {"client": DummyClient(), "temporary_creds": None}
548+
549+
monkeypatch.setattr(
550+
"opentaskpy.addons.aws.remotehandlers.s3.get_aws_client", fake_get_aws_client
551+
)
552+
553+
handler = S3Transfer(
554+
{
555+
"task_id": "s3-default-config-test",
556+
"bucket": BUCKET_NAME,
557+
"directory": "src",
558+
"fileRegex": ".*\\.txt",
559+
"protocol": {
560+
"name": "opentaskpy.addons.aws.remotehandlers.s3.S3Transfer",
561+
},
562+
}
563+
)
564+
565+
assert isinstance(captured["config"], Config)
566+
assert captured["config"].read_timeout == 60
567+
assert captured["config"].connect_timeout == 10
568+
assert captured["config"].retries["max_attempts"] == 0
569+
handler.tidy()
570+
571+
572+
def test_s3_transfer_tidy_is_idempotent(monkeypatch):
573+
class DummyClient:
574+
def __init__(self):
575+
self.close_calls = 0
576+
577+
def close(self):
578+
self.close_calls += 1
579+
580+
def fake_get_aws_client(
581+
client_type,
582+
credentials,
583+
token_expiry_seconds=900,
584+
assume_role_arn=None,
585+
assume_role_external_id=None,
586+
config=None,
587+
):
588+
return {"client": DummyClient(), "temporary_creds": None}
589+
590+
monkeypatch.setattr(
591+
"opentaskpy.addons.aws.remotehandlers.s3.get_aws_client", fake_get_aws_client
592+
)
593+
594+
handler = S3Transfer(
595+
{
596+
"task_id": "s3-tidy-test",
597+
"bucket": BUCKET_NAME,
598+
"directory": "src",
599+
"fileRegex": ".*\\.txt",
600+
"protocol": {
601+
"name": "opentaskpy.addons.aws.remotehandlers.s3.S3Transfer",
602+
},
603+
}
604+
)
605+
606+
client = handler.s3_client
607+
handler.tidy()
608+
handler.tidy()
609+
610+
assert client.close_calls == 1
611+
assert handler.s3_client is None
612+
613+
480614
def test_s3_file_watch(s3_client, setup_bucket, tmp_path):
481615
transfer_obj = transfer.Transfer(
482616
None, "s3-file-watch", s3_file_watch_task_definition

tests/test_s3_source_schema_validate.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ def test_s3_source_protocol(
125125
json_data["source"]["protocol"]["token_expiry_seconds"] = 10000
126126
assert validate_transfer_json(json_data)
127127

128+
json_data["source"]["protocol"]["botocoreReadTimeout"] = 120
129+
json_data["source"]["protocol"]["botocoreConnectTimeout"] = 30
130+
json_data["source"]["protocol"]["max_attempts"] = 2
131+
assert validate_transfer_json(json_data)
132+
128133
# Set it too low, and too high
129134
json_data["source"]["protocol"]["token_expiry_seconds"] = 899
130135
assert not validate_transfer_json(json_data)
@@ -247,6 +252,11 @@ def test_s3_destination(valid_transfer, valid_destination):
247252
json_data["destination"][0]["flags"] = {"fullPath": "flag.txt"}
248253
assert validate_transfer_json(json_data)
249254

255+
json_data["destination"][0]["protocol"]["botocoreReadTimeout"] = 120
256+
json_data["destination"][0]["protocol"]["botocoreConnectTimeout"] = 30
257+
json_data["destination"][0]["protocol"]["max_attempts"] = 2
258+
assert validate_transfer_json(json_data)
259+
250260
# Remove protocol
251261
del json_data["destination"][0]["protocol"]
252262
assert not validate_transfer_json(json_data)

0 commit comments

Comments
 (0)