JAL-2523 basic sleep and retry (3 times) on 429 response code with
[jalview.git] / src / jalview / ext / ensembl / EnsemblRestClient.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.ext.ensembl;
22
23 import jalview.io.DataSourceType;
24 import jalview.io.FileParse;
25 import jalview.util.StringUtils;
26
27 import java.io.BufferedReader;
28 import java.io.DataOutputStream;
29 import java.io.IOException;
30 import java.io.InputStream;
31 import java.io.InputStreamReader;
32 import java.net.HttpURLConnection;
33 import java.net.MalformedURLException;
34 import java.net.ProtocolException;
35 import java.net.URL;
36 import java.util.HashMap;
37 import java.util.List;
38 import java.util.Map;
39
40 import javax.ws.rs.HttpMethod;
41
42 import org.json.simple.JSONArray;
43 import org.json.simple.JSONObject;
44 import org.json.simple.parser.JSONParser;
45
46 import com.stevesoft.pat.Regex;
47
48 /**
49  * Base class for Ensembl REST service clients
50  * 
51  * @author gmcarstairs
52  */
53 abstract class EnsemblRestClient extends EnsemblSequenceFetcher
54 {
55   private static final int DEFAULT_READ_TIMEOUT = 5 * 60 * 1000; // 5 minutes
56
57   private static final int CONNECT_TIMEOUT_MS = 10 * 1000; // 10 seconds
58
59   private static final int MAX_RETRIES = 3;
60
61   private static final int HTTP_OK = 200;
62
63   private static final int HTTP_OVERLOAD = 429;
64
65   /*
66    * update these constants when Jalview has been checked / updated for
67    * changes to Ensembl REST API (ref JAL-2105)
68    * @see https://github.com/Ensembl/ensembl-rest/wiki/Change-log
69    * @see http://rest.ensembl.org/info/rest?content-type=application/json
70    */
71   private static final String LATEST_ENSEMBLGENOMES_REST_VERSION = "5.0";
72
73   private static final String LATEST_ENSEMBL_REST_VERSION = "5.0";
74
75   private static final String REST_CHANGE_LOG = "https://github.com/Ensembl/ensembl-rest/wiki/Change-log";
76
77   private static Map<String, EnsemblInfo> domainData;
78
79   // @see https://github.com/Ensembl/ensembl-rest/wiki/Output-formats
80   private static final String PING_URL = "http://rest.ensembl.org/info/ping.json";
81
82   private final static long AVAILABILITY_RETEST_INTERVAL = 10000L; // 10 seconds
83
84   private final static long VERSION_RETEST_INTERVAL = 1000L * 3600; // 1 hr
85
86   private static final Regex PROTEIN_REGEX = new Regex(
87           "(ENS)([A-Z]{3}|)P[0-9]{11}$");
88
89   private static final Regex TRANSCRIPT_REGEX = new Regex(
90           "(ENS)([A-Z]{3}|)T[0-9]{11}$");
91
92   private static final Regex GENE_REGEX = new Regex(
93           "(ENS)([A-Z]{3}|)G[0-9]{11}$");
94
95   static
96   {
97     domainData = new HashMap<String, EnsemblInfo>();
98     domainData.put(ENSEMBL_REST,
99             new EnsemblInfo(ENSEMBL_REST, LATEST_ENSEMBL_REST_VERSION));
100     domainData.put(ENSEMBL_GENOMES_REST, new EnsemblInfo(
101             ENSEMBL_GENOMES_REST, LATEST_ENSEMBLGENOMES_REST_VERSION));
102   }
103
104   protected volatile boolean inProgress = false;
105
106   /**
107    * Default constructor to use rest.ensembl.org
108    */
109   public EnsemblRestClient()
110   {
111     this(ENSEMBL_REST);
112   }
113
114   /**
115    * Constructor given the target domain to fetch data from
116    * 
117    * @param d
118    */
119   public EnsemblRestClient(String d)
120   {
121     setDomain(d);
122   }
123
124   /**
125    * Answers true if the query matches the regular expression pattern for an
126    * Ensembl transcript stable identifier
127    * 
128    * @param query
129    * @return
130    */
131   public boolean isTranscriptIdentifier(String query)
132   {
133     return query == null ? false : TRANSCRIPT_REGEX.search(query);
134   }
135
136   /**
137    * Answers true if the query matches the regular expression pattern for an
138    * Ensembl protein stable identifier
139    * 
140    * @param query
141    * @return
142    */
143   public boolean isProteinIdentifier(String query)
144   {
145     return query == null ? false : PROTEIN_REGEX.search(query);
146   }
147
148   /**
149    * Answers true if the query matches the regular expression pattern for an
150    * Ensembl gene stable identifier
151    * 
152    * @param query
153    * @return
154    */
155   public boolean isGeneIdentifier(String query)
156   {
157     return query == null ? false : GENE_REGEX.search(query);
158   }
159
160   @Override
161   public boolean queryInProgress()
162   {
163     return inProgress;
164   }
165
166   @Override
167   public StringBuffer getRawRecords()
168   {
169     return null;
170   }
171
172   /**
173    * Returns the URL for the client http request
174    * 
175    * @param ids
176    * @return
177    * @throws MalformedURLException
178    */
179   protected abstract URL getUrl(List<String> ids)
180           throws MalformedURLException;
181
182   /**
183    * Returns true if client uses GET method, false if it uses POST
184    * 
185    * @return
186    */
187   protected abstract boolean useGetRequest();
188
189   /**
190    * Return the desired value for the Content-Type request header
191    * 
192    * @param multipleIds
193    * 
194    * @return
195    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
196    */
197   protected abstract String getRequestMimeType(boolean multipleIds);
198
199   /**
200    * Return the desired value for the Accept request header
201    * 
202    * @return
203    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
204    */
205   protected abstract String getResponseMimeType();
206
207   /**
208    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
209    * indicates available, else false
210    * 
211    * @see http://rest.ensembl.org/documentation/info/ping
212    * @return
213    */
214   boolean checkEnsembl()
215   {
216     BufferedReader br = null;
217     try
218     {
219       // note this format works for both ensembl and ensemblgenomes
220       // info/ping.json works for ensembl only (March 2016)
221       URL ping = new URL(
222               getDomain() + "/info/ping?content-type=application/json");
223
224       /*
225        * expect {"ping":1} if ok
226        * if ping takes more than 2 seconds to respond, treat as if unavailable
227        */
228       br = getHttpResponse(ping, null, 2 * 1000);
229       if (br == null)
230       {
231         return false;
232       }
233       JSONParser jp = new JSONParser();
234       JSONObject val = (JSONObject) jp.parse(br);
235       String pingString = val.get("ping").toString();
236       return pingString != null;
237     } catch (Throwable t)
238     {
239       System.err.println(
240               "Error connecting to " + PING_URL + ": " + t.getMessage());
241     } finally
242     {
243       if (br != null)
244       {
245         try
246         {
247           br.close();
248         } catch (IOException e)
249         {
250           // ignore
251         }
252       }
253     }
254     return false;
255   }
256
257   /**
258    * returns a reader to a Fasta response from the Ensembl sequence endpoint
259    * 
260    * @param ids
261    * @return
262    * @throws IOException
263    */
264   protected FileParse getSequenceReader(List<String> ids) throws IOException
265   {
266     URL url = getUrl(ids);
267
268     BufferedReader reader = getHttpResponse(url, ids);
269     if (reader == null)
270     {
271       // request failed
272       return null;
273     }
274     FileParse fp = new FileParse(reader, url.toString(),
275             DataSourceType.URL);
276     return fp;
277   }
278
279   /**
280    * Gets a reader to the HTTP response, using the default read timeout of 5
281    * minutes
282    * 
283    * @param url
284    * @param ids
285    * @return
286    * @throws IOException
287    */
288   protected BufferedReader getHttpResponse(URL url, List<String> ids)
289           throws IOException
290   {
291     return getHttpResponse(url, ids, DEFAULT_READ_TIMEOUT);
292   }
293
294   /**
295    * Sends the HTTP request and gets the response as a reader
296    * 
297    * @param url
298    * @param ids
299    *          written as Json POST body if more than one
300    * @param readTimeout
301    *          in milliseconds
302    * @return
303    * @throws IOException
304    *           if response code was not 200, or other I/O error
305    */
306   protected BufferedReader getHttpResponse(URL url, List<String> ids,
307           int readTimeout) throws IOException
308   {
309     int retriesLeft = MAX_RETRIES;
310     HttpURLConnection connection = null;
311     int responseCode = 0;
312
313     while (retriesLeft > 0)
314     {
315       connection = tryConnection(url, ids, readTimeout);
316       responseCode = connection.getResponseCode();
317       if (responseCode == HTTP_OVERLOAD) // 429
318       {
319         retriesLeft--;
320         checkRetryAfter(connection);
321       }
322       else
323       {
324         retriesLeft = 0;
325       }
326     }
327     if (responseCode != HTTP_OK) // 200
328     {
329       /*
330        * note: a GET request for an invalid id returns an error code e.g. 415
331        * but POST request returns 200 and an empty Fasta response 
332        */
333       System.err.println("Response code " + responseCode + " for " + url);
334       return null;
335     }
336
337     InputStream response = connection.getInputStream();
338
339     // System.out.println(getClass().getName() + " took "
340     // + (System.currentTimeMillis() - now) + "ms to fetch");
341
342     BufferedReader reader = null;
343     reader = new BufferedReader(new InputStreamReader(response, "UTF-8"));
344     return reader;
345   }
346
347   /**
348    * @param url
349    * @param ids
350    * @param readTimeout
351    * @return
352    * @throws IOException
353    * @throws ProtocolException
354    */
355   protected HttpURLConnection tryConnection(URL url, List<String> ids,
356           int readTimeout) throws IOException, ProtocolException
357   {
358     // System.out.println(System.currentTimeMillis() + " " + url);
359     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
360
361     /*
362      * POST method allows multiple queries in one request; it is supported for
363      * sequence queries, but not for overlap
364      */
365     boolean multipleIds = ids != null && ids.size() > 1;
366     connection.setRequestMethod(
367             multipleIds ? HttpMethod.POST : HttpMethod.GET);
368     connection.setRequestProperty("Content-Type",
369             getRequestMimeType(multipleIds));
370     connection.setRequestProperty("Accept", getResponseMimeType());
371
372     connection.setUseCaches(false);
373     connection.setDoInput(true);
374     connection.setDoOutput(multipleIds);
375
376     connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
377     connection.setReadTimeout(readTimeout);
378
379     if (multipleIds)
380     {
381       writePostBody(connection, ids);
382     }
383     return connection;
384   }
385
386   /**
387    * Inspects response headers for a 'retry-after' directive, and waits for the
388    * directed period (if less than 10 seconds)
389    * 
390    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
391    * @param connection
392    */
393   void checkRetryAfter(HttpURLConnection connection)
394   {
395     String retryDelay = connection.getHeaderField("Retry-After");
396
397     // to test:
398     // retryDelay = "5";
399
400     if (retryDelay != null)
401     {
402       try
403       {
404         int retrySecs = Integer.valueOf(retryDelay);
405         if (retrySecs > 0 && retrySecs < 10)
406         {
407           System.err
408                   .println("Ensembl REST service rate limit exceeded, waiting "
409                           + retryDelay + " seconds before retrying");
410           Thread.sleep(1000 * retrySecs);
411         }
412       } catch (NumberFormatException | InterruptedException e)
413       {
414         System.err.println("Error handling Retry-After: " + e.getMessage());
415       }
416     }
417   }
418
419   /**
420    * Rechecks if Ensembl is responding, unless the last check was successful and
421    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
422    * else false. Also retrieves and saves the current version of Ensembl data
423    * and REST services at intervals.
424    * 
425    * @return
426    */
427   protected boolean isEnsemblAvailable()
428   {
429     EnsemblInfo info = domainData.get(getDomain());
430
431     long now = System.currentTimeMillis();
432
433     /*
434      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
435      */
436     boolean retestAvailability = (now
437             - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
438     if (!info.restAvailable || retestAvailability)
439     {
440       info.restAvailable = checkEnsembl();
441       info.lastAvailableCheckTime = now;
442     }
443
444     /*
445      * refetch Ensembl versions if the recheck period has elapsed
446      */
447     boolean refetchVersion = (now
448             - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
449     if (refetchVersion)
450     {
451       checkEnsemblRestVersion();
452       checkEnsemblDataVersion();
453       info.lastVersionCheckTime = now;
454     }
455
456     return info.restAvailable;
457   }
458
459   /**
460    * Constructs, writes and flushes the POST body of the request, containing the
461    * query ids in JSON format
462    * 
463    * @param connection
464    * @param ids
465    * @throws IOException
466    */
467   protected void writePostBody(HttpURLConnection connection,
468           List<String> ids) throws IOException
469   {
470     boolean first;
471     StringBuilder postBody = new StringBuilder(64);
472     postBody.append("{\"ids\":[");
473     first = true;
474     for (String id : ids)
475     {
476       if (!first)
477       {
478         postBody.append(",");
479       }
480       first = false;
481       postBody.append("\"");
482       postBody.append(id.trim());
483       postBody.append("\"");
484     }
485     postBody.append("]}");
486     byte[] thepostbody = postBody.toString().getBytes();
487     connection.setRequestProperty("Content-Length",
488             Integer.toString(thepostbody.length));
489     DataOutputStream wr = new DataOutputStream(
490             connection.getOutputStream());
491     wr.write(thepostbody);
492     wr.flush();
493     wr.close();
494   }
495
496   /**
497    * Fetches and checks Ensembl's REST version number
498    * 
499    * @return
500    */
501   private void checkEnsemblRestVersion()
502   {
503     EnsemblInfo info = domainData.get(getDomain());
504
505     JSONParser jp = new JSONParser();
506     URL url = null;
507     try
508     {
509       url = new URL(
510               getDomain() + "/info/rest?content-type=application/json");
511       BufferedReader br = getHttpResponse(url, null);
512       JSONObject val = (JSONObject) jp.parse(br);
513       String version = val.get("release").toString();
514       String majorVersion = version.substring(0, version.indexOf("."));
515       String expected = info.expectedRestVersion;
516       String expectedMajorVersion = expected.substring(0,
517               expected.indexOf("."));
518       info.restMajorVersionMismatch = false;
519       try
520       {
521         /*
522          * if actual REST major version is ahead of what we expect,
523          * record this in case we want to warn the user
524          */
525         if (Float.valueOf(majorVersion) > Float
526                 .valueOf(expectedMajorVersion))
527         {
528           info.restMajorVersionMismatch = true;
529         }
530       } catch (NumberFormatException e)
531       {
532         System.err.println("Error in REST version: " + e.toString());
533       }
534
535       /*
536        * check if REST version is later than what Jalview has tested against,
537        * if so warn; we don't worry if it is earlier (this indicates Jalview has
538        * been tested in advance against the next pending REST version)
539        */
540       boolean laterVersion = StringUtils.compareVersions(version,
541               expected) == 1;
542       if (laterVersion)
543       {
544         System.err.println(String.format(
545                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
546                 getDbSource(), expected, version, REST_CHANGE_LOG));
547       }
548       info.restVersion = version;
549     } catch (Throwable t)
550     {
551       System.err.println(
552               "Error checking Ensembl REST version: " + t.getMessage());
553     }
554   }
555
556   public boolean isRestMajorVersionMismatch()
557   {
558     return domainData.get(getDomain()).restMajorVersionMismatch;
559   }
560
561   /**
562    * Fetches and checks Ensembl's data version number
563    * 
564    * @return
565    */
566   private void checkEnsemblDataVersion()
567   {
568     JSONParser jp = new JSONParser();
569     URL url = null;
570     BufferedReader br = null;
571
572     try
573     {
574       url = new URL(
575               getDomain() + "/info/data?content-type=application/json");
576       br = getHttpResponse(url, null);
577       if (br != null)
578       {
579         JSONObject val = (JSONObject) jp.parse(br);
580         JSONArray versions = (JSONArray) val.get("releases");
581         domainData.get(getDomain()).dataVersion = versions.get(0)
582                 .toString();
583       }
584     } catch (Throwable t)
585     {
586       System.err.println(
587               "Error checking Ensembl data version: " + t.getMessage());
588     } finally
589     {
590       if (br != null)
591       {
592         try
593         {
594           br.close();
595         } catch (IOException e)
596         {
597           // ignore
598         }
599       }
600     }
601   }
602
603   public String getEnsemblDataVersion()
604   {
605     return domainData.get(getDomain()).dataVersion;
606   }
607
608   @Override
609   public String getDbVersion()
610   {
611     return getEnsemblDataVersion();
612   }
613
614 }