Repository: sigmavirus24/github3.py

Issue:
`ratelimit_remaining` and `_remaining` return wrong number for `search`
GitHub API v3 [enforces different rate limits](https://developer.github.com/v3/#rate-limiting) for subsets of API calls. [The `search` API has custom rate limts.](https://developer.github.com/v3/search/#rate-limit)

`GitHubCore` has a property `remaining_ratelimit` which requests rate limit information from [endpoint `GET /rate_limit`](https://developer.github.com/v3/rate_limit/). That API call returns information about all rate limits but `GitHubCore.remaining_ratelimit` only exposes the one for the `core` resources and caches it in `GitHubCore._remaining`.

**This means, when calling `remaining_ratelimit` or reading `_remaining` on a search class, the wrong number of remaining requests is returned.** In these cases, not the `core` but the `search` rate limit should be made available.

One solution to fix this is:

- `GitHubCore` gets a new field (e.g. `_ratelimit_resource`) that indicates which kind of rate limit calls to a subclass are counted against.
- By default this field is set to `core`.
- Sub-classes of `GitHubCore` in `search/` set the field to `search`.
- `GitHubCore.remaining_ratelimit` uses that field to determine which rate limit to return and cache:
```diff
         json = self._json(self._get(self._github_url + '/rate_limit'), 200)
-        core = json.get('resources', {}).get('core', {})
+        core = json.get('resources', {}).get(self._ratelimit_resource, {})
         self._remaining = core.get('remaining', 0)
         return self._remaining
```

Discussion:

Patch:
diff --git a/github3/models.py b/github3/models.py
index ec4bf8bf..88c482de 100644
--- a/github3/models.py
+++ b/github3/models.py
@@ -31,6 +31,7 @@ class GitHubCore(object):
     basic attributes and methods to other sub-classes that are very useful to
     have.
     """
+    _ratelimit_resource = 'core'
 
     def __init__(self, json, session=None):
         if hasattr(session, 'session'):
@@ -305,7 +306,7 @@ class GitHubCore(object):
         :returns: int
         """
         json = self._json(self._get(self._github_url + '/rate_limit'), 200)
-        core = json.get('resources', {}).get('core', {})
+        core = json.get('resources', {}).get(self._ratelimit_resource, {})
         self._remaining = core.get('remaining', 0)
         return self._remaining
 
diff --git a/github3/structs.py b/github3/structs.py
index c3af7bd8..e9a25032 100644
--- a/github3/structs.py
+++ b/github3/structs.py
@@ -129,6 +129,7 @@ class SearchIterator(GitHubIterator):
     class. For other members and methods, check its parent class.
 
     """
+    _ratelimit_resource = 'search'
 
     def __init__(self, count, url, cls, session, params=None, etag=None,
                  headers=None):


Test patch:
diff --git a/tests/cassettes/GitHubCore_ratelimit_remaining_search.json b/tests/cassettes/GitHubCore_ratelimit_remaining_search.json
new file mode 100644
index 00000000..deab066b
--- /dev/null
+++ b/tests/cassettes/GitHubCore_ratelimit_remaining_search.json
@@ -0,0 +1,1 @@
+{"recorded_with": "betamax/0.8.0", "http_interactions": [{"response": {"body": {"base64_string": "H4sIAAAAAAAAA6tWKkotzi8tSk4tVrKqVkrOL0oF0TmZuZklSlZmBjpA+dzEzLzMvHQYtzgVKGNoamhmaWlibGhcq6NUnJpYlJyBpM8QVR+Ei6TPwNwcpC+9KLEgozAHSSOqPjAP3TqgvqLEEpJdWQsAyulOc+oAAAA=", "encoding": "utf-8", "string": ""}, "url": "https://api.github.com/rate_limit", "headers": {"Cache-Control": "no-cache", "Access-Control-Expose-Headers": "ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval", "Access-Control-Allow-Origin": "*", "Content-Security-Policy": "default-src 'none'", "X-GitHub-Media-Type": "github.v3; param=full; format=json", "Content-Encoding": "gzip", "X-Content-Type-Options": "nosniff", "X-RateLimit-Limit": "60", "X-RateLimit-Reset": "1516994313", "Content-Type": "application/json; charset=utf-8", "X-XSS-Protection": "1; mode=block", "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "Date": "Fri, 26 Jan 2018 18:18:33 GMT", "X-Runtime-rack": "0.008218", "Transfer-Encoding": "chunked", "X-GitHub-Request-Id": "D196:12414:2B7A313:507FFAA:5A6B70F9", "Status": "200 OK", "Server": "GitHub.com", "X-Frame-Options": "deny", "X-RateLimit-Remaining": "60"}, "status": {"message": "OK", "code": 200}}, "recorded_at": "2018-01-26T18:18:33", "request": {"body": {"encoding": "utf-8", "string": ""}, "method": "GET", "uri": "https://api.github.com/rate_limit", "headers": {"Connection": "keep-alive", "Content-Type": "application/json", "User-Agent": "github3.py/1.0.0a4", "Accept-Charset": "utf-8", "Accept-Encoding": "gzip, deflate", "Accept": "application/vnd.github.v3.full+json"}}}, {"response": {"body": {"base64_string": "H4sIAAAAAAAAA6tWKkotzi8tSk4tVrKqVkrOL0oF0TmZuZklSlZmBjpA+dzEzLzMvHQYtzgVKGNoamhmaWlibGhcq6NUnJpYlJyBpM8QVR+Ei6TPwNwcpC+9KLEgozAHSSOqPjAP3TqgvqLEEpJdWQsAyulOc+oAAAA=", "encoding": "utf-8", "string": ""}, "url": "https://api.github.com/rate_limit", "headers": {"Cache-Control": "no-cache", "Access-Control-Expose-Headers": "ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval", "Access-Control-Allow-Origin": "*", "Content-Security-Policy": "default-src 'none'", "X-GitHub-Media-Type": "github.v3; param=full; format=json", "Content-Encoding": "gzip", "X-Content-Type-Options": "nosniff", "X-RateLimit-Limit": "60", "X-RateLimit-Reset": "1516994313", "Content-Type": "application/json; charset=utf-8", "X-XSS-Protection": "1; mode=block", "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "Date": "Fri, 26 Jan 2018 18:18:33 GMT", "X-Runtime-rack": "0.010151", "Transfer-Encoding": "chunked", "X-GitHub-Request-Id": "D196:12414:2B7A31F:507FFBB:5A6B70F9", "Status": "200 OK", "Server": "GitHub.com", "X-Frame-Options": "deny", "X-RateLimit-Remaining": "60"}, "status": {"message": "OK", "code": 200}}, "recorded_at": "2018-01-26T18:18:33", "request": {"body": {"encoding": "utf-8", "string": ""}, "method": "GET", "uri": "https://api.github.com/rate_limit", "headers": {"Connection": "keep-alive", "Content-Type": "application/json", "User-Agent": "github3.py/1.0.0a4", "Accept-Charset": "utf-8", "Accept-Encoding": "gzip, deflate", "Accept": "application/vnd.github.v3.full+json"}}}]}
\ No newline at end of file
diff --git a/tests/integration/test_github_core.py b/tests/integration/test_github_core.py
index 9446aa58..0ecf6a86 100644
--- a/tests/integration/test_github_core.py
+++ b/tests/integration/test_github_core.py
@@ -6,3 +6,31 @@ class TestGitHubCore(IntegrationHelper):
         cassette_name = self.cassette_name('ratelimit_remaining')
         with self.recorder.use_cassette(cassette_name):
             assert self.gh.ratelimit_remaining > 0
+
+    def test_ratelimit_remaining_search(self):
+        """Test if search iterators return search ratelimit"""
+
+        def _get_ratelimit(resource):
+            resources = self.gh.rate_limit().get('resources', {})
+            rate_limit = resources.get(resource, {})
+            return rate_limit.get('remaining', -1)
+
+        cassette_name = self.cassette_name('ratelimit_remaining_search')
+
+        # Run cassette to get correct remaining rate limit from responses.
+        with self.recorder.use_cassette(cassette_name):
+            correct_ratelimit_search = _get_ratelimit('search')
+            correct_ratelimit_core = _get_ratelimit('core')
+
+        # Re-run cassette to test functions under test.
+        with self.recorder.use_cassette(cassette_name):
+            result_iterator = self.gh.search_code(
+                'HTTPAdapter in:file language:python'
+                ' repo:kennethreitz/requests'
+                )
+            ratelimit_remaining_search = result_iterator.ratelimit_remaining
+            ratelimit_remaining_core = self.gh.ratelimit_remaining
+
+            assert ratelimit_remaining_search != ratelimit_remaining_core
+            assert ratelimit_remaining_core == correct_ratelimit_core
+            assert ratelimit_remaining_search == correct_ratelimit_search