github-backup 0.57.0__tar.gz → 0.58.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.
- {github_backup-0.57.0 → github_backup-0.58.0}/CHANGES.rst +30 -1
- {github_backup-0.57.0/github_backup.egg-info → github_backup-0.58.0}/PKG-INFO +1 -1
- github_backup-0.58.0/github_backup/__init__.py +1 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup/github_backup.py +167 -202
- {github_backup-0.57.0 → github_backup-0.58.0/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/SOURCES.txt +1 -0
- github_backup-0.58.0/release-requirements.txt +15 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_http_451.py +6 -49
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_pagination.py +7 -13
- github_backup-0.58.0/tests/test_retrieve_data.py +365 -0
- github_backup-0.57.0/github_backup/__init__.py +0 -1
- github_backup-0.57.0/release-requirements.txt +0 -40
- {github_backup-0.57.0 → github_backup-0.58.0}/LICENSE.txt +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/MANIFEST.in +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/README +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/README.rst +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup/__main__.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup/cli.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/entry_points.txt +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/requirements.txt +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/setup.cfg +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/setup.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_all_starred.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_attachments.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_case_sensitivity.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_json_dump_if_changed.py +0 -0
- {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_skip_assets_on.py +0 -0
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
-
0.
|
|
4
|
+
0.58.0 (2025-12-16)
|
|
5
5
|
-------------------
|
|
6
6
|
------------------------
|
|
7
|
+
- Fix retry logic for HTTP 5xx errors and network failures. [Rodos]
|
|
8
|
+
|
|
9
|
+
Refactors error handling to retry all 5xx errors (not just 502), network errors (URLError, socket.error, IncompleteRead), and JSON parse errors with exponential backoff and jitter. Respects retry-after and rate limit headers per GitHub API requirements. Consolidates retry logic into make_request_with_retry() wrapper and adds clear logging for retry attempts and failures. Removes dead code from 2016 (errors list, _request_http_error, _request_url_error) that was intentionally disabled in commit 1e5a9048 to fix #29.
|
|
10
|
+
|
|
11
|
+
Fixes #140, #110, #138
|
|
12
|
+
- Chore: remove transitive deps from release-requirements.txt. [Rodos]
|
|
13
|
+
- Chore(deps): bump urllib3 in the python-packages group.
|
|
14
|
+
[dependabot[bot]]
|
|
15
|
+
|
|
16
|
+
Bumps the python-packages group with 1 update: [urllib3](https://github.com/urllib3/urllib3).
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Updates `urllib3` from 2.6.1 to 2.6.2
|
|
20
|
+
- [Release notes](https://github.com/urllib3/urllib3/releases)
|
|
21
|
+
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
|
|
22
|
+
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.1...2.6.2)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
updated-dependencies:
|
|
26
|
+
- dependency-name: urllib3
|
|
27
|
+
dependency-version: 2.6.2
|
|
28
|
+
dependency-type: direct:production
|
|
29
|
+
update-type: version-update:semver-patch
|
|
30
|
+
dependency-group: python-packages
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
0.57.0 (2025-12-12)
|
|
35
|
+
-------------------
|
|
7
36
|
- Add GitHub Apps documentation and remove outdated header. [Rodos]
|
|
8
37
|
|
|
9
38
|
- Add GitHub Apps authentication section with setup steps
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.58.0"
|
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import logging
|
|
13
13
|
import os
|
|
14
14
|
import platform
|
|
15
|
+
import random
|
|
15
16
|
import re
|
|
16
17
|
import select
|
|
17
18
|
import socket
|
|
@@ -19,6 +20,7 @@ import ssl
|
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
23
|
+
from collections.abc import Generator
|
|
22
24
|
from datetime import datetime
|
|
23
25
|
from http.client import IncompleteRead
|
|
24
26
|
from urllib.error import HTTPError, URLError
|
|
@@ -74,6 +76,9 @@ else:
|
|
|
74
76
|
" 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
|
|
75
77
|
)
|
|
76
78
|
|
|
79
|
+
# Retry configuration
|
|
80
|
+
MAX_RETRIES = 5
|
|
81
|
+
|
|
77
82
|
|
|
78
83
|
def logging_subprocess(
|
|
79
84
|
popenargs, stdout_log_level=logging.DEBUG, stderr_log_level=logging.ERROR, **kwargs
|
|
@@ -603,170 +608,178 @@ def get_github_repo_url(args, repository):
|
|
|
603
608
|
return repo_url
|
|
604
609
|
|
|
605
610
|
|
|
606
|
-
def
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
+
def calculate_retry_delay(attempt, headers):
|
|
612
|
+
"""Calculate delay before next retry with exponential backoff."""
|
|
613
|
+
# Respect retry-after header if present
|
|
614
|
+
if retry_after := headers.get("retry-after"):
|
|
615
|
+
return int(retry_after)
|
|
611
616
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
request_per_page = per_page
|
|
617
|
+
# Respect rate limit reset time
|
|
618
|
+
if int(headers.get("x-ratelimit-remaining", 1)) < 1:
|
|
619
|
+
reset_time = int(headers.get("x-ratelimit-reset", 0))
|
|
620
|
+
return max(10, reset_time - calendar.timegm(time.gmtime()))
|
|
617
621
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
next_url or template,
|
|
622
|
-
auth,
|
|
623
|
-
as_app=args.as_app,
|
|
624
|
-
fine=True if args.token_fine is not None else False,
|
|
625
|
-
) # noqa
|
|
626
|
-
r, errors = _get_response(request, auth, next_url or template)
|
|
622
|
+
# Exponential backoff with jitter for server errors (1s base, 120s max)
|
|
623
|
+
delay = min(1.0 * (2**attempt), 120.0)
|
|
624
|
+
return delay + random.uniform(0, delay * 0.1)
|
|
627
625
|
|
|
628
|
-
status_code = int(r.getcode())
|
|
629
626
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
try:
|
|
634
|
-
response_data = json.loads(r.read().decode("utf-8"))
|
|
635
|
-
dmca_url = response_data.get("block", {}).get("html_url")
|
|
636
|
-
except Exception:
|
|
637
|
-
pass
|
|
638
|
-
raise RepositoryUnavailableError(
|
|
639
|
-
"Repository unavailable due to legal reasons (HTTP 451)",
|
|
640
|
-
dmca_url=dmca_url,
|
|
641
|
-
)
|
|
627
|
+
def retrieve_data(args, template, query_args=None, paginated=True):
|
|
628
|
+
"""
|
|
629
|
+
Fetch the data from GitHub API.
|
|
642
630
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
except json.decoder.JSONDecodeError:
|
|
650
|
-
logger.warning("JSON decode error detected")
|
|
651
|
-
read_error = True
|
|
652
|
-
except TimeoutError:
|
|
653
|
-
logger.warning("Tiemout error detected")
|
|
654
|
-
read_error = True
|
|
655
|
-
else:
|
|
656
|
-
read_error = False
|
|
631
|
+
Handle both single requests and pagination with yield of individual dicts.
|
|
632
|
+
Handles throttling, retries, read errors, and DMCA takedowns.
|
|
633
|
+
"""
|
|
634
|
+
query_args = query_args or {}
|
|
635
|
+
auth = get_auth(args, encode=not args.as_app)
|
|
636
|
+
per_page = 100
|
|
657
637
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
638
|
+
def _extract_next_page_url(link_header):
|
|
639
|
+
for link in link_header.split(","):
|
|
640
|
+
if 'rel="next"' in link:
|
|
641
|
+
return link[link.find("<") + 1:link.find(">")]
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
def fetch_all() -> Generator[dict, None, None]:
|
|
645
|
+
next_url = None
|
|
646
|
+
|
|
647
|
+
while True:
|
|
648
|
+
# FIRST: Fetch response
|
|
649
|
+
|
|
650
|
+
for attempt in range(MAX_RETRIES):
|
|
651
|
+
request = _construct_request(
|
|
652
|
+
per_page=per_page if paginated else None,
|
|
653
|
+
query_args=query_args,
|
|
654
|
+
template=next_url or template,
|
|
655
|
+
auth=auth,
|
|
656
|
+
as_app=args.as_app,
|
|
657
|
+
fine=args.token_fine is not None,
|
|
664
658
|
)
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
# Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
|
|
716
|
-
for link in link_header.split(","):
|
|
717
|
-
if 'rel="next"' in link:
|
|
718
|
-
next_url = link[link.find("<") + 1 : link.find(">")]
|
|
719
|
-
break
|
|
720
|
-
if not next_url:
|
|
721
|
-
break
|
|
722
|
-
elif type(response) is dict and single_request:
|
|
723
|
-
yield response
|
|
659
|
+
http_response = make_request_with_retry(request, auth)
|
|
660
|
+
|
|
661
|
+
match http_response.getcode():
|
|
662
|
+
case 200:
|
|
663
|
+
# Success - Parse JSON response
|
|
664
|
+
try:
|
|
665
|
+
response = json.loads(http_response.read().decode("utf-8"))
|
|
666
|
+
break # Exit retry loop and handle the data returned
|
|
667
|
+
except (
|
|
668
|
+
IncompleteRead,
|
|
669
|
+
json.decoder.JSONDecodeError,
|
|
670
|
+
TimeoutError,
|
|
671
|
+
) as e:
|
|
672
|
+
logger.warning(f"{type(e).__name__} reading response")
|
|
673
|
+
if attempt < MAX_RETRIES - 1:
|
|
674
|
+
delay = calculate_retry_delay(attempt, {})
|
|
675
|
+
logger.warning(
|
|
676
|
+
f"Retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
|
|
677
|
+
)
|
|
678
|
+
time.sleep(delay)
|
|
679
|
+
continue # Next retry attempt
|
|
680
|
+
|
|
681
|
+
case 451:
|
|
682
|
+
# DMCA takedown - extract URL if available, then raise
|
|
683
|
+
dmca_url = None
|
|
684
|
+
try:
|
|
685
|
+
response_data = json.loads(
|
|
686
|
+
http_response.read().decode("utf-8")
|
|
687
|
+
)
|
|
688
|
+
dmca_url = response_data.get("block", {}).get("html_url")
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
raise RepositoryUnavailableError(
|
|
692
|
+
"Repository unavailable due to legal reasons (HTTP 451)",
|
|
693
|
+
dmca_url=dmca_url,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
case _:
|
|
697
|
+
raise Exception(
|
|
698
|
+
f"API request returned HTTP {http_response.getcode()}: {http_response.reason}"
|
|
699
|
+
)
|
|
700
|
+
else:
|
|
701
|
+
logger.error(
|
|
702
|
+
f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
|
|
703
|
+
)
|
|
704
|
+
raise Exception(
|
|
705
|
+
f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# SECOND: Process and paginate
|
|
724
709
|
|
|
725
|
-
|
|
726
|
-
|
|
710
|
+
# Pause before next request if rate limit is low
|
|
711
|
+
if (
|
|
712
|
+
remaining := int(http_response.headers.get("x-ratelimit-remaining", 0))
|
|
713
|
+
) <= (args.throttle_limit or 0):
|
|
714
|
+
if args.throttle_limit:
|
|
715
|
+
logger.info(
|
|
716
|
+
f"Throttling: {remaining} requests left, pausing {args.throttle_pause}s"
|
|
717
|
+
)
|
|
718
|
+
time.sleep(args.throttle_pause)
|
|
727
719
|
|
|
728
|
-
|
|
729
|
-
|
|
720
|
+
# Yield results
|
|
721
|
+
if isinstance(response, list):
|
|
722
|
+
yield from response
|
|
723
|
+
elif isinstance(response, dict):
|
|
724
|
+
yield response
|
|
730
725
|
|
|
726
|
+
# Check for more pages
|
|
727
|
+
if not paginated or not (
|
|
728
|
+
next_url := _extract_next_page_url(
|
|
729
|
+
http_response.headers.get("Link", "")
|
|
730
|
+
)
|
|
731
|
+
):
|
|
732
|
+
break # No more data
|
|
731
733
|
|
|
732
|
-
|
|
733
|
-
return list(retrieve_data_gen(args, template, query_args, single_request))
|
|
734
|
+
return list(fetch_all())
|
|
734
735
|
|
|
735
736
|
|
|
736
|
-
def
|
|
737
|
-
|
|
738
|
-
query_args = {}
|
|
739
|
-
return query_args
|
|
737
|
+
def make_request_with_retry(request, auth):
|
|
738
|
+
"""Make HTTP request with automatic retry for transient errors."""
|
|
740
739
|
|
|
740
|
+
def is_retryable_status(status_code, headers):
|
|
741
|
+
# Server errors are always retryable
|
|
742
|
+
if status_code in (500, 502, 503, 504):
|
|
743
|
+
return True
|
|
744
|
+
# Rate limit (403/429) is retryable if limit exhausted
|
|
745
|
+
if status_code in (403, 429):
|
|
746
|
+
return int(headers.get("x-ratelimit-remaining", 1)) < 1
|
|
747
|
+
return False
|
|
741
748
|
|
|
742
|
-
|
|
743
|
-
retry_timeout = 3
|
|
744
|
-
errors = []
|
|
745
|
-
# We'll make requests in a loop so we can
|
|
746
|
-
# delay and retry in the case of rate-limiting
|
|
747
|
-
while True:
|
|
748
|
-
should_continue = False
|
|
749
|
+
for attempt in range(MAX_RETRIES):
|
|
749
750
|
try:
|
|
750
|
-
|
|
751
|
+
return urlopen(request, context=https_ctx)
|
|
752
|
+
|
|
751
753
|
except HTTPError as exc:
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
raise
|
|
759
|
-
except socket.error as e:
|
|
760
|
-
logger.warning(e.strerror)
|
|
761
|
-
should_continue, retry_timeout = _request_url_error(template, retry_timeout)
|
|
762
|
-
if not should_continue:
|
|
754
|
+
# HTTPError can be used as a response-like object
|
|
755
|
+
if not is_retryable_status(exc.code, exc.headers):
|
|
756
|
+
raise # Non-retryable error
|
|
757
|
+
|
|
758
|
+
if attempt >= MAX_RETRIES - 1:
|
|
759
|
+
logger.error(f"HTTP {exc.code} failed after {MAX_RETRIES} attempts")
|
|
763
760
|
raise
|
|
764
761
|
|
|
765
|
-
|
|
766
|
-
|
|
762
|
+
delay = calculate_retry_delay(attempt, exc.headers)
|
|
763
|
+
logger.warning(
|
|
764
|
+
f"HTTP {exc.code}, retrying in {delay:.1f}s "
|
|
765
|
+
f"(attempt {attempt + 1}/{MAX_RETRIES})"
|
|
766
|
+
)
|
|
767
|
+
if auth is None and exc.code in (403, 429):
|
|
768
|
+
logger.info("Hint: Authenticate to raise your GitHub rate limit")
|
|
769
|
+
time.sleep(delay)
|
|
767
770
|
|
|
768
|
-
|
|
769
|
-
|
|
771
|
+
except (URLError, socket.error) as e:
|
|
772
|
+
if attempt >= MAX_RETRIES - 1:
|
|
773
|
+
logger.error(f"Connection error failed after {MAX_RETRIES} attempts: {e}")
|
|
774
|
+
raise
|
|
775
|
+
delay = calculate_retry_delay(attempt, {})
|
|
776
|
+
logger.warning(
|
|
777
|
+
f"Connection error: {e}, retrying in {delay:.1f}s "
|
|
778
|
+
f"(attempt {attempt + 1}/{MAX_RETRIES})"
|
|
779
|
+
)
|
|
780
|
+
time.sleep(delay)
|
|
781
|
+
|
|
782
|
+
raise Exception(f"Request failed after {MAX_RETRIES} attempts") # pragma: no cover
|
|
770
783
|
|
|
771
784
|
|
|
772
785
|
def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
|
|
@@ -808,52 +821,6 @@ def _construct_request(per_page, query_args, template, auth, as_app=None, fine=F
|
|
|
808
821
|
return request
|
|
809
822
|
|
|
810
823
|
|
|
811
|
-
def _request_http_error(exc, auth, errors):
|
|
812
|
-
# HTTPError behaves like a Response so we can
|
|
813
|
-
# check the status code and headers to see exactly
|
|
814
|
-
# what failed.
|
|
815
|
-
|
|
816
|
-
should_continue = False
|
|
817
|
-
headers = exc.headers
|
|
818
|
-
limit_remaining = int(headers.get("x-ratelimit-remaining", 0))
|
|
819
|
-
|
|
820
|
-
if exc.code == 403 and limit_remaining < 1:
|
|
821
|
-
# The X-RateLimit-Reset header includes a
|
|
822
|
-
# timestamp telling us when the limit will reset
|
|
823
|
-
# so we can calculate how long to wait rather
|
|
824
|
-
# than inefficiently polling:
|
|
825
|
-
gm_now = calendar.timegm(time.gmtime())
|
|
826
|
-
reset = int(headers.get("x-ratelimit-reset", 0)) or gm_now
|
|
827
|
-
# We'll never sleep for less than 10 seconds:
|
|
828
|
-
delta = max(10, reset - gm_now)
|
|
829
|
-
|
|
830
|
-
limit = headers.get("x-ratelimit-limit")
|
|
831
|
-
logger.warning(
|
|
832
|
-
"Exceeded rate limit of {} requests; waiting {} seconds to reset".format(
|
|
833
|
-
limit, delta
|
|
834
|
-
)
|
|
835
|
-
) # noqa
|
|
836
|
-
|
|
837
|
-
if auth is None:
|
|
838
|
-
logger.info("Hint: Authenticate to raise your GitHub rate limit")
|
|
839
|
-
|
|
840
|
-
time.sleep(delta)
|
|
841
|
-
should_continue = True
|
|
842
|
-
return errors, should_continue
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
def _request_url_error(template, retry_timeout):
|
|
846
|
-
# In case of a connection timing out, we can retry a few time
|
|
847
|
-
# But we won't crash and not back-up the rest now
|
|
848
|
-
logger.info("'{}' timed out".format(template))
|
|
849
|
-
retry_timeout -= 1
|
|
850
|
-
|
|
851
|
-
if retry_timeout >= 0:
|
|
852
|
-
return True, retry_timeout
|
|
853
|
-
|
|
854
|
-
raise Exception("'{}' timed out to much, skipping!".format(template))
|
|
855
|
-
|
|
856
|
-
|
|
857
824
|
class S3HTTPRedirectHandler(HTTPRedirectHandler):
|
|
858
825
|
"""
|
|
859
826
|
A subclassed redirect handler for downloading Github assets from S3.
|
|
@@ -1503,7 +1470,7 @@ def download_attachments(
|
|
|
1503
1470
|
|
|
1504
1471
|
def get_authenticated_user(args):
|
|
1505
1472
|
template = "https://{0}/user".format(get_github_api_host(args))
|
|
1506
|
-
data = retrieve_data(args, template,
|
|
1473
|
+
data = retrieve_data(args, template, paginated=False)
|
|
1507
1474
|
return data[0]
|
|
1508
1475
|
|
|
1509
1476
|
|
|
@@ -1517,7 +1484,7 @@ def check_git_lfs_install():
|
|
|
1517
1484
|
|
|
1518
1485
|
def retrieve_repositories(args, authenticated_user):
|
|
1519
1486
|
logger.info("Retrieving repositories")
|
|
1520
|
-
|
|
1487
|
+
paginated = True
|
|
1521
1488
|
if args.user == authenticated_user["login"]:
|
|
1522
1489
|
# we must use the /user/repos API to be able to access private repos
|
|
1523
1490
|
template = "https://{0}/user/repos".format(get_github_api_host(args))
|
|
@@ -1540,16 +1507,16 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1540
1507
|
repo_path = args.repository
|
|
1541
1508
|
else:
|
|
1542
1509
|
repo_path = "{0}/{1}".format(args.user, args.repository)
|
|
1543
|
-
|
|
1510
|
+
paginated = False
|
|
1544
1511
|
template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
|
|
1545
1512
|
|
|
1546
|
-
repos = retrieve_data(args, template,
|
|
1513
|
+
repos = retrieve_data(args, template, paginated=paginated)
|
|
1547
1514
|
|
|
1548
1515
|
if args.all_starred:
|
|
1549
1516
|
starred_template = "https://{0}/users/{1}/starred".format(
|
|
1550
1517
|
get_github_api_host(args), args.user
|
|
1551
1518
|
)
|
|
1552
|
-
starred_repos = retrieve_data(args, starred_template
|
|
1519
|
+
starred_repos = retrieve_data(args, starred_template)
|
|
1553
1520
|
# flag each repo as starred for downstream processing
|
|
1554
1521
|
for item in starred_repos:
|
|
1555
1522
|
item.update({"is_starred": True})
|
|
@@ -1559,7 +1526,7 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1559
1526
|
gists_template = "https://{0}/users/{1}/gists".format(
|
|
1560
1527
|
get_github_api_host(args), args.user
|
|
1561
1528
|
)
|
|
1562
|
-
gists = retrieve_data(args, gists_template
|
|
1529
|
+
gists = retrieve_data(args, gists_template)
|
|
1563
1530
|
# flag each repo as a gist for downstream processing
|
|
1564
1531
|
for item in gists:
|
|
1565
1532
|
item.update({"is_gist": True})
|
|
@@ -1578,9 +1545,7 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1578
1545
|
starred_gists_template = "https://{0}/gists/starred".format(
|
|
1579
1546
|
get_github_api_host(args)
|
|
1580
1547
|
)
|
|
1581
|
-
starred_gists = retrieve_data(
|
|
1582
|
-
args, starred_gists_template, single_request=False
|
|
1583
|
-
)
|
|
1548
|
+
starred_gists = retrieve_data(args, starred_gists_template)
|
|
1584
1549
|
# flag each repo as a starred gist for downstream processing
|
|
1585
1550
|
for item in starred_gists:
|
|
1586
1551
|
item.update({"is_gist": True, "is_starred": True})
|
|
@@ -1849,14 +1814,14 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
1849
1814
|
pull_states = ["open", "closed"]
|
|
1850
1815
|
for pull_state in pull_states:
|
|
1851
1816
|
query_args["state"] = pull_state
|
|
1852
|
-
_pulls =
|
|
1817
|
+
_pulls = retrieve_data(args, _pulls_template, query_args=query_args)
|
|
1853
1818
|
for pull in _pulls:
|
|
1854
1819
|
if args.since and pull["updated_at"] < args.since:
|
|
1855
1820
|
break
|
|
1856
1821
|
if not args.since or pull["updated_at"] >= args.since:
|
|
1857
1822
|
pulls[pull["number"]] = pull
|
|
1858
1823
|
else:
|
|
1859
|
-
_pulls =
|
|
1824
|
+
_pulls = retrieve_data(args, _pulls_template, query_args=query_args)
|
|
1860
1825
|
for pull in _pulls:
|
|
1861
1826
|
if args.since and pull["updated_at"] < args.since:
|
|
1862
1827
|
break
|
|
@@ -1864,7 +1829,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
1864
1829
|
pulls[pull["number"]] = retrieve_data(
|
|
1865
1830
|
args,
|
|
1866
1831
|
_pulls_template + "/{}".format(pull["number"]),
|
|
1867
|
-
|
|
1832
|
+
paginated=False,
|
|
1868
1833
|
)[0]
|
|
1869
1834
|
|
|
1870
1835
|
logger.info("Saving {0} pull requests to disk".format(len(list(pulls.keys()))))
|
|
@@ -13,7 +13,6 @@ class TestHTTP451Exception:
|
|
|
13
13
|
|
|
14
14
|
def test_repository_unavailable_error_raised(self):
|
|
15
15
|
"""HTTP 451 should raise RepositoryUnavailableError with DMCA URL."""
|
|
16
|
-
# Create mock args
|
|
17
16
|
args = Mock()
|
|
18
17
|
args.as_app = False
|
|
19
18
|
args.token_fine = None
|
|
@@ -25,7 +24,6 @@ class TestHTTP451Exception:
|
|
|
25
24
|
args.throttle_limit = None
|
|
26
25
|
args.throttle_pause = 0
|
|
27
26
|
|
|
28
|
-
# Mock HTTPError 451 response
|
|
29
27
|
mock_response = Mock()
|
|
30
28
|
mock_response.getcode.return_value = 451
|
|
31
29
|
|
|
@@ -41,14 +39,10 @@ class TestHTTP451Exception:
|
|
|
41
39
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
42
40
|
mock_response.reason = "Unavailable For Legal Reasons"
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
return mock_response, []
|
|
46
|
-
|
|
47
|
-
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
42
|
+
with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
|
|
48
43
|
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
49
|
-
|
|
44
|
+
github_backup.retrieve_data(args, "https://api.github.com/repos/test/dmca/issues")
|
|
50
45
|
|
|
51
|
-
# Check exception has DMCA URL
|
|
52
46
|
assert exc_info.value.dmca_url == "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
53
47
|
assert "451" in str(exc_info.value)
|
|
54
48
|
|
|
@@ -71,14 +65,10 @@ class TestHTTP451Exception:
|
|
|
71
65
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
72
66
|
mock_response.reason = "Unavailable For Legal Reasons"
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
return mock_response, []
|
|
76
|
-
|
|
77
|
-
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
68
|
+
with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
|
|
78
69
|
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
79
|
-
|
|
70
|
+
github_backup.retrieve_data(args, "https://api.github.com/repos/test/dmca/issues")
|
|
80
71
|
|
|
81
|
-
# Exception raised even without DMCA URL
|
|
82
72
|
assert exc_info.value.dmca_url is None
|
|
83
73
|
assert "451" in str(exc_info.value)
|
|
84
74
|
|
|
@@ -101,42 +91,9 @@ class TestHTTP451Exception:
|
|
|
101
91
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
102
92
|
mock_response.reason = "Unavailable For Legal Reasons"
|
|
103
93
|
|
|
104
|
-
|
|
105
|
-
return mock_response, []
|
|
106
|
-
|
|
107
|
-
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
94
|
+
with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
|
|
108
95
|
with pytest.raises(github_backup.RepositoryUnavailableError):
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def test_other_http_errors_unchanged(self):
|
|
112
|
-
"""Other HTTP errors should still raise generic Exception."""
|
|
113
|
-
args = Mock()
|
|
114
|
-
args.as_app = False
|
|
115
|
-
args.token_fine = None
|
|
116
|
-
args.token_classic = None
|
|
117
|
-
args.username = None
|
|
118
|
-
args.password = None
|
|
119
|
-
args.osx_keychain_item_name = None
|
|
120
|
-
args.osx_keychain_item_account = None
|
|
121
|
-
args.throttle_limit = None
|
|
122
|
-
args.throttle_pause = 0
|
|
123
|
-
|
|
124
|
-
mock_response = Mock()
|
|
125
|
-
mock_response.getcode.return_value = 404
|
|
126
|
-
mock_response.read.return_value = b'{"message": "Not Found"}'
|
|
127
|
-
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
128
|
-
mock_response.reason = "Not Found"
|
|
129
|
-
|
|
130
|
-
def mock_get_response(request, auth, template):
|
|
131
|
-
return mock_response, []
|
|
132
|
-
|
|
133
|
-
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
134
|
-
# Should raise generic Exception, not RepositoryUnavailableError
|
|
135
|
-
with pytest.raises(Exception) as exc_info:
|
|
136
|
-
list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/notfound/issues"))
|
|
137
|
-
|
|
138
|
-
assert not isinstance(exc_info.value, github_backup.RepositoryUnavailableError)
|
|
139
|
-
assert "404" in str(exc_info.value)
|
|
96
|
+
github_backup.retrieve_data(args, "https://api.github.com/repos/test/dmca/issues")
|
|
140
97
|
|
|
141
98
|
|
|
142
99
|
if __name__ == "__main__":
|
|
@@ -40,7 +40,7 @@ class MockHTTPResponse:
|
|
|
40
40
|
|
|
41
41
|
@pytest.fixture
|
|
42
42
|
def mock_args():
|
|
43
|
-
"""Mock args for
|
|
43
|
+
"""Mock args for retrieve_data."""
|
|
44
44
|
args = Mock()
|
|
45
45
|
args.as_app = False
|
|
46
46
|
args.token_fine = None
|
|
@@ -77,10 +77,8 @@ def test_cursor_based_pagination(mock_args):
|
|
|
77
77
|
return responses[len(requests_made) - 1]
|
|
78
78
|
|
|
79
79
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
80
|
-
results =
|
|
81
|
-
|
|
82
|
-
mock_args, "https://api.github.com/repos/owner/repo/issues"
|
|
83
|
-
)
|
|
80
|
+
results = github_backup.retrieve_data(
|
|
81
|
+
mock_args, "https://api.github.com/repos/owner/repo/issues"
|
|
84
82
|
)
|
|
85
83
|
|
|
86
84
|
# Verify all items retrieved and cursor was used in second request
|
|
@@ -112,10 +110,8 @@ def test_page_based_pagination(mock_args):
|
|
|
112
110
|
return responses[len(requests_made) - 1]
|
|
113
111
|
|
|
114
112
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
115
|
-
results =
|
|
116
|
-
|
|
117
|
-
mock_args, "https://api.github.com/repos/owner/repo/pulls"
|
|
118
|
-
)
|
|
113
|
+
results = github_backup.retrieve_data(
|
|
114
|
+
mock_args, "https://api.github.com/repos/owner/repo/pulls"
|
|
119
115
|
)
|
|
120
116
|
|
|
121
117
|
# Verify all items retrieved and page parameter was used (not cursor)
|
|
@@ -142,10 +138,8 @@ def test_no_link_header_stops_pagination(mock_args):
|
|
|
142
138
|
return responses[len(requests_made) - 1]
|
|
143
139
|
|
|
144
140
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
145
|
-
results =
|
|
146
|
-
|
|
147
|
-
mock_args, "https://api.github.com/repos/owner/repo/labels"
|
|
148
|
-
)
|
|
141
|
+
results = github_backup.retrieve_data(
|
|
142
|
+
mock_args, "https://api.github.com/repos/owner/repo/labels"
|
|
149
143
|
)
|
|
150
144
|
|
|
151
145
|
# Verify pagination stopped after first request
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Tests for retrieve_data function."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from github_backup import github_backup
|
|
11
|
+
from github_backup.github_backup import (
|
|
12
|
+
MAX_RETRIES,
|
|
13
|
+
calculate_retry_delay,
|
|
14
|
+
make_request_with_retry,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestCalculateRetryDelay:
|
|
19
|
+
def test_respects_retry_after_header(self):
|
|
20
|
+
headers = {'retry-after': '30'}
|
|
21
|
+
assert calculate_retry_delay(0, headers) == 30
|
|
22
|
+
|
|
23
|
+
def test_respects_rate_limit_reset(self):
|
|
24
|
+
import time
|
|
25
|
+
import calendar
|
|
26
|
+
# Set reset time 60 seconds in the future
|
|
27
|
+
future_reset = calendar.timegm(time.gmtime()) + 60
|
|
28
|
+
headers = {
|
|
29
|
+
'x-ratelimit-remaining': '0',
|
|
30
|
+
'x-ratelimit-reset': str(future_reset)
|
|
31
|
+
}
|
|
32
|
+
delay = calculate_retry_delay(0, headers)
|
|
33
|
+
# Should be approximately 60 seconds (with some tolerance for execution time)
|
|
34
|
+
assert 55 <= delay <= 65
|
|
35
|
+
|
|
36
|
+
def test_exponential_backoff(self):
|
|
37
|
+
delay_0 = calculate_retry_delay(0, {})
|
|
38
|
+
delay_1 = calculate_retry_delay(1, {})
|
|
39
|
+
delay_2 = calculate_retry_delay(2, {})
|
|
40
|
+
# Base delay is 1s, so delays should be roughly 1, 2, 4 (plus jitter)
|
|
41
|
+
assert 0.9 <= delay_0 <= 1.2 # ~1s + up to 10% jitter
|
|
42
|
+
assert 1.8 <= delay_1 <= 2.4 # ~2s + up to 10% jitter
|
|
43
|
+
assert 3.6 <= delay_2 <= 4.8 # ~4s + up to 10% jitter
|
|
44
|
+
|
|
45
|
+
def test_max_delay_cap(self):
|
|
46
|
+
# Very high attempt number should not exceed 120s + jitter
|
|
47
|
+
delay = calculate_retry_delay(100, {})
|
|
48
|
+
assert delay <= 120 * 1.1 # 120s max + 10% jitter
|
|
49
|
+
|
|
50
|
+
def test_minimum_rate_limit_delay(self):
|
|
51
|
+
import time
|
|
52
|
+
import calendar
|
|
53
|
+
# Set reset time in the past (already reset)
|
|
54
|
+
past_reset = calendar.timegm(time.gmtime()) - 100
|
|
55
|
+
headers = {
|
|
56
|
+
'x-ratelimit-remaining': '0',
|
|
57
|
+
'x-ratelimit-reset': str(past_reset)
|
|
58
|
+
}
|
|
59
|
+
delay = calculate_retry_delay(0, headers)
|
|
60
|
+
# Should be minimum 10 seconds even if reset time is in past
|
|
61
|
+
assert delay >= 10
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestRetrieveDataRetry:
|
|
65
|
+
"""Tests for retry behavior in retrieve_data."""
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def mock_args(self):
|
|
69
|
+
args = Mock()
|
|
70
|
+
args.as_app = False
|
|
71
|
+
args.token_fine = None
|
|
72
|
+
args.token_classic = "fake_token"
|
|
73
|
+
args.username = None
|
|
74
|
+
args.password = None
|
|
75
|
+
args.osx_keychain_item_name = None
|
|
76
|
+
args.osx_keychain_item_account = None
|
|
77
|
+
args.throttle_limit = None
|
|
78
|
+
args.throttle_pause = 0
|
|
79
|
+
return args
|
|
80
|
+
|
|
81
|
+
def test_json_parse_error_retries_and_fails(self, mock_args):
|
|
82
|
+
"""HTTP 200 with invalid JSON should retry and eventually fail."""
|
|
83
|
+
mock_response = Mock()
|
|
84
|
+
mock_response.getcode.return_value = 200
|
|
85
|
+
mock_response.read.return_value = b"not valid json {"
|
|
86
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
87
|
+
|
|
88
|
+
call_count = 0
|
|
89
|
+
|
|
90
|
+
def mock_make_request(*args, **kwargs):
|
|
91
|
+
nonlocal call_count
|
|
92
|
+
call_count += 1
|
|
93
|
+
return mock_response
|
|
94
|
+
|
|
95
|
+
with patch("github_backup.github_backup.make_request_with_retry", side_effect=mock_make_request):
|
|
96
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0): # No delay in tests
|
|
97
|
+
with pytest.raises(Exception) as exc_info:
|
|
98
|
+
github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
|
|
99
|
+
|
|
100
|
+
assert "Failed to read response after" in str(exc_info.value)
|
|
101
|
+
assert call_count == MAX_RETRIES
|
|
102
|
+
|
|
103
|
+
def test_json_parse_error_recovers_on_retry(self, mock_args):
|
|
104
|
+
"""HTTP 200 with invalid JSON should succeed if retry returns valid JSON."""
|
|
105
|
+
bad_response = Mock()
|
|
106
|
+
bad_response.getcode.return_value = 200
|
|
107
|
+
bad_response.read.return_value = b"not valid json {"
|
|
108
|
+
bad_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
109
|
+
|
|
110
|
+
good_response = Mock()
|
|
111
|
+
good_response.getcode.return_value = 200
|
|
112
|
+
good_response.read.return_value = json.dumps([{"id": 1}]).encode("utf-8")
|
|
113
|
+
good_response.headers = {"x-ratelimit-remaining": "5000", "Link": ""}
|
|
114
|
+
|
|
115
|
+
responses = [bad_response, bad_response, good_response]
|
|
116
|
+
call_count = 0
|
|
117
|
+
|
|
118
|
+
def mock_make_request(*args, **kwargs):
|
|
119
|
+
nonlocal call_count
|
|
120
|
+
result = responses[call_count]
|
|
121
|
+
call_count += 1
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
with patch("github_backup.github_backup.make_request_with_retry", side_effect=mock_make_request):
|
|
125
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
|
|
126
|
+
result = github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
|
|
127
|
+
|
|
128
|
+
assert result == [{"id": 1}]
|
|
129
|
+
assert call_count == 3 # Failed twice, succeeded on third
|
|
130
|
+
|
|
131
|
+
def test_http_error_raises_exception(self, mock_args):
|
|
132
|
+
"""Non-success HTTP status codes should raise Exception."""
|
|
133
|
+
mock_response = Mock()
|
|
134
|
+
mock_response.getcode.return_value = 404
|
|
135
|
+
mock_response.read.return_value = b'{"message": "Not Found"}'
|
|
136
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
137
|
+
mock_response.reason = "Not Found"
|
|
138
|
+
|
|
139
|
+
with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
|
|
140
|
+
with pytest.raises(Exception) as exc_info:
|
|
141
|
+
github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/notfound/issues")
|
|
142
|
+
|
|
143
|
+
assert not isinstance(exc_info.value, github_backup.RepositoryUnavailableError)
|
|
144
|
+
assert "404" in str(exc_info.value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestMakeRequestWithRetry:
|
|
148
|
+
"""Tests for HTTP error retry behavior in make_request_with_retry."""
|
|
149
|
+
|
|
150
|
+
def test_502_error_retries_and_succeeds(self):
|
|
151
|
+
"""HTTP 502 should retry and succeed if subsequent request works."""
|
|
152
|
+
good_response = Mock()
|
|
153
|
+
good_response.read.return_value = b'{"ok": true}'
|
|
154
|
+
|
|
155
|
+
call_count = 0
|
|
156
|
+
fail_count = MAX_RETRIES - 1 # Fail all but last attempt
|
|
157
|
+
|
|
158
|
+
def mock_urlopen(*args, **kwargs):
|
|
159
|
+
nonlocal call_count
|
|
160
|
+
call_count += 1
|
|
161
|
+
if call_count <= fail_count:
|
|
162
|
+
raise HTTPError(
|
|
163
|
+
url="https://api.github.com/test",
|
|
164
|
+
code=502,
|
|
165
|
+
msg="Bad Gateway",
|
|
166
|
+
hdrs={"x-ratelimit-remaining": "5000"},
|
|
167
|
+
fp=None,
|
|
168
|
+
)
|
|
169
|
+
return good_response
|
|
170
|
+
|
|
171
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
172
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
|
|
173
|
+
result = make_request_with_retry(Mock(), None)
|
|
174
|
+
|
|
175
|
+
assert result == good_response
|
|
176
|
+
assert call_count == MAX_RETRIES
|
|
177
|
+
|
|
178
|
+
def test_503_error_retries_until_exhausted(self):
|
|
179
|
+
"""HTTP 503 should retry MAX_RETRIES times then raise."""
|
|
180
|
+
call_count = 0
|
|
181
|
+
|
|
182
|
+
def mock_urlopen(*args, **kwargs):
|
|
183
|
+
nonlocal call_count
|
|
184
|
+
call_count += 1
|
|
185
|
+
raise HTTPError(
|
|
186
|
+
url="https://api.github.com/test",
|
|
187
|
+
code=503,
|
|
188
|
+
msg="Service Unavailable",
|
|
189
|
+
hdrs={"x-ratelimit-remaining": "5000"},
|
|
190
|
+
fp=None,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
194
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
|
|
195
|
+
with pytest.raises(HTTPError) as exc_info:
|
|
196
|
+
make_request_with_retry(Mock(), None)
|
|
197
|
+
|
|
198
|
+
assert exc_info.value.code == 503
|
|
199
|
+
assert call_count == MAX_RETRIES
|
|
200
|
+
|
|
201
|
+
def test_404_error_not_retried(self):
|
|
202
|
+
"""HTTP 404 should not be retried - raise immediately."""
|
|
203
|
+
call_count = 0
|
|
204
|
+
|
|
205
|
+
def mock_urlopen(*args, **kwargs):
|
|
206
|
+
nonlocal call_count
|
|
207
|
+
call_count += 1
|
|
208
|
+
raise HTTPError(
|
|
209
|
+
url="https://api.github.com/test",
|
|
210
|
+
code=404,
|
|
211
|
+
msg="Not Found",
|
|
212
|
+
hdrs={"x-ratelimit-remaining": "5000"},
|
|
213
|
+
fp=None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
217
|
+
with pytest.raises(HTTPError) as exc_info:
|
|
218
|
+
make_request_with_retry(Mock(), None)
|
|
219
|
+
|
|
220
|
+
assert exc_info.value.code == 404
|
|
221
|
+
assert call_count == 1 # No retries
|
|
222
|
+
|
|
223
|
+
def test_rate_limit_403_retried_when_remaining_zero(self):
|
|
224
|
+
"""HTTP 403 with x-ratelimit-remaining=0 should retry."""
|
|
225
|
+
good_response = Mock()
|
|
226
|
+
call_count = 0
|
|
227
|
+
|
|
228
|
+
def mock_urlopen(*args, **kwargs):
|
|
229
|
+
nonlocal call_count
|
|
230
|
+
call_count += 1
|
|
231
|
+
if call_count == 1:
|
|
232
|
+
raise HTTPError(
|
|
233
|
+
url="https://api.github.com/test",
|
|
234
|
+
code=403,
|
|
235
|
+
msg="Forbidden",
|
|
236
|
+
hdrs={"x-ratelimit-remaining": "0"},
|
|
237
|
+
fp=None,
|
|
238
|
+
)
|
|
239
|
+
return good_response
|
|
240
|
+
|
|
241
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
242
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
|
|
243
|
+
result = make_request_with_retry(Mock(), None)
|
|
244
|
+
|
|
245
|
+
assert result == good_response
|
|
246
|
+
assert call_count == 2
|
|
247
|
+
|
|
248
|
+
def test_403_not_retried_when_remaining_nonzero(self):
|
|
249
|
+
"""HTTP 403 with x-ratelimit-remaining>0 should not retry (permission error)."""
|
|
250
|
+
call_count = 0
|
|
251
|
+
|
|
252
|
+
def mock_urlopen(*args, **kwargs):
|
|
253
|
+
nonlocal call_count
|
|
254
|
+
call_count += 1
|
|
255
|
+
raise HTTPError(
|
|
256
|
+
url="https://api.github.com/test",
|
|
257
|
+
code=403,
|
|
258
|
+
msg="Forbidden",
|
|
259
|
+
hdrs={"x-ratelimit-remaining": "5000"},
|
|
260
|
+
fp=None,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
264
|
+
with pytest.raises(HTTPError) as exc_info:
|
|
265
|
+
make_request_with_retry(Mock(), None)
|
|
266
|
+
|
|
267
|
+
assert exc_info.value.code == 403
|
|
268
|
+
assert call_count == 1 # No retries
|
|
269
|
+
|
|
270
|
+
def test_connection_error_retries_and_succeeds(self):
|
|
271
|
+
"""URLError (connection error) should retry and succeed if subsequent request works."""
|
|
272
|
+
good_response = Mock()
|
|
273
|
+
call_count = 0
|
|
274
|
+
fail_count = MAX_RETRIES - 1 # Fail all but last attempt
|
|
275
|
+
|
|
276
|
+
def mock_urlopen(*args, **kwargs):
|
|
277
|
+
nonlocal call_count
|
|
278
|
+
call_count += 1
|
|
279
|
+
if call_count <= fail_count:
|
|
280
|
+
raise URLError("Connection refused")
|
|
281
|
+
return good_response
|
|
282
|
+
|
|
283
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
284
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
|
|
285
|
+
result = make_request_with_retry(Mock(), None)
|
|
286
|
+
|
|
287
|
+
assert result == good_response
|
|
288
|
+
assert call_count == MAX_RETRIES
|
|
289
|
+
|
|
290
|
+
def test_socket_error_retries_until_exhausted(self):
|
|
291
|
+
"""socket.error should retry MAX_RETRIES times then raise."""
|
|
292
|
+
call_count = 0
|
|
293
|
+
|
|
294
|
+
def mock_urlopen(*args, **kwargs):
|
|
295
|
+
nonlocal call_count
|
|
296
|
+
call_count += 1
|
|
297
|
+
raise socket.error("Connection reset by peer")
|
|
298
|
+
|
|
299
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
300
|
+
with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
|
|
301
|
+
with pytest.raises(socket.error):
|
|
302
|
+
make_request_with_retry(Mock(), None)
|
|
303
|
+
|
|
304
|
+
assert call_count == MAX_RETRIES
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class TestRetrieveDataThrottling:
|
|
308
|
+
"""Tests for throttling behavior in retrieve_data."""
|
|
309
|
+
|
|
310
|
+
@pytest.fixture
|
|
311
|
+
def mock_args(self):
|
|
312
|
+
args = Mock()
|
|
313
|
+
args.as_app = False
|
|
314
|
+
args.token_fine = None
|
|
315
|
+
args.token_classic = "fake_token"
|
|
316
|
+
args.username = None
|
|
317
|
+
args.password = None
|
|
318
|
+
args.osx_keychain_item_name = None
|
|
319
|
+
args.osx_keychain_item_account = None
|
|
320
|
+
args.throttle_limit = 10 # Throttle when remaining <= 10
|
|
321
|
+
args.throttle_pause = 5 # Pause 5 seconds
|
|
322
|
+
return args
|
|
323
|
+
|
|
324
|
+
def test_throttling_pauses_when_rate_limit_low(self, mock_args):
|
|
325
|
+
"""Should pause when x-ratelimit-remaining is at or below throttle_limit."""
|
|
326
|
+
mock_response = Mock()
|
|
327
|
+
mock_response.getcode.return_value = 200
|
|
328
|
+
mock_response.read.return_value = json.dumps([{"id": 1}]).encode("utf-8")
|
|
329
|
+
mock_response.headers = {"x-ratelimit-remaining": "5", "Link": ""} # Below throttle_limit
|
|
330
|
+
|
|
331
|
+
with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
|
|
332
|
+
with patch("github_backup.github_backup.time.sleep") as mock_sleep:
|
|
333
|
+
github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
|
|
334
|
+
|
|
335
|
+
mock_sleep.assert_called_once_with(5) # throttle_pause value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class TestRetrieveDataSingleItem:
|
|
339
|
+
"""Tests for single item (dict) responses in retrieve_data."""
|
|
340
|
+
|
|
341
|
+
@pytest.fixture
|
|
342
|
+
def mock_args(self):
|
|
343
|
+
args = Mock()
|
|
344
|
+
args.as_app = False
|
|
345
|
+
args.token_fine = None
|
|
346
|
+
args.token_classic = "fake_token"
|
|
347
|
+
args.username = None
|
|
348
|
+
args.password = None
|
|
349
|
+
args.osx_keychain_item_name = None
|
|
350
|
+
args.osx_keychain_item_account = None
|
|
351
|
+
args.throttle_limit = None
|
|
352
|
+
args.throttle_pause = 0
|
|
353
|
+
return args
|
|
354
|
+
|
|
355
|
+
def test_dict_response_returned_as_list(self, mock_args):
|
|
356
|
+
"""Single dict response should be returned as a list with one item."""
|
|
357
|
+
mock_response = Mock()
|
|
358
|
+
mock_response.getcode.return_value = 200
|
|
359
|
+
mock_response.read.return_value = json.dumps({"login": "testuser", "id": 123}).encode("utf-8")
|
|
360
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000", "Link": ""}
|
|
361
|
+
|
|
362
|
+
with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
|
|
363
|
+
result = github_backup.retrieve_data(mock_args, "https://api.github.com/user")
|
|
364
|
+
|
|
365
|
+
assert result == [{"login": "testuser", "id": 123}]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.57.0"
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
autopep8==2.3.2
|
|
2
|
-
black==25.12.0
|
|
3
|
-
bleach==6.3.0
|
|
4
|
-
certifi==2025.11.12
|
|
5
|
-
charset-normalizer==3.4.4
|
|
6
|
-
click==8.3.1
|
|
7
|
-
colorama==0.4.6
|
|
8
|
-
docutils==0.22.3
|
|
9
|
-
flake8==7.3.0
|
|
10
|
-
gitchangelog==3.0.4
|
|
11
|
-
pytest==9.0.2
|
|
12
|
-
idna==3.11
|
|
13
|
-
importlib-metadata==8.7.0
|
|
14
|
-
jaraco.classes==3.4.0
|
|
15
|
-
keyring==25.7.0
|
|
16
|
-
markdown-it-py==4.0.0
|
|
17
|
-
mccabe==0.7.0
|
|
18
|
-
mdurl==0.1.2
|
|
19
|
-
more-itertools==10.8.0
|
|
20
|
-
mypy-extensions==1.1.0
|
|
21
|
-
packaging==25.0
|
|
22
|
-
pathspec==0.12.1
|
|
23
|
-
pkginfo==1.12.1.2
|
|
24
|
-
platformdirs==4.5.1
|
|
25
|
-
pycodestyle==2.14.0
|
|
26
|
-
pyflakes==3.4.0
|
|
27
|
-
Pygments==2.19.2
|
|
28
|
-
readme-renderer==44.0
|
|
29
|
-
requests==2.32.5
|
|
30
|
-
requests-toolbelt==1.0.0
|
|
31
|
-
restructuredtext-lint==2.0.2
|
|
32
|
-
rfc3986==2.0.0
|
|
33
|
-
rich==14.2.0
|
|
34
|
-
setuptools==80.9.0
|
|
35
|
-
six==1.17.0
|
|
36
|
-
tqdm==4.67.1
|
|
37
|
-
twine==6.2.0
|
|
38
|
-
urllib3==2.6.1
|
|
39
|
-
webencodings==0.5.1
|
|
40
|
-
zipp==3.23.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|