Skip to content

Commit b972e35

Browse files
authored
Merge pull request #301 from alex-feel/alex-feel-dev
Enhance GitHub API rate limiting resilience and error reporting
2 parents 0803902 + be3f9b9 commit b972e35

File tree

5 files changed

+503
-41
lines changed

5 files changed

+503
-41
lines changed

CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,19 @@ The setup scripts support these environment variables for debugging and customiz
334334

335335
- `CLAUDE_CODE_TOOLBOX_DEBUG`: Set to `1`, `true`, or `yes` to enable verbose debug logging during MCP server configuration and other operations
336336
- `CLAUDE_CODE_GIT_BASH_PATH`: Override the Git Bash executable path (useful for non-standard installations where Git Bash is not in the default location)
337+
- `CLAUDE_PARALLEL_WORKERS`: Override the number of concurrent download workers (default: 2)
338+
- `CLAUDE_SEQUENTIAL_MODE`: Set to `1`, `true`, or `yes` to disable parallel downloads entirely
339+
340+
### Download Retry Configuration
341+
342+
The setup scripts include robust retry logic for handling GitHub API rate limiting during file downloads:
343+
344+
- **Retry attempts:** 10 (with exponential backoff)
345+
- **Initial delay:** 2 seconds, doubling each retry
346+
- **Maximum delay cap:** 60 seconds per retry
347+
- **Jitter:** Random 0-25% added to prevent synchronized retries
348+
- **Stagger delay:** 0.5 second delay between launching concurrent download threads
349+
- **Total worst-case wait:** ~6 minutes per file (covers extended rate limit windows)
337350

338351
### Hooks Configuration Structure
339352

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)