Repository: recurly/recurly-client-python

Issue:
RateLimiter Exception 
When I reach the rate-limit I get a no-explicit error message.

The error that I get is the following:
`
self.rate_limit = int(self.__headers.get("X-RateLimit-Limit"))
TypeError: int() argument must be a string, a bytes-like object or a number, not 'NoneType'
`

**Your Environment**

- 3.4.2
- python3.8


Comments:

Patch:
diff --git a/recurly/pager.py b/recurly/pager.py
index 1102023..02d6d8b 100644
--- a/recurly/pager.py
+++ b/recurly/pager.py
@@ -82,7 +82,7 @@ class Pager:
         """Makes a HEAD request to the API to determine how many total records exist.
         """
         resource = self.__client._make_request("HEAD", self.__path, None, self.__params)
-        return int(resource.get_response().total_records)
+        return resource.get_response().total_records
 
     def __map_array_params(self, params):
         """Converts array parameters to CSV strings to maintain consistency with
diff --git a/recurly/response.py b/recurly/response.py
index a850c51..c42ee80 100644
--- a/recurly/response.py
+++ b/recurly/response.py
@@ -1,7 +1,28 @@
-import sys, traceback
 from datetime import datetime
 import recurly
-import json
+
+
+def parse_int_header(dictionary, key):
+    """Defensively parse an int header"""
+    val = dictionary.get(key)
+
+    if val is None or not isinstance(val, str):
+        return None
+
+    if not val.isdigit():
+        return None
+
+    return int(val)
+
+
+def parse_datetime_header(dictionary, key):
+    """Defensively parse datetime header"""
+
+    int_val = parse_int_header(dictionary, key)
+    if int_val is None:
+        return None
+
+    return datetime.utcfromtimestamp(int_val)
 
 
 class Response:
@@ -12,29 +33,26 @@ class Response:
         self.status = response.status
         http_body = response.read()
         self.body = None
+        self.__headers = response.headers
+
+        self.request_id = self.__headers.get("X-Request-Id")
+        self.date = self.__headers.get("Date")
+
+        self.rate_limit = parse_int_header(self.__headers, "X-RateLimit-Limit")
+        self.rate_limit_remaining = parse_int_header(
+            self.__headers, "X-RateLimit-Remaining"
+        )
+        self.rate_limit_reset = parse_datetime_header(
+            self.__headers, "X-RateLimit-Reset"
+        )
+        self.content_type = self.__headers.get("Content-Type", "").split(";")[0]
+
+        self.proxy_metadata = {
+            "server": self.__headers.get("Server"),
+            "cf-ray": self.__headers.get("CF-RAY"),
+        }
+
+        self.total_records = parse_int_header(self.__headers, "Recurly-Total-Records")
 
-        try:
-            self.__headers = response.headers
-            self.request_id = self.__headers.get("X-Request-Id")
-            self.rate_limit = int(self.__headers.get("X-RateLimit-Limit"))
-            self.rate_limit_remaining = int(self.__headers.get("X-RateLimit-Remaining"))
-            self.rate_limit_reset = datetime.utcfromtimestamp(
-                int(self.__headers.get("X-RateLimit-Reset"))
-            )
-            self.date = self.__headers.get("Date")
-            self.content_type = self.__headers.get("Content-Type", "").split(";")[0]
-            self.proxy_metadata = {
-                "server": self.__headers.get("Server"),
-                "cf-ray": self.__headers.get("CF-RAY"),
-            }
-            self.total_records = self.__headers.get("recurly-total-records")
-            if http_body and len(http_body) > 0:
-                self.body = http_body
-        except:
-            # Re-raise the exception in strict-mode
-            if recurly.STRICT_MODE:
-                raise
-            # Log and ignore it in production, we don't want this to kill the whole request
-            else:
-                print("[WARNING][Recurly] Unexpected error parsing response metadata")
-                traceback.print_exc(file=sys.stdout)
+        if http_body and len(http_body) > 0:
+            self.body = http_body


Test patch:
diff --git a/tests/test_resource.py b/tests/test_resource.py
index 882d4bc..3abaa88 100644
--- a/tests/test_resource.py
+++ b/tests/test_resource.py
@@ -28,46 +28,6 @@ class TestResource(unittest.TestCase):
         with self.assertRaises(ValueError):
             self.assertEqual(cast(obj), obj)
 
-    def test_cast_from_response(self):
-        resp = MagicMock()
-        resp.headers = {
-            "X-Request-Id": "0av50sm5l2n2gkf88ehg",
-            "X-RateLimit-Limit": "2000",
-            "X-RateLimit-Remaining": "1985",
-            "X-RateLimit-Reset": "1564624560",
-            "Date": "Thu, 01 Aug 2019 01:26:44 GMT",
-            "Server": "cloudflare",
-            "CF-RAY": "4ff4b71268424738-EWR",
-        }
-
-        request = Request("GET", "/sites", {})
-        empty = cast({}, "Empty", Response(resp, request))
-        response = empty.get_response()
-
-        self.assertEqual(type(response), Response)
-        self.assertEqual(response.request_id, "0av50sm5l2n2gkf88ehg")
-        self.assertEqual(response.rate_limit, 2000)
-        self.assertEqual(response.rate_limit_remaining, 1985)
-        self.assertEqual(response.rate_limit_reset, datetime(2019, 8, 1, 1, 56))
-        self.assertEqual(response.date, "Thu, 01 Aug 2019 01:26:44 GMT")
-        self.assertEqual(response.proxy_metadata["server"], "cloudflare")
-        self.assertEqual(response.proxy_metadata["cf-ray"], "4ff4b71268424738-EWR")
-        self.assertEqual(response.request.method, "GET")
-        self.assertEqual(response.request.path, "/sites")
-        self.assertEqual(response.request.body, {})
-
-        resp = MagicMock()
-        resp.headers = {
-            "X-Request-Id": "abcd123",
-            "X-RateLimit-Limit": "invalid2000",
-            "X-RateLimit-Remaining": "1985",
-            "X-RateLimit-Reset": "1564624560",
-            "Date": "Thu, 01 Aug 2019 01:26:44 GMT",
-        }
-
-        with self.assertRaises(ValueError):
-            cast({}, "Empty", Response(resp, request))
-
     def test_cast_page(self):
         # should return a page of cast data
         page = cast(
diff --git a/tests/test_response.py b/tests/test_response.py
new file mode 100644
index 0000000..6b83b1d
--- /dev/null
+++ b/tests/test_response.py
@@ -0,0 +1,79 @@
+import unittest
+import recurly
+from datetime import datetime
+from recurly import Response, Request
+from unittest.mock import Mock, MagicMock
+
+
+class TestResponse(unittest.TestCase):
+    def test_init(self):
+        resp = MagicMock()
+        resp.headers = {
+            "X-Request-Id": "0av50sm5l2n2gkf88ehg",
+            "X-RateLimit-Limit": "2000",
+            "X-RateLimit-Remaining": "1985",
+            "X-RateLimit-Reset": "1564624560",
+            "Recurly-Total-Records": "100",
+            "Date": "Thu, 01 Aug 2019 01:26:44 GMT",
+            "Server": "cloudflare",
+            "CF-RAY": "4ff4b71268424738-EWR",
+        }
+
+        req = Request("GET", "/sites", {})
+        response = Response(resp, req)
+
+        self.assertEqual(type(response), Response)
+        self.assertEqual(response.request_id, "0av50sm5l2n2gkf88ehg")
+        self.assertEqual(response.rate_limit, 2000)
+        self.assertEqual(response.rate_limit_remaining, 1985)
+        self.assertEqual(response.rate_limit_reset, datetime(2019, 8, 1, 1, 56))
+        self.assertEqual(response.total_records, 100)
+        self.assertEqual(response.date, "Thu, 01 Aug 2019 01:26:44 GMT")
+        self.assertEqual(response.proxy_metadata["server"], "cloudflare")
+        self.assertEqual(response.proxy_metadata["cf-ray"], "4ff4b71268424738-EWR")
+        self.assertEqual(response.request.method, "GET")
+        self.assertEqual(response.request.path, "/sites")
+        self.assertEqual(response.request.body, {})
+
+    def test_init_with_invalid_headers(self):
+        resp = MagicMock()
+        resp.headers = {
+            "X-Request-Id": "0av50sm5l2n2gkf88ehg",
+            "X-RateLimit-Limit": "notanum",
+            "X-RateLimit-Remaining": "notanum",
+            "X-RateLimit-Reset": "notanum",
+            "recurly-total-records": "notanum",
+            "Date": "Thu, 01 Aug 2019 01:26:44 GMT",
+            "Server": "cloudflare",
+            "CF-RAY": "4ff4b71268424738-EWR",
+        }
+
+        req = Request("GET", "/sites", {})
+        response = Response(resp, req)
+
+        self.assertEqual(type(response), Response)
+        self.assertEqual(response.request_id, "0av50sm5l2n2gkf88ehg")
+        self.assertEqual(response.rate_limit, None)
+        self.assertEqual(response.rate_limit_remaining, None)
+        self.assertEqual(response.rate_limit_reset, None)
+        self.assertEqual(response.total_records, None)
+        self.assertEqual(response.date, "Thu, 01 Aug 2019 01:26:44 GMT")
+        self.assertEqual(response.proxy_metadata["server"], "cloudflare")
+        self.assertEqual(response.proxy_metadata["cf-ray"], "4ff4b71268424738-EWR")
+
+    def test_init_with_missing_headers(self):
+        resp = MagicMock()
+        resp.headers = {}
+
+        req = Request("GET", "/sites", {})
+        response = Response(resp, req)
+
+        self.assertEqual(type(response), Response)
+        self.assertEqual(response.request_id, None)
+        self.assertEqual(response.rate_limit, None)
+        self.assertEqual(response.rate_limit_remaining, None)
+        self.assertEqual(response.rate_limit_reset, None)
+        self.assertEqual(response.total_records, None)
+        self.assertEqual(response.date, None)
+        self.assertEqual(response.proxy_metadata["server"], None)
+        self.assertEqual(response.proxy_metadata["cf-ray"], None)