Skip to content

Commit d8a3f69

Browse files
devin-ai-integration[bot]bot_apklazebnyioctavia-squidington-iiidarynaishchenko
authored
fix(source-github): prevent blocking sleep during rate limit handling (#74758)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai> Co-authored-by: Serhii Lazebnyi <gl_serhii.lazebnyi@airbyte.io> Co-authored-by: Octavia Squidington III <octavia-squidington-iii@users.noreply.github.com> Co-authored-by: Serhii Lazebnyi <serglazebny@gmail.com> Co-authored-by: Daryna Ishchenko <darina.ishchenko17@gmail.com>
1 parent a0c130f commit d8a3f69

8 files changed

Lines changed: 225 additions & 22 deletions

File tree

airbyte-integrations/connectors/source-github/metadata.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ data:
1010
connectorSubtype: api
1111
connectorType: source
1212
definitionId: ef69ef6e-aa7f-4af1-a01d-ef775033524e
13-
dockerImageTag: 2.1.21
13+
dockerImageTag: 2.1.22
1414
dockerRepository: airbyte/source-github
1515
documentationUrl: https://docs.airbyte.com/integrations/sources/github
1616
erdUrl: https://dbdocs.io/airbyteio/source-github?view=relationships

airbyte-integrations/connectors/source-github/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ requires = [ "poetry-core>=1.0.0",]
33
build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
6-
version = "2.1.21"
6+
version = "2.1.22"
77
name = "source-github"
88
description = "Source implementation for GitHub."
99
authors = [ "Airbyte <contact@airbyte.io>",]

airbyte-integrations/connectors/source-github/source_github/source.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def streams(self, config: Mapping[str, Any]) -> List[Stream]:
241241
# This parameter is deprecated and in future will be used sane default, page_size: 10
242242
page_size = config.get("page_size_for_large_streams", constants.DEFAULT_PAGE_SIZE_FOR_LARGE_STREAM)
243243
access_token_type, _ = self.get_access_token(config)
244-
max_waiting_time = config.get("max_waiting_time", 10) * 60
244+
max_waiting_time = config.get("max_waiting_time", 120) * 60
245245
organization_args = {
246246
"authenticator": authenticator,
247247
"organizations": organizations,

airbyte-integrations/connectors/source-github/source_github/spec.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,11 @@
135135
"max_waiting_time": {
136136
"type": "integer",
137137
"title": "Max Waiting Time (in minutes)",
138-
"examples": [10, 30, 60],
139-
"default": 10,
138+
"examples": [10, 60, 120],
139+
"default": 120,
140140
"minimum": 1,
141-
"maximum": 60,
142-
"description": "Max Waiting Time for rate limit. Set higher value to wait till rate limits will be resetted to continue sync",
141+
"maximum": 240,
142+
"description": "Max time (in minutes) the connector will wait when all API tokens are rate-limited before failing. GitHub rate limits reset every 60 minutes, so values above 60 allow the connector to wait for a full reset cycle.",
143143
"order": 5
144144
}
145145
}

airbyte-integrations/connectors/source-github/source_github/streams.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,11 @@ def read_records(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> Iter
190190

191191
self.logger.warning(error_msg)
192192
except GitHubAPILimitException as e:
193-
internal_message = (
194-
f"Stream: `{self.name}`, slice: `{stream_slice}`. Limits for all provided tokens are reached, please try again later"
195-
)
196-
message = "Rate Limits for all provided tokens are reached. For more information please refer to documentation: https://docs.airbyte.com/integrations/sources/github#limitations--troubleshooting"
197-
raise AirbyteTracedException(internal_message=internal_message, message=message, failure_type=FailureType.config_error) from e
193+
internal_message = f"Stream: `{self.name}`, slice: `{stream_slice}`. {e}"
194+
message = "Rate limit exceeded for all configured GitHub API tokens."
195+
raise AirbyteTracedException(
196+
internal_message=internal_message, message=message, failure_type=FailureType.transient_error
197+
) from e
198198

199199

200200
class GithubStream(GithubStreamABC):

airbyte-integrations/connectors/source-github/source_github/utils.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,16 @@ class MultipleTokenAuthenticatorWithRateLimiter(AbstractHeaderAuthenticator):
5656
If a token exceeds the capacity limit, the system switches to another token.
5757
If all tokens are exhausted, the system will enter a sleep state until
5858
the first token becomes available again.
59+
60+
An API budget mechanism throttles requests proactively: when a token's
61+
remaining quota drops below a configurable reserve, a small delay is
62+
injected before the request so that the connector never fully exhausts
63+
all tokens at once.
5964
"""
6065

6166
DURATION = timedelta(seconds=3600) # Duration at which the current rate limit window resets
67+
BUDGET_RESERVE_FRACTION = 0.1 # Start throttling when only 10% of quota remains
68+
BUDGET_MIN_RESERVE = 50 # Always keep at least this many calls in reserve per token
6269

6370
def __init__(self, tokens: List[str], auth_method: str = "token", auth_header: str = "Authorization"):
6471
self._logger = logging.getLogger("airbyte")
@@ -72,7 +79,8 @@ def __init__(self, tokens: List[str], auth_method: str = "token", auth_header: s
7279
self.check_all_tokens()
7380
self._tokens_iter = cycle(self._tokens)
7481
self._active_token = next(self._tokens_iter)
75-
self._max_time = 60 * 10 # 10 minutes as default
82+
self._max_time = 60 * 120 # 120 minutes as default (must exceed GitHub's 60-min rate limit window)
83+
self._budget_logged = False # avoid log spam for throttle messages
7684

7785
def _initialize_http_clients(self, tokens: List[str]) -> Mapping[str, HttpClient]:
7886
return {
@@ -169,15 +177,94 @@ def _check_token_limits(self, token: str):
169177
def check_all_tokens(self):
170178
for token in self._tokens:
171179
self._check_token_limits(token)
180+
self._budget_logged = False
181+
182+
def _get_budget_reserve(self, token: Token, count_attr: str) -> int:
183+
"""Return the minimum number of calls to keep in reserve for a token.
184+
185+
The reserve is the larger of ``BUDGET_MIN_RESERVE`` and
186+
``BUDGET_RESERVE_FRACTION`` of the token's actual remaining count.
187+
We use the current remaining value as an approximation when the
188+
original limit is unknown.
189+
"""
190+
remaining = getattr(token, count_attr)
191+
# Use 5000 (GitHub default) as the basis, but fall back to the
192+
# remaining count if it's higher (e.g. enterprise tokens).
193+
limit_estimate = max(5000, remaining)
194+
return max(self.BUDGET_MIN_RESERVE, int(limit_estimate * self.BUDGET_RESERVE_FRACTION))
195+
196+
def _apply_budget_throttle(self, token: Token, count_attr: str, reset_attr: str) -> None:
197+
"""Optionally sleep a little to spread remaining calls over the reset window.
198+
199+
When the remaining count for *all* tokens is below the budget
200+
reserve, we inject a short delay proportional to how much time
201+
remains until the rate-limit window resets. This avoids hitting
202+
the wall and having to do a long blocking sleep.
203+
"""
204+
reserve = self._get_budget_reserve(token, count_attr)
205+
remaining = getattr(token, count_attr)
206+
if remaining > reserve:
207+
return # plenty of headroom — no throttling needed
208+
209+
# Only throttle when *every* token is running low so that we don't
210+
# slow down needlessly while other tokens still have capacity.
211+
if not all(getattr(t, count_attr) <= self._get_budget_reserve(t, count_attr) for t in self._tokens.values()):
212+
return
213+
214+
# Calculate a proportional delay: spread the remaining calls evenly
215+
# across the time left until the earliest reset.
216+
seconds_to_reset = max((getattr(token, reset_attr) - ab_datetime_now()).total_seconds(), 0)
217+
total_remaining = sum(max(getattr(t, count_attr), 0) for t in self._tokens.values())
218+
if total_remaining <= 0 or seconds_to_reset <= 0:
219+
return
220+
221+
delay = seconds_to_reset / total_remaining
222+
# Cap the delay to avoid extremely long pauses on single requests
223+
delay = min(delay, 10.0)
224+
if delay >= 0.1:
225+
if not self._budget_logged:
226+
self._logger.info(
227+
"API budget: throttling requests (%.1fs delay). %d calls remaining across %d token(s), " "%.0fs until reset.",
228+
delay,
229+
total_remaining,
230+
len(self._tokens),
231+
seconds_to_reset,
232+
)
233+
self._budget_logged = True
234+
time.sleep(delay)
235+
236+
HEARTBEAT_INTERVAL = 60.0 # Log every 60s during exhaustion sleep
237+
238+
def _sleep_with_heartbeat(self, total_seconds: float, count_attr: str) -> None:
239+
"""Sleep for *total_seconds* but log progress periodically so the
240+
platform heartbeat stays alive and operators can see the connector
241+
is not stuck."""
242+
remaining = total_seconds
243+
while remaining > 0:
244+
chunk = min(remaining, self.HEARTBEAT_INTERVAL)
245+
time.sleep(chunk)
246+
remaining -= chunk
247+
if remaining > 0:
248+
self._logger.info(
249+
"Rate limit exhausted (%s). Waiting for reset — %.0fs remaining.",
250+
count_attr,
251+
remaining,
252+
)
172253

173254
def process_token(self, current_token, count_attr, reset_attr):
174255
if getattr(current_token, count_attr) > 0:
256+
self._apply_budget_throttle(current_token, count_attr, reset_attr)
175257
setattr(current_token, count_attr, getattr(current_token, count_attr) - 1)
176258
return True
177259
elif all(getattr(x, count_attr) == 0 for x in self._tokens.values()):
178260
min_time_to_wait = min((getattr(x, reset_attr) - ab_datetime_now()).total_seconds() for x in self._tokens.values())
179261
if min_time_to_wait < self.max_time:
180-
time.sleep(min_time_to_wait if min_time_to_wait > 0 else 0)
262+
self._logger.info(
263+
"All tokens exhausted (%s). Sleeping %.0fs until rate limit resets.",
264+
count_attr,
265+
max(min_time_to_wait, 0),
266+
)
267+
self._sleep_with_heartbeat(max(min_time_to_wait, 0), count_attr)
181268
self.check_all_tokens()
182269
else:
183270
raise GitHubAPILimitException(f"Rate limits for all tokens ({count_attr}) were reached")

airbyte-integrations/connectors/source-github/unit_tests/test_multiple_token_authenticator.py

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def test_authenticator_counter(rate_limit_mock_response, requests_mock):
4343
assert authenticator._tokens["token1"].count_rest == 4998
4444

4545

46-
def test_multiple_token_authenticator_with_rate_limiter(requests_mock):
46+
@patch("time.sleep")
47+
def test_multiple_token_authenticator_with_rate_limiter(sleep_mock, requests_mock):
4748
"""
4849
This test ensures that:
4950
1. The rate limiter iterates over all tokens one-by-one after the previous is fully drained.
@@ -89,19 +90,17 @@ def request_callback_orgs(request, context):
8990
with pytest.raises(AirbyteTracedException) as e:
9091
list(read_full_refresh(stream))
9192
assert [(x.count_rest, x.count_graphql) for x in authenticator._tokens.values()] == [(0, 500), (0, 500), (0, 500)]
92-
message = (
93-
"Stream: `organizations`, slice: `{'organization': 'org1'}`. Limits for all provided tokens are reached, please try again later"
94-
)
95-
assert e.value.failure_type == FailureType.config_error
96-
assert e.value.internal_message == message
93+
assert e.value.failure_type == FailureType.transient_error
94+
assert "Stream: `organizations`" in e.value.internal_message
95+
assert "Rate limits for all tokens" in e.value.internal_message
9796

9897

9998
@freeze_time("2021-01-01 12:00:00")
10099
@patch("time.sleep")
101100
def test_multiple_token_authenticator_with_rate_limiter_and_sleep(sleep_mock, caplog, requests_mock):
102101
"""
103102
This test ensures that:
104-
1. The rate limiter will only wait (sleep) for token availability if the nearest available token appears within 600 seconds (see max_time).
103+
1. The rate limiter will only wait (sleep) for token availability if the nearest available token appears within max_time (default 7200 seconds / 120 minutes).
105104
2. Token Counter is reset to new values after 1500 requests were made and last token is still in use.
106105
"""
107106

@@ -147,7 +146,18 @@ def request_callback_orgs(request, context):
147146
)
148147

149148
list(read_full_refresh(stream))
150-
sleep_mock.assert_called_once_with(ACCEPTED_WAITING_TIME_IN_SECONDS)
149+
# The exhaustion sleep is now chunked into heartbeat intervals.
150+
# Verify total sleep time adds up to the expected wait time.
151+
# Budget-throttle sleeps (small fractional values) may also appear.
152+
all_sleeps = [c.args[0] for c in sleep_mock.call_args_list]
153+
total_sleep = sum(all_sleeps)
154+
# Total sleep should be at least the expected waiting time (budget throttle adds small extras)
155+
assert (
156+
total_sleep >= ACCEPTED_WAITING_TIME_IN_SECONDS
157+
), f"Expected total sleep >= {ACCEPTED_WAITING_TIME_IN_SECONDS}s, got {total_sleep:.1f}s"
158+
# Verify heartbeat chunking: there should be multiple sleep calls for the exhaustion wait
159+
heartbeat_sleeps = [s for s in all_sleeps if s >= 1.0]
160+
assert len(heartbeat_sleeps) > 1, "Expected multiple heartbeat sleep chunks, not a single blocking sleep"
151161
assert [(x.count_rest, x.count_graphql) for x in authenticator._tokens.values()] == [(500, 500), (500, 500), (498, 500)]
152162

153163

@@ -167,3 +177,108 @@ def test_invalid_credentials_error_message(requests_mock):
167177
MultipleTokenAuthenticatorWithRateLimiter(tokens=["token1", "token2", "token3"])
168178

169179
assert "HTTP Status Code: 401" in e.value.message
180+
181+
182+
@freeze_time("2021-01-01 12:00:00")
183+
@patch("time.sleep")
184+
def test_api_budget_throttles_when_tokens_run_low(sleep_mock, requests_mock):
185+
"""
186+
Verify that the API budget mechanism injects small delays once all
187+
tokens drop below the reserve threshold, preventing full exhaustion.
188+
"""
189+
low_remaining = 30 # Below BUDGET_MIN_RESERVE (50)
190+
reset_time = int((ab_datetime_now() + timedelta(seconds=300)).timestamp())
191+
192+
requests_mock.get(
193+
"https://api.github.com/rate_limit",
194+
json={
195+
"resources": {
196+
"core": {"limit": 5000, "used": 4970, "remaining": low_remaining, "reset": reset_time},
197+
"graphql": {"limit": 5000, "used": 0, "remaining": 5000, "reset": reset_time},
198+
}
199+
},
200+
)
201+
202+
authenticator = MultipleTokenAuthenticatorWithRateLimiter(tokens=["token1"])
203+
token = authenticator._tokens["token1"]
204+
assert token.count_rest == low_remaining
205+
206+
# Simulate a single request — budget throttle should fire because
207+
# remaining (30) < BUDGET_MIN_RESERVE (50)
208+
requests_mock.get("https://api.github.com/orgs/org1", json={"id": 1})
209+
organization_args = {"organizations": ["org1"], "authenticator": authenticator}
210+
stream = Organizations(**organization_args)
211+
list(read_full_refresh(stream))
212+
213+
# time.sleep should have been called with a positive delay for throttling
214+
assert sleep_mock.call_count >= 1
215+
for call in sleep_mock.call_args_list:
216+
assert call.args[0] > 0
217+
218+
219+
@freeze_time("2021-01-01 12:00:00")
220+
@patch("time.sleep")
221+
def test_api_budget_does_not_throttle_with_headroom(sleep_mock, requests_mock):
222+
"""
223+
Verify that no throttling occurs when tokens have plenty of remaining calls.
224+
"""
225+
reset_time = int((ab_datetime_now() + timedelta(seconds=3600)).timestamp())
226+
227+
requests_mock.get(
228+
"https://api.github.com/rate_limit",
229+
json={
230+
"resources": {
231+
"core": {"limit": 5000, "used": 0, "remaining": 5000, "reset": reset_time},
232+
"graphql": {"limit": 5000, "used": 0, "remaining": 5000, "reset": reset_time},
233+
}
234+
},
235+
)
236+
237+
authenticator = MultipleTokenAuthenticatorWithRateLimiter(tokens=["token1"])
238+
requests_mock.get("https://api.github.com/orgs/org1", json={"id": 1})
239+
organization_args = {"organizations": ["org1"], "authenticator": authenticator}
240+
stream = Organizations(**organization_args)
241+
list(read_full_refresh(stream))
242+
243+
# No throttle delay should have been introduced
244+
sleep_mock.assert_not_called()
245+
246+
247+
@freeze_time("2021-01-01 12:00:00")
248+
@patch("time.sleep")
249+
def test_api_budget_no_throttle_when_some_tokens_have_headroom(sleep_mock, requests_mock):
250+
"""
251+
When only some tokens are below the reserve but others are not, no
252+
throttling should occur — the connector should just rotate to a
253+
healthy token.
254+
"""
255+
reset_time = int((ab_datetime_now() + timedelta(seconds=300)).timestamp())
256+
call_count = 0
257+
258+
def rate_limit_callback(request, context):
259+
nonlocal call_count
260+
call_count += 1
261+
# First token: low remaining. Second token: plenty of headroom.
262+
if call_count <= 1:
263+
remaining = 20
264+
else:
265+
remaining = 4000
266+
return json.dumps(
267+
{
268+
"resources": {
269+
"core": {"limit": 5000, "used": 5000 - remaining, "remaining": remaining, "reset": reset_time},
270+
"graphql": {"limit": 5000, "used": 0, "remaining": 5000, "reset": reset_time},
271+
}
272+
}
273+
)
274+
275+
requests_mock.get("https://api.github.com/rate_limit", text=rate_limit_callback)
276+
authenticator = MultipleTokenAuthenticatorWithRateLimiter(tokens=["token_low", "token_high"])
277+
278+
requests_mock.get("https://api.github.com/orgs/org1", json={"id": 1})
279+
organization_args = {"organizations": ["org1"], "authenticator": authenticator}
280+
stream = Organizations(**organization_args)
281+
list(read_full_refresh(stream))
282+
283+
# No throttle — the second token still has headroom
284+
sleep_mock.assert_not_called()

docs/integrations/sources/github.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ Your token should have at least the `repo` scope. Depending on which streams you
229229

230230
| Version | Date | Pull Request | Subject |
231231
|:-----------|:-----------|:------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
232+
| 2.1.22 | 2026-04-22 | [74758](https://github.com/airbytehq/airbyte/pull/74758) | Fix rate limit sleep blocking heartbeat; classify rate limit errors as transient; increase default max_waiting_time to 120 minutes to cover GitHub's hourly rate limit window |
232233
| 2.1.21 | 2026-04-21 | [74586](https://github.com/airbytehq/airbyte/pull/74586) | Update dependencies |
233234
| 2.1.20 | 2026-04-13 | [76275](https://github.com/airbytehq/airbyte/pull/76275) | Bump airbyte-cdk to >=7.12.0 to fix OAuth `scope=null` in consent URL |
234235
| 2.1.19 | 2026-04-06 | [76090](https://github.com/airbytehq/airbyte/pull/76090) | Fix 403 permission errors misclassified as retryable, causing infinite retry loops |

0 commit comments

Comments
 (0)