Repository: biolab/orange3-text

Issue:
http.client.BadStatusLine:  ''
https://sentry.io/biolab/orange3-text-bl/issues/204513206/

```
http.client.BadStatusLine:  ''
(9 additional frame(s) were not displayed)
...
  Module "urllib.request", line 1202, in http_open

    return self.do_open(http.client.HTTPConnection, req)
  Module "urllib.request", line 1177, in do_open

    r = h.getresponse()
  Module "http.client", line 1172, in getresponse

    response.begin()
  Module "http.client", line 351, in begin

    version, status, reason = self._read_status()
  Module "http.client", line 321, in _read_status

    raise BadStatusLine(line)
```


Discussion:

Resolving patch:
diff --git a/orangecontrib/text/nyt.py b/orangecontrib/text/nyt.py
--- a/orangecontrib/text/nyt.py
+++ b/orangecontrib/text/nyt.py
@@ -6,7 +6,8 @@
 from datetime import date
 from time import sleep
 from urllib import request, parse
-from urllib.error import HTTPError
+from http.client import HTTPException
+from urllib.error import HTTPError, URLError
 
 
 from Orange import data
@@ -14,6 +15,7 @@
 from orangecontrib.text.corpus import Corpus
 
 SLEEP = 1
+TIMEOUT = 10
 MAX_DOCS = 1000
 BATCH_SIZE = 10
 MIN_DATE = date(1851, 1, 1)
@@ -62,6 +64,7 @@ def __init__(self, api_key):
         self.api_key = api_key
         self.on_error = None
         self.on_rate_limit = None
+        self.on_no_connection = None
         self.cache_path = None
         self._cache_init()
 
@@ -72,7 +75,7 @@ def api_key_valid(self):
             with request.urlopen(url) as connection:
                 if connection.getcode() == 200:
                     return True
-        except HTTPError:
+        except (HTTPError, URLError, HTTPException):
             return False
 
     def search(self, query, date_from=None, date_to=None, max_docs=None,
@@ -91,9 +94,6 @@ def search(self, query, date_from=None, date_to=None, max_docs=None,
         Returns:
             Corpus: Search results.
         """
-        if not self.api_key_valid():
-            raise RuntimeError('The API key is not valid.')
-        sleep(SLEEP)  # due to one call per second limit
         if max_docs is None or max_docs > MAX_DOCS:
             max_docs = MAX_DOCS
 
@@ -161,7 +161,7 @@ def _fetch_page(self, query, date_from, date_to, page):
         else:
             url = self._encode_url(query, date_from, date_to, page, for_caching=False)
             try:
-                with request.urlopen(url) as conn:
+                with request.urlopen(url, timeout=TIMEOUT) as conn:
                     data = conn.read().decode('utf-8')
             except HTTPError as e:
                 if e.code == 403 and page > 0:
@@ -176,6 +176,11 @@ def _fetch_page(self, query, date_from, date_to, page):
                 elif callable(self.on_error):
                     self.on_error(str(e))
                 return None, False
+            except URLError:
+                if callable(self.on_no_connection):
+                    self.on_no_connection()
+                    return None, False
+                raise
             data = json.loads(data)
             self._cache_store(cache_url, data)
             return data, True
diff --git a/orangecontrib/text/widgets/ownyt.py b/orangecontrib/text/widgets/ownyt.py
--- a/orangecontrib/text/widgets/ownyt.py
+++ b/orangecontrib/text/widgets/ownyt.py
@@ -26,7 +26,8 @@ class APICredentialsDialog(OWWidget):
         key_input = ''
 
         class Error(OWWidget.Error):
-            invalid_credentials = Msg('This credentials are invalid.')
+            invalid_credentials = Msg('This credentials are invalid. '
+                                      'Check the key and your internet connection.')
 
         def __init__(self, parent):
             super().__init__()
@@ -87,6 +88,7 @@ class Warning(OWWidget.Warning):
     class Error(OWWidget.Error):
         no_api = Msg('Please provide a valid API key.')
         no_query = Msg('Please provide a query.')
+        offline = Msg('No internet connection.')
         api_error = Msg('API error: {}')
         rate_limit = Msg('Rate limit exceeded. Please try again later.')
 
@@ -164,6 +166,9 @@ def progress_with_info(self, n_retrieved, n_all):
     def on_start(self):
         self.Error.api_error.clear()
         self.Error.rate_limit.clear()
+        self.Error.offline.clear()
+        self.num_all, self.num_retrieved = 0, 0
+        self.update_info_label()
         self.progressBarInit(None)
         self.search_button.setText('Stop')
         self.send(IO.CORPUS, None)
@@ -190,8 +195,10 @@ def set_text_features(self):
 
     def update_api(self, api):
         self.nyt_api = api
+        self.Error.no_api.clear()
         self.nyt_api.on_error = self.Error.api_error
         self.nyt_api.on_rate_limit = self.Error.rate_limit
+        self.nyt_api.on_no_connection = self.Error.offline
 
     def send_report(self):
         self.report_items([


Test patch:
diff --git a/orangecontrib/text/tests/test_nyt.py b/orangecontrib/text/tests/test_nyt.py
--- a/orangecontrib/text/tests/test_nyt.py
+++ b/orangecontrib/text/tests/test_nyt.py
@@ -3,8 +3,9 @@
 import tempfile
 import unittest
 from contextlib import contextmanager
-from unittest.mock import patch, Mock
-from urllib.error import HTTPError
+from unittest.mock import patch, Mock, MagicMock
+from http.client import HTTPException
+from urllib.error import HTTPError, URLError
 
 from Orange.data import TimeVariable
 from orangecontrib.text import Corpus
@@ -24,7 +25,7 @@ def __init__(self, cache):
                     # Last empty line is sometimes missing
                     pass
 
-    def __call__(self, url):
+    def __call__(self, url, **kwargs):
         self.current_url = url
         self.response_code = 200 if url in self.data else 404
 
@@ -45,7 +46,7 @@ def getcode(self):
 mock_urllib = MockUrlOpen(CACHE)
 
 
-class MockErrors(Mock):
+class MockHTTPErrors(Mock):
     def __call__(self, url, *args, **kwargs):
         if 'test' in url:               # mock api valid check as usual
             return mock_urllib(url)
@@ -53,6 +54,14 @@ def __call__(self, url, *args, **kwargs):
             raise HTTPError(None, 429, None, None, None)
 
 
+class MockURLErrors(Mock):
+    def __call__(self, url, *args, **kwargs):
+        if 'test' in url:               # mock api valid check as usual
+            return mock_urllib(url)
+        else:                           # raise URLError errors for _fetch_page
+            raise URLError(None, None)
+
+
 @patch('urllib.request.urlopen', mock_urllib)
 class NYTTests(unittest.TestCase):
     API_KEY = 'api_key'
@@ -132,7 +141,7 @@ def should_break():
         self.assertEqual(len(c), BATCH_SIZE)
 
 
-@patch('urllib.request.urlopen', MockErrors())
+@patch('urllib.request.urlopen', MockHTTPErrors())
 class NYTTestsErrorRaising(unittest.TestCase):
     API_KEY = 'api_key'
 
@@ -181,6 +190,40 @@ def test_error_empty_result(self):
         self.assertIsNone(c)
 
 
+@patch('urllib.request.urlopen', MockURLErrors())
+class NYTTestsErrorRaising(unittest.TestCase):
+    API_KEY = 'api_key'
+
+    def test_url_errors(self):
+        nyt = NYT(self.API_KEY)
+        nyt.on_no_connection = MagicMock()
+        c = nyt.search('slovenia')
+        self.assertIsNone(c)
+        self.assertEqual(nyt.on_no_connection.call_count, 1)
+
+        nyt.on_no_connection = None
+        with self.assertRaises(URLError):
+            nyt.search('slovenia')
+
+
+class NYTTestsApiValidErrorRaising(unittest.TestCase):
+    API_KEY = 'api_key'
+
+    def setUp(self):
+        self.nyt = NYT(self.API_KEY)
+
+    def test_api_key_valid_errors(self):
+        errors = [
+            HTTPError(None, 429, None, None, None),
+            URLError(''),
+            HTTPException(),
+        ]
+
+        for e in errors:
+            with patch('urllib.request.urlopen', Mock(side_effect=e)):
+                self.assertFalse(self.nyt.api_key_valid())
+
+
 class Test403(unittest.TestCase):
     API_KEY = 'api_key'