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 Messages = messages
}; };
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); // On overloaded_error (HTTP 529): retry Sonnet once after a short delay, then
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token); // 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 var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.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." 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error calling Claude AI for quote analysis"); _logger.LogError(ex, "Error calling Claude AI for quote analysis");
+60 -21
View File
@@ -1184,10 +1184,10 @@ async function aiUploadFile(file) {
aiRefreshPhotoList(); aiRefreshPhotoList();
document.getElementById('ai_photoError')?.classList.add('d-none'); document.getElementById('ai_photoError')?.classList.add('d-none');
} else { } else {
alert('Upload failed: ' + (result.error || 'Unknown error')); aiShowError('Upload failed: ' + (result.error || 'Unknown error'));
} }
} catch (err) { } catch (err) {
alert('Upload error: ' + err.message); aiShowError('Upload error: ' + err.message);
} }
} }
@@ -1454,24 +1454,49 @@ function aiShowError(message) {
el.textContent = message; el.textContent = message;
el.classList.remove('d-none'); el.classList.remove('d-none');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
// Fallback if element not found
alert('AI Error: ' + message);
} }
} }
// Step 3: Coating layers // Step 3: Coating layers
function renderStep3Html() { function renderStep3Html() {
const isSandblastOnly = !!wz.data.sandblastOnly;
return ` return `
<p class="text-muted small mb-3"> <div class="form-check form-switch border rounded py-2 px-3 mb-3 bg-light">
<i class="bi bi-info-circle me-1"></i> <input class="form-check-input" type="checkbox" id="sandblastOnlyToggle"
Add one or more coating layers. The first coat uses 100% of the labor estimate; ${isSandblastOnly ? 'checked' : ''} onchange="onSandblastOnlyToggle()">
each additional coat adds 30%. <label class="form-check-label" for="sandblastOnlyToggle">
</p> <strong>Sandblast / Prep Only</strong>
<div id="coatsListContainer"></div> <span class="text-muted fw-normal ms-2 small">— no powder coating applied</span>
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()"> </label>
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer </div>
</button>`; <div id="coatingSectionWrap"${isSandblastOnly ? ' class="d-none"' : ''}>
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>
Add one or more coating layers. The first coat uses 100% of the labor estimate;
each additional coat adds 30%.
</p>
<div id="coatsListContainer"></div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
</button>
</div>
${isSandblastOnly ? `<div class="text-center text-muted py-3">
<i class="bi bi-tools fs-3 d-block mb-2 opacity-50"></i>
No powder coating — no oven or powder costs will be applied.
</div>` : ''}`;
}
function onSandblastOnlyToggle() {
const checked = document.getElementById('sandblastOnlyToggle')?.checked;
wz.data.sandblastOnly = checked;
if (checked) {
wz.data.coats = [];
// AI price was estimated with coating in mind — clear it so pricing recalculates from prep labor
if (wz.itemType === 'ai') {
wz.data.manualUnitPrice = null;
}
}
renderStep(3);
} }
function renderCoatsList() { function renderCoatsList() {
@@ -1893,8 +1918,9 @@ function renderStep4Html() {
</div>`; </div>`;
} }
const isSandblastOnly = !!wz.data.sandblastOnly;
const isCatalog = wz.itemType === 'product'; const isCatalog = wz.itemType === 'product';
const isAi = wz.itemType === 'ai'; const isAi = wz.itemType === 'ai' && !isSandblastOnly;
const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog
const current = wz.data.prepServices || []; const current = wz.data.prepServices || [];
@@ -1917,6 +1943,12 @@ function renderStep4Html() {
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price. Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
</div>` : ''; </div>` : '';
const sandblastBanner = isSandblastOnly ? `
<div class="alert alert-warning py-2 mb-3">
<i class="bi bi-tools me-1"></i>
<strong>Sandblast / Prep Only:</strong> estimated minutes will be billed as labor — no powder or oven costs.
</div>` : '';
const blastOptions = blastSetupData.length > 0 const blastOptions = blastSetupData.length > 0
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('') ? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
: ''; : '';
@@ -1960,7 +1992,7 @@ function renderStep4Html() {
? '' ? ''
: `<div class="text-muted small mb-3">Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.</div>`; : `<div class="text-muted small mb-3">Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.</div>`;
return `${catalogBanner}${aiBanner}${hint}${rows}`; return `${catalogBanner}${aiBanner}${sandblastBanner}${hint}${rows}`;
} }
function onPrepIncludeCostToggle() { function onPrepIncludeCostToggle() {
@@ -2274,14 +2306,17 @@ function preFillStep2() {
function buildItemFromWizard() { function buildItemFromWizard() {
const d = wz.data; const d = wz.data;
const isAi = wz.itemType === 'ai'; const isSandblastOnly = !!d.sandblastOnly;
// Sandblast-only AI items lose the AI pricing flag — the AI price included coating costs
// that no longer apply, so the server prices from prep labor instead.
const isAi = wz.itemType === 'ai' && !isSandblastOnly;
return { return {
description: d.description || null, description: d.description || null,
quantity: d.quantity || 1, quantity: d.quantity || 1,
surfaceAreaSqFt: d.surfaceAreaSqFt || 0, surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
estimatedMinutes: d.estimatedMinutes || 0, estimatedMinutes: d.estimatedMinutes || 0,
catalogItemId: d.catalogItemId || null, catalogItemId: d.catalogItemId || null,
manualUnitPrice: d.manualUnitPrice ?? null, manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null),
powderCostOverride: d.powderCostOverride ?? null, powderCostOverride: d.powderCostOverride ?? null,
isGenericItem: !!d.isGenericItem, isGenericItem: !!d.isGenericItem,
isLaborItem: !!d.isLaborItem, isLaborItem: !!d.isLaborItem,
@@ -2296,8 +2331,9 @@ function buildItemFromWizard() {
prepServices: d.prepServices || [], prepServices: d.prepServices || [],
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'), includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
complexity: d.complexity || 'Simple', complexity: d.complexity || 'Simple',
aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [], // Keep AI photos even for sandblast-only so they get promoted to permanent storage
aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [], aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [],
aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [],
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null, aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
}; };
@@ -2336,8 +2372,11 @@ function buildCardHtml(item, i) {
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' }; : { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const coatCount = item.coats?.length || 0; const coatCount = item.coats?.length || 0;
const coatBadge = (coatCount > 0) const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId;
const coatBadge = coatCount > 0
? `<span class="badge bg-secondary ms-1">${coatCount} coat${coatCount > 1 ? 's' : ''}</span>` ? `<span class="badge bg-secondary ms-1">${coatCount} coat${coatCount > 1 ? 's' : ''}</span>`
: isPrepOnly
? `<span class="badge bg-warning text-dark ms-1" title="No powder coating"><i class="bi bi-tools me-1"></i>Prep Only</span>`
: ''; : '';
const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple') const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')