Skip to content

Commit 2b73a20

Browse files
committed
Retry iRODS objectstore reads on transient connection errors
1 parent 8720f0e commit 2b73a20

2 files changed

Lines changed: 85 additions & 1 deletion

File tree

lib/galaxy/objectstore/irods.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
Object Store plugin for the Integrated Rule-Oriented Data System (iRODS)
33
"""
44

5+
import functools
56
import logging
67
import os
78
import shutil
89
import ssl
910
import threading
11+
import time
1012
from datetime import datetime
1113
from pathlib import Path
1214

@@ -16,11 +18,13 @@
1618
from irods.exception import (
1719
CollectionDoesNotExist,
1820
DataObjectDoesNotExist,
21+
NetworkException,
1922
)
2023
from irods.session import iRODSSession
2124
except ImportError:
2225
irods = None
2326

27+
2428
from galaxy.util import (
2529
ExecutionTimer,
2630
string_as_bool,
@@ -35,6 +39,33 @@
3539
logging.getLogger("irods.connection").setLevel(logging.INFO) # irods logging generates gigabytes of logs
3640

3741

42+
_IRODS_RETRY_ATTEMPTS = 3
43+
_IRODS_RETRY_BACKOFF = 0.5
44+
45+
46+
def _retry_on_connection_error(func):
47+
"""Retry iRODS read operations on a transient connection error."""
48+
49+
@functools.wraps(func)
50+
def wrapper(*args, **kwargs):
51+
for attempt in range(1, _IRODS_RETRY_ATTEMPTS + 1):
52+
try:
53+
return func(*args, **kwargs)
54+
except (NetworkException, ssl.SSLError) as exc:
55+
if attempt == _IRODS_RETRY_ATTEMPTS:
56+
raise
57+
log.warning(
58+
"Transient iRODS error in %s (attempt %d/%d), retrying on a fresh connection: %s",
59+
func.__name__,
60+
attempt,
61+
_IRODS_RETRY_ATTEMPTS,
62+
exc,
63+
)
64+
time.sleep(_IRODS_RETRY_BACKOFF * attempt)
65+
66+
return wrapper
67+
68+
3869
def _config_xml_error(tag):
3970
msg = f"No {tag} element in config XML tree"
4071
raise Exception(msg)
@@ -376,6 +407,7 @@ def _config_to_dict(self):
376407
}
377408

378409
# rel_path is file or folder?
410+
@_retry_on_connection_error
379411
def _get_remote_size(self, rel_path):
380412
ipt_timer = ExecutionTimer()
381413
p = Path(rel_path)
@@ -396,6 +428,7 @@ def _get_remote_size(self, rel_path):
396428
log.debug("irods_pt _get_remote_size: %s", ipt_timer)
397429

398430
# rel_path is file or folder?
431+
@_retry_on_connection_error
399432
def _exists_remotely(self, rel_path):
400433
ipt_timer = ExecutionTimer()
401434
p = Path(rel_path)
@@ -415,6 +448,7 @@ def _exists_remotely(self, rel_path):
415448
finally:
416449
log.debug("irods_pt _exists_remotely: %s", ipt_timer)
417450

451+
@_retry_on_connection_error
418452
def _download(self, rel_path):
419453
ipt_timer = ExecutionTimer()
420454
cache_path = self._get_cache_path(rel_path)

test/unit/objectstore/test_irods.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import os
2+
import ssl
23

34
import pytest
45

5-
from galaxy.objectstore.irods import parse_config_xml
6+
from galaxy.objectstore import irods as irods_module
7+
from galaxy.objectstore.irods import (
8+
_IRODS_RETRY_ATTEMPTS,
9+
_retry_on_connection_error,
10+
NetworkException,
11+
parse_config_xml,
12+
)
613
from galaxy.util import parse_xml
714

815
SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__))
@@ -80,3 +87,46 @@ def test_parse_config_xml_no_auth():
8087
root = tree.getroot()
8188
with pytest.raises(Exception, match="No auth element in config XML tree"):
8289
parse_config_xml(root)
90+
91+
92+
def _make_flaky(exc, fail_times):
93+
calls = {"n": 0}
94+
95+
@_retry_on_connection_error
96+
def op(_self):
97+
calls["n"] += 1
98+
if calls["n"] <= fail_times:
99+
raise exc
100+
return "ok"
101+
102+
return op, calls
103+
104+
105+
@pytest.fixture(autouse=True)
106+
def _no_sleep(monkeypatch):
107+
monkeypatch.setattr(irods_module.time, "sleep", lambda *_: None)
108+
109+
110+
def test_retry_recovers_from_network_exception():
111+
op, calls = _make_flaky(NetworkException("reset"), fail_times=_IRODS_RETRY_ATTEMPTS - 1)
112+
assert op(object()) == "ok"
113+
assert calls["n"] == _IRODS_RETRY_ATTEMPTS
114+
115+
116+
def test_retry_recovers_from_ssl_error():
117+
op, calls = _make_flaky(ssl.SSLEOFError("handshake"), fail_times=1)
118+
assert op(object()) == "ok"
119+
assert calls["n"] == 2
120+
121+
122+
def test_retry_gives_up_after_max_attempts():
123+
op, calls = _make_flaky(ssl.SSLEOFError("handshake"), fail_times=_IRODS_RETRY_ATTEMPTS)
124+
with pytest.raises(ssl.SSLError):
125+
op(object())
126+
assert calls["n"] == _IRODS_RETRY_ATTEMPTS
127+
128+
129+
def test_no_retry_on_success():
130+
op, calls = _make_flaky(NetworkException("reset"), fail_times=0)
131+
assert op(object()) == "ok"
132+
assert calls["n"] == 1

0 commit comments

Comments
 (0)