Skip to content

Commit 03732bb

Browse files
hydropixclaude
andcommitted
fix(429): unblock manual resume + add auto-resume toggle (#133)
- UI now handles 'rate_limited' status (was missing, leaving the progress bar spinning and the Resume button greyed out) - Interrupt endpoint accepts 'rate_limited' (was 400-ing) - New AUTO_PAUSE_ON_RATE_LIMIT toggle (env + Options checkbox): when off, translation waits Retry-After seconds and auto-resumes from the last checkpoint instead of pausing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1706610 commit 03732bb

10 files changed

Lines changed: 141 additions & 36 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ AUTO_ADJUST_CONTEXT=true # Automatically adjust context/chunk size if prompt to
9898
# Advanced
9999
MAX_TRANSLATION_ATTEMPTS=3
100100

101+
# Auto-pause on HTTP 429 rate limit
102+
# true (default): pause translation after retries are exhausted, user resumes manually
103+
# false: wait `Retry-After` seconds (or RATE_LIMIT_AUTO_RESUME_DELAY) and auto-resume
104+
# from the last checkpoint. Useful for long novels on free-tier APIs.
105+
AUTO_PAUSE_ON_RATE_LIMIT=true
106+
RATE_LIMIT_AUTO_RESUME_DELAY=60
107+
101108
EPUB_TOKEN_ALIGNMENT_ENABLED=true
102109
# Options: true (enable Phase 2 fallback), false (use old behavior with only Phase 1 + Phase 3)
103110

src/api/blueprints/translation_routes.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from src.config import (
1010
REQUEST_TIMEOUT,
11-
OLLAMA_NUM_CTX
11+
OLLAMA_NUM_CTX,
12+
AUTO_PAUSE_ON_RATE_LIMIT
1213
)
1314
from src.tts.tts_config import TTSConfig
1415

@@ -80,6 +81,8 @@ def start_translation_request():
8081
'openrouter_api_key': _resolve_api_key(data.get('openrouter_api_key'), 'OPENROUTER_API_KEY'),
8182
# Prompt options (optional instructions to include in the system prompt)
8283
'prompt_options': data.get('prompt_options', {}),
84+
# Auto-pause on rate limit toggle (request overrides .env default)
85+
'auto_pause_on_rate_limit': data.get('auto_pause_on_rate_limit', AUTO_PAUSE_ON_RATE_LIMIT),
8386
# Bilingual output (original + translation interleaved)
8487
'bilingual_output': data.get('bilingual_output', False),
8588
# TTS configuration
@@ -152,12 +155,22 @@ def interrupt_translation_job(translation_id):
152155
return jsonify({"error": "Translation not found"}), 404
153156

154157
job_data = state_manager.get_translation(translation_id)
155-
if job_data.get('status') == 'running' or job_data.get('status') == 'queued':
158+
status = job_data.get('status')
159+
if status in ('running', 'queued'):
156160
state_manager.set_interrupted(translation_id, True)
157161
return jsonify({
158162
"message": "Interruption signal sent. Translation will stop after the current segment."
159163
}), 200
160164

165+
if status == 'rate_limited':
166+
# Cancels any in-flight auto-resume sleep and stops the UI from treating
167+
# the job as still-active.
168+
state_manager.set_interrupted(translation_id, True)
169+
state_manager.set_translation_field(translation_id, 'status', 'interrupted')
170+
return jsonify({
171+
"message": "Auto-resume cancelled. Translation marked interrupted; you can resume manually later."
172+
}), 200
173+
161174
return jsonify({
162175
"message": "The translation is not in an interruptible state (e.g., already completed or failed)."
163176
}), 400

src/api/handlers.py

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from src.utils.file_utils import get_unique_output_path, generate_tts_for_translation
1515
from src.core.llm import OpenRouterProvider
1616
from src.core.llm.exceptions import RateLimitError
17+
from src.config import AUTO_PAUSE_ON_RATE_LIMIT, RATE_LIMIT_AUTO_RESUME_DELAY
1718
from src.core.adapters import translate_file
1819
from src.tts.tts_config import TTSConfig
1920
from .websocket import emit_update
@@ -440,44 +441,82 @@ def _openrouter_cost_callback(cost_data):
440441
}, namespace='/')
441442

442443
except RateLimitError as e:
443-
# Auto-pause on rate limit — save checkpoint so user can resume later
444+
auto_pause = config.get('auto_pause_on_rate_limit', AUTO_PAUSE_ON_RATE_LIMIT)
444445
retry_msg = f" Retry suggested after ~{e.retry_after}s." if e.retry_after else ""
445446
provider_name = e.provider or config.get('llm_provider', 'API')
446-
pause_msg = f"⏸️ Rate limited by {provider_name}.{retry_msg} Translation auto-paused — you can resume when ready."
447447

448-
_log_message_callback("rate_limit_auto_pause", pause_msg)
449-
450-
if state_manager.exists(translation_id):
451-
state_manager.set_translation_field(translation_id, 'status', 'rate_limited')
452-
state_manager.set_translation_field(translation_id, 'interrupted', True)
453-
454-
# Mark checkpoint as interrupted so it appears in resumable jobs
455-
checkpoint_manager.mark_interrupted(translation_id)
448+
if not state_manager.exists(translation_id):
449+
return
456450

457-
stats = state_manager.get_translation_field(translation_id, 'stats') or {}
458-
elapsed_time = time.time() - stats.get('start_time', time.time())
459-
_update_translation_stats_callback({'elapsed_time': elapsed_time})
451+
# Auto-resume mode keeps the job running: wait, then re-enter from the checkpoint.
452+
if not auto_pause:
453+
wait_seconds = e.retry_after or RATE_LIMIT_AUTO_RESUME_DELAY
454+
wait_msg = (f"⏳ Rate limited by {provider_name}.{retry_msg} "
455+
f"Auto-resume in {wait_seconds}s (auto-pause disabled).")
456+
_log_message_callback("rate_limit_auto_resume", wait_msg)
460457

461-
# Emit rate_limited status + checkpoint_created for UI
458+
# Surface 'rate_limited' transiently so the UI shows what's happening.
459+
state_manager.set_translation_field(translation_id, 'status', 'rate_limited')
462460
emit_update(socketio, translation_id, {
463461
'status': 'rate_limited',
464-
'log': pause_msg,
465-
'result': state_manager.get_translation_field(translation_id, 'result') or f"Translation paused (rate limited)"
462+
'log': wait_msg
466463
}, state_manager)
467464

468-
socketio.emit('checkpoint_created', {
469-
'translation_id': translation_id,
470-
'status': 'rate_limited',
471-
'message': pause_msg
472-
}, namespace='/')
465+
await asyncio.sleep(wait_seconds)
473466

474-
# Trigger file list refresh for partial output
475-
output_filepath = state_manager.get_translation_field(translation_id, 'output_filepath')
476-
if output_filepath and os.path.exists(output_filepath):
477-
socketio.emit('file_list_changed', {
478-
'reason': 'rate_limited',
479-
'filename': config.get('output_filename', 'unknown')
480-
}, namespace='/')
467+
# Honor an interrupt that arrived during the wait by falling through to pause.
468+
if state_manager.get_translation_field(translation_id, 'interrupted'):
469+
_log_message_callback("rate_limit_auto_resume_cancelled",
470+
"🛑 Auto-resume cancelled by user, pausing instead.")
471+
else:
472+
cp_data = checkpoint_manager.load_checkpoint(translation_id)
473+
if cp_data:
474+
new_config = dict(config)
475+
new_config['is_resume'] = True
476+
new_config['resume_from_index'] = cp_data['resume_from_index']
477+
checkpoint_manager.mark_running(translation_id)
478+
state_manager.set_translation_field(translation_id, 'status', 'running')
479+
emit_update(socketio, translation_id, {
480+
'status': 'running',
481+
'log': f"▶️ Auto-resuming from chunk {cp_data['resume_from_index']}..."
482+
}, state_manager)
483+
await perform_actual_translation(
484+
translation_id, new_config, state_manager, output_dir, socketio
485+
)
486+
return
487+
# No checkpoint available, fall through to the pause path below.
488+
_log_message_callback("rate_limit_no_checkpoint",
489+
"⚠️ Auto-resume requested but no checkpoint found, falling back to pause.")
490+
491+
pause_msg = f"⏸️ Rate limited by {provider_name}.{retry_msg} Translation auto-paused, you can resume when ready."
492+
_log_message_callback("rate_limit_auto_pause", pause_msg)
493+
494+
state_manager.set_translation_field(translation_id, 'status', 'rate_limited')
495+
state_manager.set_translation_field(translation_id, 'interrupted', True)
496+
checkpoint_manager.mark_interrupted(translation_id)
497+
498+
stats = state_manager.get_translation_field(translation_id, 'stats') or {}
499+
elapsed_time = time.time() - stats.get('start_time', time.time())
500+
_update_translation_stats_callback({'elapsed_time': elapsed_time})
501+
502+
emit_update(socketio, translation_id, {
503+
'status': 'rate_limited',
504+
'log': pause_msg,
505+
'result': state_manager.get_translation_field(translation_id, 'result') or f"Translation paused (rate limited)"
506+
}, state_manager)
507+
508+
socketio.emit('checkpoint_created', {
509+
'translation_id': translation_id,
510+
'status': 'rate_limited',
511+
'message': pause_msg
512+
}, namespace='/')
513+
514+
output_filepath = state_manager.get_translation_field(translation_id, 'output_filepath')
515+
if output_filepath and os.path.exists(output_filepath):
516+
socketio.emit('file_list_changed', {
517+
'reason': 'rate_limited',
518+
'filename': config.get('output_filename', 'unknown')
519+
}, namespace='/')
481520

482521
except Exception as e:
483522
critical_error_msg = f"Critical error during translation task ({translation_id}): {str(e)}"

src/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@
135135
THINKING_MODELS = UNCONTROLLABLE_THINKING_MODELS + CONTROLLABLE_THINKING_MODELS
136136
MAX_TRANSLATION_ATTEMPTS = int(os.getenv('MAX_TRANSLATION_ATTEMPTS', '2'))
137137

138+
# Auto-pause on HTTP 429 rate limit
139+
# When True (default): translation pauses after retries are exhausted; user resumes manually.
140+
# When False: translation auto-resumes from the last checkpoint after waiting `retry_after`
141+
# seconds (or RATE_LIMIT_AUTO_RESUME_DELAY if no Retry-After header).
142+
AUTO_PAUSE_ON_RATE_LIMIT = os.getenv('AUTO_PAUSE_ON_RATE_LIMIT', 'true').lower() == 'true'
143+
RATE_LIMIT_AUTO_RESUME_DELAY = int(os.getenv('RATE_LIMIT_AUTO_RESUME_DELAY', '60'))
144+
138145
# Adaptive context optimization settings
139146
# The new strategy starts at a small context and grows as needed based on actual token usage
140147
AUTO_ADJUST_CONTEXT = os.getenv("AUTO_ADJUST_CONTEXT", "true").lower() == "true"

src/web/static/js/core/settings-manager.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const LOCAL_SETTINGS = [
6666
'textCleanup',
6767
'refineTranslation',
6868
'bilingualMode',
69+
'disableAutoPause',
6970
'customInstructionFile',
7071
'apiEndpointCustomized', // Track if user manually changed endpoint
7172
'openaiEndpointCustomized'
@@ -174,6 +175,7 @@ export const SettingsManager = {
174175
{ id: 'textCleanup', event: 'change' },
175176
{ id: 'refineTranslation', event: 'change' },
176177
{ id: 'bilingualMode', event: 'change' },
178+
{ id: 'disableAutoPause', event: 'change' },
177179
{ id: 'customInstructionSelect', event: 'change' }
178180
];
179181

@@ -357,14 +359,20 @@ export const SettingsManager = {
357359
bilingualCheckbox.checked = prefs.bilingualMode;
358360
}
359361
}
362+
if (prefs.disableAutoPause !== undefined) {
363+
const disableAutoPauseCheckbox = DomHelpers.getElement('disableAutoPause');
364+
if (disableAutoPauseCheckbox) {
365+
disableAutoPauseCheckbox.checked = prefs.disableAutoPause;
366+
}
367+
}
360368

361369
// Store custom instruction file for later application (after loadCustomInstructions completes)
362370
if (prefs.customInstructionFile) {
363371
window.__pendingCustomInstructionSelection = prefs.customInstructionFile;
364372
}
365373

366374
// Keep Prompt Options section open if any option is active
367-
const hasAnyPromptOption = prefs.textCleanup || prefs.refineTranslation || prefs.bilingualMode || prefs.customInstructionFile;
375+
const hasAnyPromptOption = prefs.textCleanup || prefs.refineTranslation || prefs.bilingualMode || prefs.disableAutoPause || prefs.customInstructionFile;
368376
if (hasAnyPromptOption) {
369377
const promptOptionsSection = DomHelpers.getElement('promptOptionsSection');
370378
const promptOptionsIcon = DomHelpers.getElement('promptOptionsIcon');
@@ -419,6 +427,7 @@ export const SettingsManager = {
419427
const textCleanupCheckbox = DomHelpers.getElement('textCleanup');
420428
const refineTranslationCheckbox = DomHelpers.getElement('refineTranslation');
421429
const bilingualModeCheckbox = DomHelpers.getElement('bilingualMode');
430+
const disableAutoPauseCheckbox = DomHelpers.getElement('disableAutoPause');
422431

423432
const prefs = {
424433
lastProvider: DomHelpers.getValue('llmProvider'),
@@ -432,6 +441,7 @@ export const SettingsManager = {
432441
textCleanup: textCleanupCheckbox ? textCleanupCheckbox.checked : false,
433442
refineTranslation: refineTranslationCheckbox ? refineTranslationCheckbox.checked : false,
434443
bilingualMode: bilingualModeCheckbox ? bilingualModeCheckbox.checked : false,
444+
disableAutoPause: disableAutoPauseCheckbox ? disableAutoPauseCheckbox.checked : false,
435445
customInstructionFile: DomHelpers.getValue('customInstructionSelect') || ''
436446
};
437447

src/web/static/js/translation/batch-controller.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function getTranslationConfig(file) {
8787
file_type: file.fileType,
8888
prompt_options: promptOptions,
8989
bilingual_output: DomHelpers.getElement('bilingualMode')?.checked || false,
90+
auto_pause_on_rate_limit: !(DomHelpers.getElement('disableAutoPause')?.checked || false),
9091
tts_enabled: ttsEnabled,
9192
tts_voice: ttsEnabled ? (DomHelpers.getValue('ttsVoice') || '') : '',
9293
tts_rate: ttsEnabled ? (DomHelpers.getValue('ttsRate') || '+0%') : '+0%',

src/web/static/js/translation/translation-tracker.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ export const TranslationTracker = {
191191

192192
if (serverState.status === 'completed' ||
193193
serverState.status === 'error' ||
194-
serverState.status === 'interrupted') {
194+
serverState.status === 'interrupted' ||
195+
serverState.status === 'rate_limited') {
195196

196197
MessageLogger.addLog(`🔄 Syncing state: translation ${serverState.status} on server`);
197198
this.resetUIToIdle();
@@ -392,7 +393,7 @@ export const TranslationTracker = {
392393

393394
if (!currentJob || data.translation_id !== currentJob.translationId) {
394395
if (data.translation_id && !currentJob) {
395-
if (data.status === 'completed' || data.status === 'error' || data.status === 'interrupted') {
396+
if (data.status === 'completed' || data.status === 'error' || data.status === 'interrupted' || data.status === 'rate_limited') {
396397
this.resetUIToIdle();
397398
}
398399
}
@@ -432,6 +433,14 @@ export const TranslationTracker = {
432433
data
433434
);
434435
this.updateActiveTranslationsState();
436+
} else if (data.status === 'rate_limited') {
437+
MessageLogger.resetProgressTracking();
438+
this.finishCurrentFileTranslation(
439+
`⏸️ ${currentFile.name}: Rate limited, translation auto-paused. Use the Resume button below when ready.`,
440+
'info',
441+
data
442+
);
443+
this.updateActiveTranslationsState();
435444
} else if (data.status === 'error') {
436445
MessageLogger.resetProgressTracking();
437446
this.finishCurrentFileTranslation(
@@ -699,7 +708,8 @@ export const TranslationTracker = {
699708
this.updateFileStatusInList(
700709
currentFile.name,
701710
resultData.status === 'completed' ? 'Completed' :
702-
(resultData.status === 'interrupted' ? 'Interrupted' : 'Error')
711+
resultData.status === 'interrupted' ? 'Interrupted' :
712+
resultData.status === 'rate_limited' ? 'Rate Limited' : 'Error'
703713
);
704714

705715
StateManager.setState('translation.currentJob', null);
@@ -709,6 +719,9 @@ export const TranslationTracker = {
709719
} else if (resultData.status === 'interrupted') {
710720
MessageLogger.addLog('🛑 Batch processing stopped by user.');
711721
this.resetUIToIdle();
722+
} else if (resultData.status === 'rate_limited') {
723+
MessageLogger.addLog('⏸️ Batch processing paused (rate limited). Resume from the section below.');
724+
this.resetUIToIdle();
712725
} else {
713726
this.processNextFileInQueue();
714727
}

src/web/static/js/ui/form-manager.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,8 @@ export const FormManager = {
688688
},
689689
// Bilingual output (original + translation interleaved)
690690
bilingual_output: DomHelpers.getElement('bilingualMode')?.checked || false,
691+
// Disable auto-pause on rate limit (auto-resume after Retry-After)
692+
auto_pause_on_rate_limit: !(DomHelpers.getElement('disableAutoPause')?.checked || false),
691693
// TTS configuration
692694
tts_enabled: ttsEnabled,
693695
tts_voice: ttsEnabled ? (DomHelpers.getValue('ttsVoice') || '') : '',

src/web/static/js/utils/lifecycle-manager.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export const LifecycleManager = {
221221
const data = await ApiClient.getTranslationStatus(tidToCheck);
222222
const serverStatus = data.status;
223223

224-
if (serverStatus === 'completed' || serverStatus === 'error' || serverStatus === 'interrupted') {
224+
if (serverStatus === 'completed' || serverStatus === 'error' || serverStatus === 'interrupted' || serverStatus === 'rate_limited') {
225225
MessageLogger.addLog(`🔄 Detected state desync: job ${serverStatus} on server but UI still active. Syncing...`);
226226

227227
window.dispatchEvent(new CustomEvent('translationUpdate', {

src/web/templates/translation_interface.html

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ <h3>Drop files to translate</h3>
387387
</div>
388388

389389
<!-- Text Cleanup (OCR Correction) -->
390-
<div class="form-group" style="margin-bottom: 0;">
390+
<div class="form-group" style="margin-bottom: 15px;">
391391
<label style="display: flex; align-items: flex-start; gap: 10px; cursor: pointer;">
392392
<input type="checkbox" id="textCleanup">
393393
<div>
@@ -399,6 +399,19 @@ <h3>Drop files to translate</h3>
399399
</label>
400400
</div>
401401

402+
<!-- Disable Auto-Pause on Rate Limit -->
403+
<div class="form-group" style="margin-bottom: 0;">
404+
<label style="display: flex; align-items: flex-start; gap: 10px; cursor: pointer;">
405+
<input type="checkbox" id="disableAutoPause">
406+
<div>
407+
<span style="font-weight: 600; color: var(--text-dark);">Don't auto-pause on rate limit (HTTP 429)</span>
408+
<p style="margin: 5px 0 0 0; font-size: 0.8125rem; color: var(--text-muted-light);">
409+
By default, the translation pauses after the provider rate-limits and asks you to resume manually. Enable this to keep the translation running: it will wait for <code>Retry-After</code> seconds (or 60s default) and auto-resume from the last checkpoint. Useful for long novels on free-tier APIs. Click "Interrupt" anytime to cancel the auto-resume.
410+
</p>
411+
</div>
412+
</label>
413+
</div>
414+
402415
<!-- Custom Instructions -->
403416
<div class="form-group" style="margin-bottom: 0; margin-top: 15px;">
404417
<label style="font-weight: 600; color: var(--text-dark); margin-bottom: 5px; display: block;">

0 commit comments

Comments
 (0)