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
1724import json
1825import os
26+ import random
1927import re
2028import sys
29+ import time
30+ import urllib .error
2131import urllib .parse
2232import urllib .request
2333from html import unescape
2434
2535import 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
36111def 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
47116def 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
62130def 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
96147def 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
107157def 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
133180def 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