Add AI overload retry with model fallback and consolidate wizard errors

Anthropic returns overloaded_error (HTTP 529) during high-demand periods.
Previously this failed immediately with a generic error. Now the service
retries Sonnet once after 5s, then falls back to Haiku (a separate
capacity pool) after another 3s before giving up. If all three attempts
are overloaded the user sees a clear "high demand" message rather than a
generic error. Non-overload errors still log at Error level.

Also consolidated AI wizard error display in item-wizard.js: photo upload
failures were using browser alert() while analyze failures used the inline
red alert bar. All errors now go through aiShowError() so they always
appear consistently as the red bar below the Analyze button. Removed the
alert() fallback from aiShowError() itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:27:27 -04:00
parent a8fb56e8ec
commit 74414c6c71
2 changed files with 107 additions and 23 deletions
@@ -254,8 +254,44 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
Messages = messages
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
// On overloaded_error (HTTP 529): retry Sonnet once after a short delay, then
// fall back to Haiku (separate capacity pool). If Haiku is also overloaded, give up.
// Total worst-case added latency before fallback: ~5s.
MessageResponse response;
var modelsToTry = new[] { "claude-sonnet-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001" };
HttpRequestException? lastOverloadEx = null;
response = null!;
for (int attempt = 0; attempt < modelsToTry.Length; attempt++)
{
messageRequest.Model = modelsToTry[attempt];
if (attempt > 0)
{
var delay = attempt == 1 ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(3);
_logger.LogWarning("Claude API overloaded on {Model} (attempt {Attempt}); retrying with {NextModel} in {Delay}s",
modelsToTry[attempt - 1], attempt, modelsToTry[attempt], delay.TotalSeconds);
await Task.Delay(delay);
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
try
{
response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
lastOverloadEx = null;
break;
}
catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error"))
{
lastOverloadEx = hex;
}
}
if (lastOverloadEx != null)
{
_logger.LogWarning(lastOverloadEx, "Claude API overloaded on all models including fallback");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again."
};
}
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
@@ -329,6 +365,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
ErrorMessage = "The AI service did not respond in time. Please try again."
};
}
catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error"))
{
_logger.LogWarning(hex, "Claude API overloaded (outer catch — unexpected path)");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again."
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Claude AI for quote analysis");