Skip to content

Commit 003c3f3

Browse files
committed
Keep completed batches when a translate_all batch fails mid-run
translate_all chunks a locale's strings into many sequential batch requests but had no per-batch error handling: one failing batch (network / rate limit / SDK error) propagated out of the whole call and was caught only by the lane's coarse per-locale rescue, discarding every batch that had already succeeded — so a transient blip on batch 41 of 80 threw away ~1,000 already-translated (and billed) strings and dropped the entire locale to English. Wrap each batch: on failure, keep the batches that already succeeded and stop — the remaining strings fall back to English and are retried next run (the fold's reuse-awareness means the kept ones aren't re-billed). Stopping rather than continuing avoids hammering the API when the failure is systemic. Two tests cover it: a later-batch failure keeps the completed batch and stops; a first-batch failure returns empty without propagating.
1 parent 6738d36 commit 003c3f3

2 files changed

Lines changed: 62 additions & 2 deletions

File tree

fastlane/lanes/ai_translator.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,26 @@ def translate_plural(english_forms:, categories:, locale:, note: nil, anchors: {
160160
# strings already sorted by key so each batch naturally groups one feature (reader.*, editor.*) — better
161161
# terminology consistency within a batch.
162162
#
163+
# A batch spanning many keys is many sequential requests. If one FAILS mid-run (network / rate limit / SDK
164+
# error), the batches that already succeeded are KEPT and the run stops — the remaining strings fall back to
165+
# English and are retried on the next run, rather than the whole locale's completed (and billed) work being
166+
# discarded. Stopping (vs. continuing) avoids hammering the API when the failure is systemic.
167+
#
163168
# @param strings [Array<Hash>] each { key:, source:, comment: } (string or symbol keys both accepted).
164169
# @param locale [String] target lproj code.
165170
# @param batch_size [Integer] strings per request.
166171
def translate_all(strings, locale:, batch_size: DEFAULT_BATCH_SIZE)
167172
items = batchable_items(strings)
168173
return {} if items.empty?
169174

170-
items.each_slice(batch_size).with_object({}) do |chunk, out|
171-
out.merge!(translate_batch(chunk, locale))
175+
translated = {}
176+
items.each_slice(batch_size).with_index do |chunk, index|
177+
translated.merge!(translate_batch(chunk, locale))
178+
rescue StandardError => e
179+
warn_batch_failure(locale: locale, index: index, kept: translated.size, error: e)
180+
break
172181
end
182+
translated
173183
end
174184

175185
# Builds Message Batch jobs for many strings across many locales (the async / cheaper bulk path). Returns
@@ -297,6 +307,14 @@ def to_string_keys(hash)
297307
(hash || {}).each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
298308
end
299309

310+
# A batch request failed mid-run. Keep the batches that already succeeded and STOP rather than hammer the rest:
311+
# the untranslated strings fall back to English and are retried next run (the fold's reuse-awareness means the
312+
# kept ones aren't re-billed). Surfaced on stderr — this class is deliberately fastlane-free, so no UI here.
313+
def warn_batch_failure(locale:, index:, kept:, error:)
314+
warn "AITranslator: batch #{index + 1} for '#{locale}' failed (#{error.message}); " \
315+
"kept #{kept} translation(s), the rest fall back to English (retry next run)."
316+
end
317+
300318
# One batched request: number the chunk, ask for a JSON {number => translation}, keep the validated ones.
301319
def translate_batch(chunk, locale)
302320
numbered = number_chunk(chunk)

fastlane/lanes/ai_translator_test.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Uses a canned-reply lambda for `complete:`, so it exercises all of the prompt-building / validation logic
55
# without the `anthropic` gem or the network.
66
require 'minitest/autorun'
7+
require 'stringio'
78
require_relative 'ai_translator'
89

910
# Exercises prompt-building and the validator gate via a canned-reply `complete:` lambda (no gem / network).
@@ -17,6 +18,16 @@ def translator(reply:, prompts: nil)
1718
AITranslator.new(complete: complete)
1819
end
1920

21+
# Runs the block with $stderr captured, returning what it wrote (the class surfaces batch failures via warn).
22+
def capture_stderr
23+
original = $stderr
24+
$stderr = StringIO.new
25+
yield
26+
$stderr.string
27+
ensure
28+
$stderr = original
29+
end
30+
2031
def test_returns_cleaned_translation
2132
t = translator(reply: %("Réglages"\n)) # wrapped in quotes + trailing newline
2233
assert_equal 'Réglages', t.translate(source: 'Settings', locale: 'fr')
@@ -182,6 +193,37 @@ def test_translate_all_bad_json_batch_falls_back
182193
assert_empty out
183194
end
184195

196+
def test_translate_all_keeps_completed_batches_when_a_later_batch_fails
197+
calls = 0
198+
complete = lambda do |**|
199+
calls += 1
200+
raise 'rate limited' if calls == 2 # the second batch fails mid-run
201+
202+
'{"1":"x","2":"y"}'
203+
end
204+
out = nil
205+
warnings = capture_stderr do
206+
out = AITranslator.new(complete: complete).translate_all(
207+
[{ key: 'a', source: 'One' }, { key: 'b', source: 'Two' },
208+
{ key: 'c', source: 'Three' }, { key: 'd', source: 'Four' },
209+
{ key: 'e', source: 'Five' }, { key: 'f', source: 'Six' }],
210+
locale: 'fr', batch_size: 2
211+
)
212+
end
213+
214+
assert_equal 2, calls, 'must stop after the failing batch, not hammer the remaining ones'
215+
assert_equal({ 'a' => 'x', 'b' => 'y' }, out, 'the first completed batch is kept, not discarded')
216+
assert_match(/batch 2 for 'fr' failed \(rate limited\)/, warnings)
217+
end
218+
219+
def test_translate_all_returns_empty_without_raising_when_the_first_batch_fails
220+
complete = ->(**) { raise 'network down' }
221+
out = nil
222+
capture_stderr { out = AITranslator.new(complete: complete).translate_all([{ key: 'a', source: 'One' }], locale: 'fr') }
223+
224+
assert_empty out, 'a total failure degrades to English (empty), and must not propagate the exception'
225+
end
226+
185227
def test_translate_all_empty_input_makes_no_call
186228
called = false
187229
complete = lambda do |**|

0 commit comments

Comments
 (0)