Skip to content

Commit be3f9b9

Browse files
committed
fix: enhance GitHub API rate limiting resilience and error reporting
Increase retry attempts from 3 to 10 with longer exponential backoff (2s initial, 2x multiplier, 60s cap) to handle GitHub secondary rate limits more reliably. Add stagger delay (0.5s) between parallel download task submissions to prevent thundering herd effects on rate-limited APIs. Track download failures across all setup steps and display error banner instead of false success message when downloads fail. Exit with code 1 when download failures occur while still completing configuration steps. Add comprehensive test coverage for new retry defaults, stagger delay mechanism, and download failure tracking.
1 parent 7c6b329 commit be3f9b9

File tree

4 files changed

+490
-41
lines changed

4 files changed

+490
-41
lines changed

scripts/setup_environment.py

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ def debug_log(message: str) -> None:
137137

138138

139139
# Default number of parallel workers - can be overridden via CLAUDE_PARALLEL_WORKERS env var
140-
# Reduced from 5 to 3 to decrease likelihood of hitting GitHub secondary rate limits
141-
DEFAULT_PARALLEL_WORKERS = int(os.environ.get('CLAUDE_PARALLEL_WORKERS', '3'))
140+
# Reduced from 5 to 2 to decrease likelihood of hitting GitHub secondary rate limits
141+
DEFAULT_PARALLEL_WORKERS = int(os.environ.get('CLAUDE_PARALLEL_WORKERS', '2'))
142142

143143

144144
def is_parallel_mode_enabled() -> bool:
@@ -155,6 +155,7 @@ def execute_parallel(
155155
items: list[T],
156156
func: Callable[[T], R],
157157
max_workers: int = DEFAULT_PARALLEL_WORKERS,
158+
stagger_delay: float = 0.0,
158159
) -> list[R]:
159160
"""Execute a function on items in parallel with error isolation.
160161
@@ -164,7 +165,9 @@ def execute_parallel(
164165
Args:
165166
items: List of items to process
166167
func: Function to apply to each item
167-
max_workers: Maximum number of parallel workers (default: 5)
168+
max_workers: Maximum number of parallel workers (default: 2)
169+
stagger_delay: Delay in seconds between task submissions to prevent
170+
thundering herd on rate-limited APIs (default: 0.0)
168171
169172
Returns:
170173
List of results in the same order as input items.
@@ -186,10 +189,12 @@ def execute_parallel(
186189
results_with_index: list[tuple[int, R | BaseException]] = []
187190

188191
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
189-
# Submit all tasks with their index for ordering
190-
future_to_index: dict[concurrent.futures.Future[R], int] = {
191-
executor.submit(func, item): idx for idx, item in enumerate(items)
192-
}
192+
# Submit tasks with optional stagger delay to prevent thundering herd
193+
future_to_index: dict[concurrent.futures.Future[R], int] = {}
194+
for idx, item in enumerate(items):
195+
future_to_index[executor.submit(func, item)] = idx
196+
if stagger_delay > 0 and idx < len(items) - 1:
197+
time.sleep(stagger_delay)
193198

194199
# Collect results as they complete
195200
for future in concurrent.futures.as_completed(future_to_index):
@@ -228,6 +233,7 @@ def execute_parallel_safe(
228233
func: Callable[[T], R],
229234
default_on_error: R,
230235
max_workers: int = DEFAULT_PARALLEL_WORKERS,
236+
stagger_delay: float = 0.0,
231237
) -> list[R]:
232238
"""Execute a function on items in parallel with error handling.
233239
@@ -238,7 +244,9 @@ def execute_parallel_safe(
238244
items: List of items to process
239245
func: Function to apply to each item
240246
default_on_error: Value to return for items that raise exceptions
241-
max_workers: Maximum number of parallel workers (default: 5)
247+
max_workers: Maximum number of parallel workers (default: 2)
248+
stagger_delay: Delay in seconds between task submissions to prevent
249+
thundering herd on rate-limited APIs (default: 0.0)
242250
243251
Returns:
244252
List of results in the same order as input items.
@@ -254,7 +262,7 @@ def safe_func(item: T) -> R:
254262
debug_log(f'Item processing failed: {exc}')
255263
return default_on_error
256264

257-
return execute_parallel(items, safe_func, max_workers)
265+
return execute_parallel(items, safe_func, max_workers, stagger_delay=stagger_delay)
258266

259267

260268
# Windows UAC elevation helper functions
@@ -3489,8 +3497,8 @@ def execute_dependency(dep: str) -> bool:
34893497
def fetch_with_retry[T](
34903498
request_func: Callable[[], T],
34913499
url: str,
3492-
max_retries: int = 3,
3493-
initial_backoff: float = 1.0,
3500+
max_retries: int = 10,
3501+
initial_backoff: float = 2.0,
34943502
) -> T:
34953503
"""Execute a fetch operation with retry logic for rate limiting.
34963504
@@ -3500,8 +3508,8 @@ def fetch_with_retry[T](
35003508
Args:
35013509
request_func: Function that performs the actual request and returns result
35023510
url: URL being fetched (for logging purposes)
3503-
max_retries: Maximum number of retry attempts (default: 3)
3504-
initial_backoff: Initial backoff time in seconds (default: 1.0)
3511+
max_retries: Maximum number of retry attempts (default: 10)
3512+
initial_backoff: Initial backoff time in seconds (default: 2.0)
35053513
35063514
Returns:
35073515
Result from request_func
@@ -3889,8 +3897,8 @@ def download_single_resource(task: tuple[str, Path]) -> bool:
38893897
resource, destination = task
38903898
return handle_resource(resource, destination, config_source, base_url, auth_param)
38913899

3892-
# Execute downloads in parallel (or sequential if CLAUDE_SEQUENTIAL_MODE=1)
3893-
results = execute_parallel_safe(download_tasks, download_single_resource, False)
3900+
# Execute downloads in parallel with stagger delay to avoid rate limiting
3901+
results = execute_parallel_safe(download_tasks, download_single_resource, False, stagger_delay=0.5)
38943902
return all(results)
38953903

38963904

@@ -3949,7 +3957,7 @@ def process_file_downloads(
39493957
continue
39503958

39513959
# Expand destination path (~ and environment variables)
3952-
# This makes it work cross-platform: ~/.config, %USERPROFILE%\bin, $HOME/.local
3960+
# This makes it work cross-platform: ~/.config, %USERPROFILE%\\bin, $HOME/.local
39533961
expanded_dest = os.path.expanduser(str(dest))
39543962
expanded_dest = os.path.expandvars(expanded_dest)
39553963
dest_path = Path(expanded_dest)
@@ -3970,9 +3978,9 @@ def download_single_file(download_info: tuple[str, Path]) -> bool:
39703978
source, dest_path = download_info
39713979
return handle_resource(source, dest_path, config_source, base_url, auth_param)
39723980

3973-
# Execute downloads in parallel (or sequential if CLAUDE_SEQUENTIAL_MODE=1)
3981+
# Execute downloads in parallel with stagger delay to avoid rate limiting
39743982
if valid_downloads:
3975-
download_results = execute_parallel_safe(valid_downloads, download_single_file, False)
3983+
download_results = execute_parallel_safe(valid_downloads, download_single_file, False, stagger_delay=0.5)
39763984
success_count = sum(1 for result in download_results if result)
39773985
failed_count = len(download_results) - success_count + invalid_count
39783986
else:
@@ -4115,8 +4123,8 @@ def install_single_skill(skill_config: dict[str, Any]) -> bool:
41154123
"""Install a single skill and return success status."""
41164124
return process_skill(skill_config, skills_dir, config_source, auth_param)
41174125

4118-
# Execute skill installations in parallel (or sequential if CLAUDE_SEQUENTIAL_MODE=1)
4119-
results = execute_parallel_safe(skills_config, install_single_skill, False)
4126+
# Execute skill installations in parallel with stagger delay to avoid rate limiting
4127+
results = execute_parallel_safe(skills_config, install_single_skill, False, stagger_delay=0.5)
41204128
return all(results)
41214129

41224130

@@ -4892,8 +4900,8 @@ def download_single_hook(task: tuple[str, Path]) -> bool:
48924900
file, destination = task
48934901
return handle_resource(file, destination, config_source, base_url, auth_param)
48944902

4895-
# Execute downloads in parallel (or sequential if CLAUDE_SEQUENTIAL_MODE=1)
4896-
results = execute_parallel_safe(download_tasks, download_single_hook, False)
4903+
# Execute downloads in parallel with stagger delay to avoid rate limiting
4904+
results = execute_parallel_safe(download_tasks, download_single_hook, False, stagger_delay=0.5)
48974905
return all(results)
48984906

48994907

@@ -6184,12 +6192,16 @@ def main() -> None:
61846192
# Ensure .local/bin is in PATH early to prevent uv tool warnings
61856193
ensure_local_bin_in_path()
61866194

6195+
# Track download failures across all steps for final error reporting
6196+
download_failures: list[str] = []
6197+
61876198
# Step 3: Download/copy custom files
61886199
print()
61896200
print(f'{Colors.CYAN}Step 3: Processing file downloads...{Colors.NC}')
61906201
files_to_download = config.get('files-to-download', [])
61916202
if files_to_download:
6192-
process_file_downloads(files_to_download, config_source, base_url, args.auth)
6203+
if not process_file_downloads(files_to_download, config_source, base_url, args.auth):
6204+
download_failures.append('file downloads')
61936205
else:
61946206
info('No custom files to download')
61956207

@@ -6217,13 +6229,21 @@ def main() -> None:
62176229
print()
62186230
print(f'{Colors.CYAN}Step 7: Processing agents...{Colors.NC}')
62196231
agents = config.get('agents', [])
6220-
process_resources(agents, agents_dir, 'agents', config_source, base_url, args.auth)
6232+
if agents:
6233+
if not process_resources(agents, agents_dir, 'agents', config_source, base_url, args.auth):
6234+
download_failures.append('agents')
6235+
else:
6236+
info('No agents to process')
62216237

62226238
# Step 8: Process slash commands
62236239
print()
62246240
print(f'{Colors.CYAN}Step 8: Processing slash commands...{Colors.NC}')
62256241
commands = config.get('slash-commands', [])
6226-
process_resources(commands, commands_dir, 'slash commands', config_source, base_url, args.auth)
6242+
if commands:
6243+
if not process_resources(commands, commands_dir, 'slash commands', config_source, base_url, args.auth):
6244+
download_failures.append('slash commands')
6245+
else:
6246+
info('No slash commands to process')
62276247

62286248
# Step 9: Process skills
62296249
print()
@@ -6235,7 +6255,11 @@ def main() -> None:
62356255
if isinstance(skills_raw, list)
62366256
else []
62376257
)
6238-
process_skills(skills, skills_dir, config_source, args.auth)
6258+
if skills:
6259+
if not process_skills(skills, skills_dir, config_source, args.auth):
6260+
download_failures.append('skills')
6261+
else:
6262+
info('No skills configured')
62396263

62406264
# Step 10: Process system prompt (if specified)
62416265
print()
@@ -6246,7 +6270,8 @@ def main() -> None:
62466270
clean_prompt = system_prompt.split('?')[0] if '?' in system_prompt else system_prompt
62476271
sys_prompt_filename = Path(clean_prompt).name
62486272
prompt_path = prompts_dir / sys_prompt_filename
6249-
handle_resource(system_prompt, prompt_path, config_source, base_url, args.auth)
6273+
if not handle_resource(system_prompt, prompt_path, config_source, base_url, args.auth):
6274+
download_failures.append('system prompt')
62506275
else:
62516276
info('No additional system prompt configured')
62526277

@@ -6303,7 +6328,8 @@ def main() -> None:
63036328
print()
63046329
print(f'{Colors.CYAN}Step 13: Downloading hooks...{Colors.NC}')
63056330
hooks = config.get('hooks', {})
6306-
download_hook_files(hooks, claude_user_dir, config_source, base_url, args.auth)
6331+
if not download_hook_files(hooks, claude_user_dir, config_source, base_url, args.auth):
6332+
download_failures.append('hook files')
63076333

63086334
# Step 14: Configure settings
63096335
print()
@@ -6357,7 +6383,33 @@ def main() -> None:
63576383
info('Environment configuration completed successfully')
63586384
info('To create custom commands, add "command-names: [name1, name2]" to your config')
63596385

6360-
# Final message
6386+
# Check for download failures and report accordingly
6387+
if download_failures:
6388+
print()
6389+
print(f'{Colors.RED}========================================================================{Colors.NC}')
6390+
print(f'{Colors.RED} Setup Completed with Errors{Colors.NC}')
6391+
print(f'{Colors.RED}========================================================================{Colors.NC}')
6392+
print()
6393+
error('The following resources failed to download:')
6394+
for failure in download_failures:
6395+
error(f' - {failure}')
6396+
print()
6397+
error('Configuration steps were completed, but some files are missing.')
6398+
error('Please check your network connection and authentication, then re-run the setup.')
6399+
print()
6400+
6401+
# If running elevated via UAC, add a pause so user can see the error
6402+
if was_elevated_via_uac and not is_running_in_pytest():
6403+
print()
6404+
print(f'{Colors.RED}========================================================================{Colors.NC}')
6405+
print(f'{Colors.RED} Setup Completed with Download Errors{Colors.NC}')
6406+
print(f'{Colors.RED}========================================================================{Colors.NC}')
6407+
print()
6408+
input('Press Enter to exit...')
6409+
6410+
sys.exit(1)
6411+
6412+
# Final message - success (no download failures)
63616413
print()
63626414
print(f'{Colors.GREEN}========================================================================{Colors.NC}')
63636415
print(f'{Colors.GREEN} Setup Complete!{Colors.NC}')

0 commit comments

Comments
 (0)