Skip to content

Commit 0f05a72

Browse files
authored
Merge pull request #513 from NMRLipids/dev-autocomplete-retry
retry on transient failures
2 parents 414c2dd + 9164142 commit 0f05a72

1 file changed

Lines changed: 104 additions & 63 deletions

File tree

developer/autocomplete_metadata.py

Lines changed: 104 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,67 +12,126 @@
1212
1313
.. note::
1414
This file is meant to be used by automated workflows.
15+
16+
Several upstream services (notably EBI's UniChem and ChEBI) intermittently
17+
answer with transient ``5xx`` errors. Requests are therefore retried a few
18+
times with exponential backoff that honors any ``Retry-After`` header, so a
19+
blip does not abort metadata completion while staying polite to the servers.
20+
The retry budget can be overridden with the ``AUTOCOMPLETE_MAX_RETRIES``
21+
environment variable (set it to ``0`` to disable retries entirely).
1522
"""
1623

1724
import json
1825
import os
26+
import random
1927
import re
2028
import sys
29+
import time
30+
import urllib.error
2131
import urllib.parse
2232
import urllib.request
2333
from html import unescape
2434

2535
import yaml
2636

37+
# HTTP status codes that signal a transient, server-side problem and are safe
38+
# to retry. 429 (Too Many Requests) is included so we back off politely instead
39+
# of hammering a rate-limited endpoint.
40+
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
41+
MAX_RETRIES = max(0, int(os.environ.get("AUTOCOMPLETE_MAX_RETRIES", "4")))
42+
BACKOFF_BASE = 1.0 # seconds for the first retry; doubles each attempt
43+
MAX_BACKOFF = 30.0 # cap any single sleep so a flaky service can't stall us forever
44+
DEFAULT_TIMEOUT = 15
45+
USER_AGENT = "FAIRMD-lipids-autocomplete (+https://github.com/NMRLipids/FAIRMD_lipids)"
46+
47+
48+
def _retry_delay(error, attempt):
49+
"""Seconds to wait before the next attempt.
50+
51+
Prefers a server-provided ``Retry-After`` header (the polite signal), and
52+
otherwise falls back to exponential backoff with a little jitter so
53+
concurrent callers don't retry in lockstep.
54+
"""
55+
headers = getattr(error, "headers", None)
56+
retry_after = headers.get("Retry-After") if headers is not None else None
57+
if retry_after:
58+
try:
59+
# Retry-After is usually a number of seconds; it may also be an HTTP
60+
# date, in which case we fall through to plain backoff.
61+
return min(float(retry_after), MAX_BACKOFF)
62+
except (TypeError, ValueError):
63+
pass
64+
backoff = BACKOFF_BASE * (2**attempt)
65+
return min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
66+
67+
68+
def fetch(req, timeout=DEFAULT_TIMEOUT):
69+
"""Open ``req`` (a URL string or :class:`urllib.request.Request`) robustly.
70+
71+
Returns the response body as ``bytes`` for an HTTP 200 response, or ``None``
72+
when the resource is unavailable. Transient failures (HTTP 429/5xx and
73+
connection-level errors such as timeouts) are retried with backed-off,
74+
``Retry-After``-aware delays; definitive errors (e.g. 404) are not retried.
75+
"""
76+
if isinstance(req, str):
77+
req = urllib.request.Request(req)
78+
req.add_header("User-Agent", USER_AGENT)
79+
80+
for attempt in range(MAX_RETRIES + 1):
81+
try:
82+
with urllib.request.urlopen(req, timeout=timeout) as response:
83+
return response.read() if response.status == 200 else None
84+
except urllib.error.HTTPError as error:
85+
if error.code not in RETRYABLE_STATUS or attempt == MAX_RETRIES:
86+
return None
87+
delay = _retry_delay(error, attempt)
88+
except (urllib.error.URLError, TimeoutError):
89+
# Covers DNS failures, dropped connections and socket timeouts.
90+
if attempt == MAX_RETRIES:
91+
return None
92+
delay = _retry_delay(None, attempt)
93+
except Exception:
94+
return None
95+
time.sleep(delay)
96+
return None
97+
2798

28-
def check_api(url):
99+
def fetch_json(req, timeout=DEFAULT_TIMEOUT):
100+
"""Like :func:`fetch`, but decode the body as JSON. Returns ``None`` on any
101+
failure (request error or malformed payload)."""
102+
body = fetch(req, timeout=timeout)
103+
if not body:
104+
return None
29105
try:
30-
with urllib.request.urlopen(url, timeout=5) as response:
31-
return response.status == 200
32-
except Exception:
33-
return False
106+
return json.loads(body.decode("utf-8"))
107+
except (ValueError, UnicodeDecodeError):
108+
return None
34109

35110

36111
def get_chembl(inchikey):
37112
url = f"https://www.ebi.ac.uk/chembl/api/data/molecule?standard_inchi_key={inchikey}&format=json"
38-
if check_api(url):
39-
try:
40-
with urllib.request.urlopen(url) as response:
41-
return json.loads(response.read().decode("utf-8")) if response.status == 200 else {}
42-
except Exception:
43-
return {}
44-
return {}
113+
return fetch_json(url) or {}
45114

46115

47116
def get_pubchem(inchikey):
48117
url = (
49118
f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/inchikey/"
50119
f"{inchikey}/property/IUPACName,SMILES,InChI,InChIKey,MolecularFormula,MolecularWeight/JSON"
51120
)
52-
if check_api(url):
121+
data = fetch_json(url)
122+
if data:
53123
try:
54-
with urllib.request.urlopen(url) as response:
55-
if response.status == 200:
56-
return json.loads(response.read().decode("utf-8"))["PropertyTable"]["Properties"][0]
57-
except Exception:
124+
return data["PropertyTable"]["Properties"][0]
125+
except (KeyError, IndexError, TypeError):
58126
pass
59127
return {}
60128

61129

62130
def get_pubchem_synonyms(cid):
63131
url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/{cid}/synonyms/JSON"
64-
if check_api(url):
65-
try:
66-
with urllib.request.urlopen(url) as response:
67-
if response.status == 200:
68-
return (
69-
json.loads(response.read().decode("utf-8"))
70-
.get("InformationList", {})
71-
.get("Information", [{}])[0]
72-
.get("Synonym", [])
73-
)
74-
except Exception:
75-
pass
132+
data = fetch_json(url)
133+
if data:
134+
return data.get("InformationList", {}).get("Information", [{}])[0].get("Synonym", [])
76135
return []
77136

78137

@@ -82,15 +141,7 @@ def get_chebi(chebi_id):
82141

83142
url = f"https://www.ebi.ac.uk/chebi/backend/api/public/compound/{chebi_id}/?only_ontology_parents=false&only_ontology_children=false"
84143

85-
try:
86-
if check_api(url):
87-
with urllib.request.urlopen(url) as response:
88-
if response.status == 200:
89-
return json.loads(response.read().decode("utf-8"))
90-
except Exception:
91-
pass
92-
93-
return {}
144+
return fetch_json(url) or {}
94145

95146

96147
def get_metabolights(chebi_id):
@@ -99,9 +150,8 @@ def get_metabolights(chebi_id):
99150
if not chebi_id:
100151
return ""
101152
mtbl_id = f"MTBLC{chebi_id}"
102-
if check_api(f"https://www.ebi.ac.uk/metabolights/ws/compounds/{mtbl_id}"):
103-
return mtbl_id
104-
return ""
153+
url = f"https://www.ebi.ac.uk/metabolights/ws/compounds/{mtbl_id}"
154+
return mtbl_id if fetch(url) is not None else ""
105155

106156

107157
def get_cas(inchikey):
@@ -118,33 +168,24 @@ def get_cas(inchikey):
118168
# does not match, whereas "InChIKey=<value>" does.
119169
query = urllib.parse.quote(f"InChIKey={inchikey}")
120170
url = f"https://commonchemistry.cas.org/api/search?q={query}"
121-
try:
122-
req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
123-
with urllib.request.urlopen(req, timeout=5) as response:
124-
if response.status == 200:
125-
results = json.loads(response.read().decode("utf-8")).get("results", [])
126-
if results:
127-
return results[0].get("rn", "") or ""
128-
except Exception:
129-
pass
171+
req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
172+
data = fetch_json(req)
173+
if data:
174+
results = data.get("results", [])
175+
if results:
176+
return results[0].get("rn", "") or ""
130177
return ""
131178

132179

133180
def get_unichem(inchikey):
134181
url = "https://www.ebi.ac.uk/unichem/api/v1/compounds"
135-
if check_api("https://www.ebi.ac.uk/unichem/api/v1/sources"):
136-
try:
137-
data = json.dumps({"type": "inchikey", "compound": inchikey}).encode("utf-8")
138-
headers = {"Content-Type": "application/json"}
139-
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
140-
141-
with urllib.request.urlopen(req) as response:
142-
if response.status == 200:
143-
compounds = json.loads(response.read().decode("utf-8")).get("compounds", [])
144-
if compounds and "sources" in compounds[0]:
145-
return compounds[0]["sources"]
146-
except Exception:
147-
pass
182+
payload = json.dumps({"type": "inchikey", "compound": inchikey}).encode("utf-8")
183+
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
184+
data = fetch_json(req)
185+
if data:
186+
compounds = data.get("compounds", [])
187+
if compounds and "sources" in compounds[0]:
188+
return compounds[0]["sources"]
148189
return []
149190

150191

0 commit comments

Comments
 (0)