Skip to content

Commit b5df59e

Browse files
committed
Add timeout options
1 parent 0c913af commit b5df59e

6 files changed

Lines changed: 210 additions & 2 deletions

File tree

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)