@@ -74,9 +74,6 @@ def __init__(self, message, dmca_url=None):
7474 " 3. Debian/Ubuntu: apt-get install ca-certificates\n \n "
7575 )
7676
77- # Retry configuration
78- MAX_RETRIES = 5
79-
8077
8178def logging_subprocess (
8279 popenargs , stdout_log_level = logging .DEBUG , stderr_log_level = logging .ERROR , ** kwargs
@@ -144,6 +141,17 @@ def mask_password(url, secret="*****"):
144141 return url .replace (parsed .password , secret )
145142
146143
144+ def non_negative_int (value ):
145+ """Argparse type validator for non-negative integers."""
146+ try :
147+ ivalue = int (value )
148+ except ValueError :
149+ raise argparse .ArgumentTypeError (f"'{ value } ' is not a valid integer" )
150+ if ivalue < 0 :
151+ raise argparse .ArgumentTypeError (f"{ value } must be 0 or greater" )
152+ return ivalue
153+
154+
147155def parse_args (args = None ):
148156 parser = argparse .ArgumentParser (description = "Backup a github account" )
149157 parser .add_argument ("user" , metavar = "USER" , type = str , help = "github username" )
@@ -468,6 +476,13 @@ def parse_args(args=None):
468476 parser .add_argument (
469477 "--exclude" , dest = "exclude" , help = "names of repositories to exclude" , nargs = "*"
470478 )
479+ parser .add_argument (
480+ "--retries" ,
481+ dest = "max_retries" ,
482+ type = non_negative_int ,
483+ default = 5 ,
484+ help = "maximum number of retries for API calls (default: 5)" ,
485+ )
471486 return parser .parse_args (args )
472487
473488
@@ -622,7 +637,7 @@ def retrieve_data(args, template, query_args=None, paginated=True):
622637 def _extract_next_page_url (link_header ):
623638 for link in link_header .split ("," ):
624639 if 'rel="next"' in link :
625- return link [link .find ("<" ) + 1 : link .find (">" )]
640+ return link [link .find ("<" ) + 1 : link .find (">" )]
626641 return None
627642
628643 def fetch_all () -> Generator [dict , None , None ]:
@@ -631,7 +646,7 @@ def fetch_all() -> Generator[dict, None, None]:
631646 while True :
632647 # FIRST: Fetch response
633648
634- for attempt in range (MAX_RETRIES ):
649+ for attempt in range (args . max_retries + 1 ):
635650 request = _construct_request (
636651 per_page = per_page if paginated else None ,
637652 query_args = query_args ,
@@ -640,7 +655,7 @@ def fetch_all() -> Generator[dict, None, None]:
640655 as_app = args .as_app ,
641656 fine = args .token_fine is not None ,
642657 )
643- http_response = make_request_with_retry (request , auth )
658+ http_response = make_request_with_retry (request , auth , args . max_retries )
644659
645660 match http_response .getcode ():
646661 case 200 :
@@ -654,10 +669,10 @@ def fetch_all() -> Generator[dict, None, None]:
654669 TimeoutError ,
655670 ) as e :
656671 logger .warning (f"{ type (e ).__name__ } reading response" )
657- if attempt < MAX_RETRIES - 1 :
672+ if attempt < args . max_retries :
658673 delay = calculate_retry_delay (attempt , {})
659674 logger .warning (
660- f"Retrying in { delay :.1f} s (attempt { attempt + 1 } /{ MAX_RETRIES } )"
675+ f"Retrying read in { delay :.1f} s (attempt { attempt + 1 } /{ args . max_retries + 1 } )"
661676 )
662677 time .sleep (delay )
663678 continue # Next retry attempt
@@ -683,10 +698,10 @@ def fetch_all() -> Generator[dict, None, None]:
683698 )
684699 else :
685700 logger .error (
686- f"Failed to read response after { MAX_RETRIES } attempts for { next_url or template } "
701+ f"Failed to read response after { args . max_retries + 1 } attempts for { next_url or template } "
687702 )
688703 raise Exception (
689- f"Failed to read response after { MAX_RETRIES } attempts for { next_url or template } "
704+ f"Failed to read response after { args . max_retries + 1 } attempts for { next_url or template } "
690705 )
691706
692707 # SECOND: Process and paginate
@@ -718,7 +733,7 @@ def fetch_all() -> Generator[dict, None, None]:
718733 return list (fetch_all ())
719734
720735
721- def make_request_with_retry (request , auth ):
736+ def make_request_with_retry (request , auth , max_retries = 5 ):
722737 """Make HTTP request with automatic retry for transient errors."""
723738
724739 def is_retryable_status (status_code , headers ):
@@ -730,40 +745,49 @@ def is_retryable_status(status_code, headers):
730745 return int (headers .get ("x-ratelimit-remaining" , 1 )) < 1
731746 return False
732747
733- for attempt in range (MAX_RETRIES ):
748+ for attempt in range (max_retries + 1 ):
734749 try :
735750 return urlopen (request , context = https_ctx )
736751
737752 except HTTPError as exc :
738753 # HTTPError can be used as a response-like object
739754 if not is_retryable_status (exc .code , exc .headers ):
755+ logger .error (
756+ f"API Error: { exc .code } { exc .reason } for { request .full_url } "
757+ )
740758 raise # Non-retryable error
741759
742- if attempt >= MAX_RETRIES - 1 :
743- logger .error (f"HTTP { exc .code } failed after { MAX_RETRIES } attempts" )
760+ if attempt >= max_retries :
761+ logger .error (
762+ f"HTTP { exc .code } failed after { max_retries + 1 } attempts for { request .full_url } "
763+ )
744764 raise
745765
746766 delay = calculate_retry_delay (attempt , exc .headers )
747767 logger .warning (
748- f"HTTP { exc .code } , retrying in { delay :.1f} s "
749- f"(attempt { attempt + 1 } /{ MAX_RETRIES } ) "
768+ f"HTTP { exc .code } ( { exc . reason } ) , retrying in { delay :.1f} s "
769+ f"(attempt { attempt + 1 } /{ max_retries + 1 } ) for { request . full_url } "
750770 )
751771 if auth is None and exc .code in (403 , 429 ):
752772 logger .info ("Hint: Authenticate to raise your GitHub rate limit" )
753773 time .sleep (delay )
754774
755775 except (URLError , socket .error ) as e :
756- if attempt >= MAX_RETRIES - 1 :
757- logger .error (f"Connection error failed after { MAX_RETRIES } attempts: { e } " )
776+ if attempt >= max_retries :
777+ logger .error (
778+ f"Connection error failed after { max_retries + 1 } attempts: { e } for { request .full_url } "
779+ )
758780 raise
759781 delay = calculate_retry_delay (attempt , {})
760782 logger .warning (
761783 f"Connection error: { e } , retrying in { delay :.1f} s "
762- f"(attempt { attempt + 1 } /{ MAX_RETRIES } ) "
784+ f"(attempt { attempt + 1 } /{ max_retries + 1 } ) for { request . full_url } "
763785 )
764786 time .sleep (delay )
765787
766- raise Exception (f"Request failed after { MAX_RETRIES } attempts" ) # pragma: no cover
788+ raise Exception (
789+ f"Request failed after { max_retries + 1 } attempts"
790+ ) # pragma: no cover
767791
768792
769793def _construct_request (per_page , query_args , template , auth , as_app = None , fine = False ):
@@ -1579,9 +1603,7 @@ def filter_repositories(args, unfiltered_repositories):
15791603 repositories = [r for r in repositories if not r .get ("archived" )]
15801604 if args .starred_skip_size_over is not None :
15811605 if args .starred_skip_size_over <= 0 :
1582- logger .warning (
1583- "--starred-skip-size-over must be greater than 0, ignoring"
1584- )
1606+ logger .warning ("--starred-skip-size-over must be greater than 0, ignoring" )
15851607 else :
15861608 size_limit_kb = args .starred_skip_size_over * 1024
15871609 filtered = []
@@ -1590,7 +1612,9 @@ def filter_repositories(args, unfiltered_repositories):
15901612 size_mb = r .get ("size" , 0 ) / 1024
15911613 logger .info (
15921614 "Skipping starred repo {0} ({1:.0f} MB) due to --starred-skip-size-over {2}" .format (
1593- r .get ("full_name" , r .get ("name" )), size_mb , args .starred_skip_size_over
1615+ r .get ("full_name" , r .get ("name" )),
1616+ size_mb ,
1617+ args .starred_skip_size_over ,
15941618 )
15951619 )
15961620 else :
0 commit comments