github-talent-mcp 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {github_talent_mcp-0.2.0/src/github_talent_mcp.egg-info → github_talent_mcp-0.3.0}/PKG-INFO +6 -9
  2. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/README.md +5 -8
  3. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/pyproject.toml +1 -1
  4. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/github_client.py +64 -10
  5. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0/src/github_talent_mcp.egg-info}/PKG-INFO +6 -9
  6. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/SOURCES.txt +1 -0
  7. github_talent_mcp-0.3.0/tests/test_rate_limit.py +93 -0
  8. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/LICENSE +0 -0
  9. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/setup.cfg +0 -0
  10. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/__init__.py +0 -0
  11. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/__main__.py +0 -0
  12. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/models.py +0 -0
  13. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/scoring.py +0 -0
  14. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/server.py +0 -0
  15. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/__init__.py +0 -0
  16. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/bulk.py +0 -0
  17. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/compare.py +0 -0
  18. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/contributors.py +0 -0
  19. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/outreach.py +0 -0
  20. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/profile.py +0 -0
  21. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/rank.py +0 -0
  22. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/score_jd.py +0 -0
  23. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/search.py +0 -0
  24. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/dependency_links.txt +0 -0
  25. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/entry_points.txt +0 -0
  26. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/requires.txt +0 -0
  27. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/top_level.txt +0 -0
  28. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/tests/test_bulk.py +0 -0
  29. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/tests/test_compare.py +0 -0
  30. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/tests/test_outreach.py +0 -0
  31. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/tests/test_score_jd.py +0 -0
  32. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/tests/test_scoring.py +0 -0
  33. {github_talent_mcp-0.2.0 → github_talent_mcp-0.3.0}/tests/test_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-talent-mcp
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: MCP server for searching, scoring, and ranking GitHub developers for technical recruiting
5
5
  Author: Carolina Cherry
6
6
  License: MIT
@@ -33,10 +33,13 @@ Dynamic: license-file
33
33
  [![Python 3.14+](https://img.shields.io/badge/Python-3.14+-blue.svg)](https://www.python.org)
34
34
  [![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-8A2BE2)](https://modelcontextprotocol.io)
35
35
  [![Claude](https://img.shields.io/badge/Built_for-Claude_by_Anthropic-d4a373)](https://claude.ai)
36
+ [![GitHub Copilot](https://img.shields.io/badge/Works_with-GitHub_Copilot-8957E5?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot)
36
37
  [![GitHub API](https://img.shields.io/badge/GitHub-REST_API_v3-181717?logo=github)](https://docs.github.com/en/rest)
37
38
 
38
39
  MCP server that searches, scores, and ranks GitHub developers for technical recruiting.
39
40
 
41
+ Works with **Claude** (Code & Desktop) and **GitHub Copilot** (CLI & desktop app) — any MCP client that speaks stdio.
42
+
40
43
  ## Demo
41
44
 
42
45
  https://github.com/user-attachments/assets/b2dbe9e0-26ee-4849-861a-4b5cb268facc
@@ -149,13 +152,7 @@ Restart Claude Desktop. The tools will appear in the toolbox icon.
149
152
 
150
153
  The [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) and the [GitHub Copilot app](https://github.com/features/ai/github-app) share one MCP config, so a single setup covers both.
151
154
 
152
- One command (Copilot CLI):
153
-
154
- ```bash
155
- copilot mcp add github-talent -e GITHUB_TOKEN=$GITHUB_TOKEN -- uvx github-talent-mcp
156
- ```
157
-
158
- Or add it manually to `~/.copilot/mcp-config.json`:
155
+ Add it to `~/.copilot/mcp-config.json` (global), or commit a `.copilot/mcp-config.json` in your repo — the Copilot app picks that up automatically:
159
156
 
160
157
  ```json
161
158
  {
@@ -175,7 +172,7 @@ Or add it manually to `~/.copilot/mcp-config.json`:
175
172
 
176
173
  Export your token first (`export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`) — Copilot only inherits `PATH`, so the `${GITHUB_TOKEN}` reference reads it from your shell. If `uvx` isn't on your `PATH`, use its absolute path as `command`.
177
174
 
178
- The **Copilot app** picks up this same config; it also syncs a repo's `.copilot/mcp-config.json` automatically and lets you add servers under Settings → MCP. Verify with `copilot mcp list` (terminal) or `/mcp show` (in a Copilot session) — you should see 8 tools under `github-talent`.
175
+ The **Copilot app** reads this same config and also lets you add servers under Settings → MCP. In a Copilot CLI session, run `/mcp add` to register interactively or `/mcp show` to verify — you should see 8 tools under `github-talent`.
179
176
 
180
177
  ## Try It
181
178
 
@@ -4,10 +4,13 @@
4
4
  [![Python 3.14+](https://img.shields.io/badge/Python-3.14+-blue.svg)](https://www.python.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-8A2BE2)](https://modelcontextprotocol.io)
6
6
  [![Claude](https://img.shields.io/badge/Built_for-Claude_by_Anthropic-d4a373)](https://claude.ai)
7
+ [![GitHub Copilot](https://img.shields.io/badge/Works_with-GitHub_Copilot-8957E5?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot)
7
8
  [![GitHub API](https://img.shields.io/badge/GitHub-REST_API_v3-181717?logo=github)](https://docs.github.com/en/rest)
8
9
 
9
10
  MCP server that searches, scores, and ranks GitHub developers for technical recruiting.
10
11
 
12
+ Works with **Claude** (Code & Desktop) and **GitHub Copilot** (CLI & desktop app) — any MCP client that speaks stdio.
13
+
11
14
  ## Demo
12
15
 
13
16
  https://github.com/user-attachments/assets/b2dbe9e0-26ee-4849-861a-4b5cb268facc
@@ -120,13 +123,7 @@ Restart Claude Desktop. The tools will appear in the toolbox icon.
120
123
 
121
124
  The [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) and the [GitHub Copilot app](https://github.com/features/ai/github-app) share one MCP config, so a single setup covers both.
122
125
 
123
- One command (Copilot CLI):
124
-
125
- ```bash
126
- copilot mcp add github-talent -e GITHUB_TOKEN=$GITHUB_TOKEN -- uvx github-talent-mcp
127
- ```
128
-
129
- Or add it manually to `~/.copilot/mcp-config.json`:
126
+ Add it to `~/.copilot/mcp-config.json` (global), or commit a `.copilot/mcp-config.json` in your repo — the Copilot app picks that up automatically:
130
127
 
131
128
  ```json
132
129
  {
@@ -146,7 +143,7 @@ Or add it manually to `~/.copilot/mcp-config.json`:
146
143
 
147
144
  Export your token first (`export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`) — Copilot only inherits `PATH`, so the `${GITHUB_TOKEN}` reference reads it from your shell. If `uvx` isn't on your `PATH`, use its absolute path as `command`.
148
145
 
149
- The **Copilot app** picks up this same config; it also syncs a repo's `.copilot/mcp-config.json` automatically and lets you add servers under Settings → MCP. Verify with `copilot mcp list` (terminal) or `/mcp show` (in a Copilot session) — you should see 8 tools under `github-talent`.
146
+ The **Copilot app** reads this same config and also lets you add servers under Settings → MCP. In a Copilot CLI session, run `/mcp add` to register interactively or `/mcp show` to verify — you should see 8 tools under `github-talent`.
150
147
 
151
148
  ## Try It
152
149
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "github-talent-mcp"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "MCP server for searching, scoring, and ranking GitHub developers for technical recruiting"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  import os
5
6
  import time
@@ -54,6 +55,59 @@ class GitHubClient:
54
55
  reset = resp.headers.get("X-RateLimit-Reset", "unknown")
55
56
  log.warning(f"Rate limit low: {remaining} remaining, resets at {reset}")
56
57
 
58
+ # -- Request wrapper with rate-limit backoff --
59
+
60
+ async def _get(
61
+ self,
62
+ url: str,
63
+ *,
64
+ params: dict | None = None,
65
+ headers: dict | None = None,
66
+ max_retries: int = 3,
67
+ ) -> httpx.Response:
68
+ """GET with backoff on GitHub rate limits (primary + secondary) and 5xx.
69
+
70
+ Respects Retry-After and X-RateLimit-Reset so a broad sourcing run paces
71
+ itself instead of erroring out mid-job. Each wait is capped (60s) so an
72
+ exhausted hourly quota fails fast rather than hanging the session.
73
+ """
74
+ resp = await self._client.get(url, params=params, headers=headers)
75
+ for attempt in range(max_retries):
76
+ if not self._should_retry(resp):
77
+ return resp
78
+ delay = self._retry_after_seconds(resp, attempt)
79
+ log.warning(
80
+ f"GitHub {resp.status_code} on {url} — backing off {delay:.0f}s "
81
+ f"(retry {attempt + 1}/{max_retries})"
82
+ )
83
+ await asyncio.sleep(delay)
84
+ resp = await self._client.get(url, params=params, headers=headers)
85
+ return resp
86
+
87
+ @staticmethod
88
+ def _should_retry(resp: httpx.Response) -> bool:
89
+ if resp.status_code >= 500:
90
+ return True
91
+ if resp.status_code in (403, 429):
92
+ # Secondary limit sends Retry-After; primary limit zeroes Remaining.
93
+ if resp.headers.get("Retry-After"):
94
+ return True
95
+ if resp.headers.get("X-RateLimit-Remaining") == "0":
96
+ return True
97
+ return False
98
+
99
+ @staticmethod
100
+ def _retry_after_seconds(resp: httpx.Response, attempt: int) -> float:
101
+ retry_after = resp.headers.get("Retry-After", "")
102
+ if retry_after.isdigit():
103
+ return min(float(retry_after), 60.0)
104
+ if resp.headers.get("X-RateLimit-Remaining") == "0":
105
+ reset = resp.headers.get("X-RateLimit-Reset", "")
106
+ if reset.isdigit():
107
+ wait = float(reset) - time.time()
108
+ return min(max(wait, 0.0), 60.0) + 1.0
109
+ return min(2.0 ** attempt, 30.0)
110
+
57
111
  # -- API methods --
58
112
 
59
113
  async def search_users(
@@ -81,7 +135,7 @@ class GitHubClient:
81
135
  # Recent activity should be verified via get_developer_profile.
82
136
 
83
137
  q = " ".join(parts)
84
- resp = await self._client.get(
138
+ resp = await self._get(
85
139
  "/search/users",
86
140
  params={"q": q, "per_page": per_page, "page": page, "sort": "followers", "order": "desc"},
87
141
  )
@@ -94,7 +148,7 @@ class GitHubClient:
94
148
  cached = self._cache_get(cache_key)
95
149
  if cached is not None:
96
150
  return cached
97
- resp = await self._client.get(f"/repos/{owner}/{repo}")
151
+ resp = await self._get(f"/repos/{owner}/{repo}")
98
152
  self._check_rate_limit(resp)
99
153
  resp.raise_for_status()
100
154
  data = resp.json()
@@ -106,7 +160,7 @@ class GitHubClient:
106
160
  cached = self._cache_get(cache_key)
107
161
  if cached is not None:
108
162
  return cached
109
- resp = await self._client.get(f"/users/{username}")
163
+ resp = await self._get(f"/users/{username}")
110
164
  self._check_rate_limit(resp)
111
165
  resp.raise_for_status()
112
166
  data = resp.json()
@@ -118,7 +172,7 @@ class GitHubClient:
118
172
  cached = self._cache_get(cache_key)
119
173
  if cached is not None:
120
174
  return cached
121
- resp = await self._client.get(
175
+ resp = await self._get(
122
176
  f"/users/{username}/repos",
123
177
  params={"per_page": per_page, "sort": "pushed", "direction": "desc", "type": "owner"},
124
178
  )
@@ -133,7 +187,7 @@ class GitHubClient:
133
187
  cached = self._cache_get(cache_key)
134
188
  if cached is not None:
135
189
  return cached
136
- resp = await self._client.get(f"/repos/{owner}/{repo}/languages")
190
+ resp = await self._get(f"/repos/{owner}/{repo}/languages")
137
191
  self._check_rate_limit(resp)
138
192
  resp.raise_for_status()
139
193
  data = resp.json()
@@ -147,7 +201,7 @@ class GitHubClient:
147
201
  return cached
148
202
  all_events: list[dict] = []
149
203
  for page in range(1, max_pages + 1):
150
- resp = await self._client.get(
204
+ resp = await self._get(
151
205
  f"/users/{username}/events/public",
152
206
  params={"per_page": 100, "page": page},
153
207
  )
@@ -170,7 +224,7 @@ class GitHubClient:
170
224
  cached = self._cache_get(cache_key)
171
225
  if cached is not None:
172
226
  return cached
173
- resp = await self._client.get(
227
+ resp = await self._get(
174
228
  "/search/commits",
175
229
  params={
176
230
  "q": f"author:{username} user:{username} committer-date:>={since_date}",
@@ -189,7 +243,7 @@ class GitHubClient:
189
243
  cached = self._cache_get(cache_key)
190
244
  if cached is not None:
191
245
  return cached
192
- resp = await self._client.get(
246
+ resp = await self._get(
193
247
  "/search/issues",
194
248
  params={
195
249
  "q": f"author:{username} type:pr created:>={since_date}",
@@ -208,7 +262,7 @@ class GitHubClient:
208
262
  if cached is not None:
209
263
  return cached
210
264
  try:
211
- resp = await self._client.get(
265
+ resp = await self._get(
212
266
  f"/repos/{username}/{username}/readme",
213
267
  headers={"Accept": "application/vnd.github.raw+json"},
214
268
  )
@@ -224,7 +278,7 @@ class GitHubClient:
224
278
  async def get_repo_contributors(
225
279
  self, owner: str, repo: str, per_page: int = 30,
226
280
  ) -> list[dict]:
227
- resp = await self._client.get(
281
+ resp = await self._get(
228
282
  f"/repos/{owner}/{repo}/contributors",
229
283
  params={"per_page": per_page},
230
284
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-talent-mcp
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: MCP server for searching, scoring, and ranking GitHub developers for technical recruiting
5
5
  Author: Carolina Cherry
6
6
  License: MIT
@@ -33,10 +33,13 @@ Dynamic: license-file
33
33
  [![Python 3.14+](https://img.shields.io/badge/Python-3.14+-blue.svg)](https://www.python.org)
34
34
  [![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-8A2BE2)](https://modelcontextprotocol.io)
35
35
  [![Claude](https://img.shields.io/badge/Built_for-Claude_by_Anthropic-d4a373)](https://claude.ai)
36
+ [![GitHub Copilot](https://img.shields.io/badge/Works_with-GitHub_Copilot-8957E5?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot)
36
37
  [![GitHub API](https://img.shields.io/badge/GitHub-REST_API_v3-181717?logo=github)](https://docs.github.com/en/rest)
37
38
 
38
39
  MCP server that searches, scores, and ranks GitHub developers for technical recruiting.
39
40
 
41
+ Works with **Claude** (Code & Desktop) and **GitHub Copilot** (CLI & desktop app) — any MCP client that speaks stdio.
42
+
40
43
  ## Demo
41
44
 
42
45
  https://github.com/user-attachments/assets/b2dbe9e0-26ee-4849-861a-4b5cb268facc
@@ -149,13 +152,7 @@ Restart Claude Desktop. The tools will appear in the toolbox icon.
149
152
 
150
153
  The [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) and the [GitHub Copilot app](https://github.com/features/ai/github-app) share one MCP config, so a single setup covers both.
151
154
 
152
- One command (Copilot CLI):
153
-
154
- ```bash
155
- copilot mcp add github-talent -e GITHUB_TOKEN=$GITHUB_TOKEN -- uvx github-talent-mcp
156
- ```
157
-
158
- Or add it manually to `~/.copilot/mcp-config.json`:
155
+ Add it to `~/.copilot/mcp-config.json` (global), or commit a `.copilot/mcp-config.json` in your repo — the Copilot app picks that up automatically:
159
156
 
160
157
  ```json
161
158
  {
@@ -175,7 +172,7 @@ Or add it manually to `~/.copilot/mcp-config.json`:
175
172
 
176
173
  Export your token first (`export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`) — Copilot only inherits `PATH`, so the `${GITHUB_TOKEN}` reference reads it from your shell. If `uvx` isn't on your `PATH`, use its absolute path as `command`.
177
174
 
178
- The **Copilot app** picks up this same config; it also syncs a repo's `.copilot/mcp-config.json` automatically and lets you add servers under Settings → MCP. Verify with `copilot mcp list` (terminal) or `/mcp show` (in a Copilot session) — you should see 8 tools under `github-talent`.
175
+ The **Copilot app** reads this same config and also lets you add servers under Settings → MCP. In a Copilot CLI session, run `/mcp add` to register interactively or `/mcp show` to verify — you should see 8 tools under `github-talent`.
179
176
 
180
177
  ## Try It
181
178
 
@@ -25,6 +25,7 @@ src/github_talent_mcp/tools/search.py
25
25
  tests/test_bulk.py
26
26
  tests/test_compare.py
27
27
  tests/test_outreach.py
28
+ tests/test_rate_limit.py
28
29
  tests/test_score_jd.py
29
30
  tests/test_scoring.py
30
31
  tests/test_search.py
@@ -0,0 +1,93 @@
1
+ import asyncio
2
+ import time
3
+ from unittest.mock import AsyncMock
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from github_talent_mcp.github_client import GitHubClient
9
+
10
+
11
+ def _resp(status: int, headers: dict | None = None) -> httpx.Response:
12
+ return httpx.Response(
13
+ status,
14
+ headers=headers or {},
15
+ request=httpx.Request("GET", "https://api.github.com/x"),
16
+ )
17
+
18
+
19
+ def _client_with(responses: list[httpx.Response]) -> GitHubClient:
20
+ client = GitHubClient.__new__(GitHubClient)
21
+ client._client = type("FakeHTTP", (), {})()
22
+ client._client.get = AsyncMock(side_effect=list(responses))
23
+ return client
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def no_sleep(monkeypatch):
28
+ """Capture backoff durations without actually waiting."""
29
+ slept: list[float] = []
30
+
31
+ async def fake_sleep(delay):
32
+ slept.append(delay)
33
+
34
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
35
+ return slept
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_retries_on_primary_rate_limit(no_sleep):
40
+ reset = str(int(time.time()) + 2)
41
+ client = _client_with([
42
+ _resp(403, {"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}),
43
+ _resp(200),
44
+ ])
45
+ resp = await client._get("/x")
46
+ assert resp.status_code == 200
47
+ assert client._client.get.await_count == 2
48
+ assert no_sleep and no_sleep[0] <= 61.0
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_retries_on_secondary_rate_limit_retry_after(no_sleep):
53
+ client = _client_with([
54
+ _resp(429, {"Retry-After": "3"}),
55
+ _resp(200),
56
+ ])
57
+ resp = await client._get("/x")
58
+ assert resp.status_code == 200
59
+ assert no_sleep == [3.0]
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_retries_on_5xx(no_sleep):
64
+ client = _client_with([_resp(502), _resp(200)])
65
+ resp = await client._get("/x")
66
+ assert resp.status_code == 200
67
+ assert client._client.get.await_count == 2
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_no_retry_on_404(no_sleep):
72
+ client = _client_with([_resp(404)])
73
+ resp = await client._get("/x")
74
+ assert resp.status_code == 404
75
+ assert client._client.get.await_count == 1
76
+ assert no_sleep == []
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_no_retry_on_403_with_quota_remaining(no_sleep):
81
+ # A 403 that is NOT a rate limit (e.g. permissions) should not retry.
82
+ client = _client_with([_resp(403, {"X-RateLimit-Remaining": "4999"})])
83
+ resp = await client._get("/x")
84
+ assert resp.status_code == 403
85
+ assert client._client.get.await_count == 1
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_gives_up_after_max_retries(no_sleep):
90
+ client = _client_with([_resp(403, {"Retry-After": "1"})] * 10)
91
+ resp = await client._get("/x", max_retries=3)
92
+ assert resp.status_code == 403
93
+ assert client._client.get.await_count == 4 # initial + 3 retries