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:
@@ -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");
|
||||
|
||||
@@ -1184,10 +1184,10 @@ async function aiUploadFile(file) {
|
||||
aiRefreshPhotoList();
|
||||
document.getElementById('ai_photoError')?.classList.add('d-none');
|
||||
} else {
|
||||
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||
aiShowError('Upload failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Upload error: ' + err.message);
|
||||
aiShowError('Upload error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1454,24 +1454,49 @@ function aiShowError(message) {
|
||||
el.textContent = message;
|
||||
el.classList.remove('d-none');
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
// Fallback if element not found
|
||||
alert('AI Error: ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Coating layers
|
||||
function renderStep3Html() {
|
||||
const isSandblastOnly = !!wz.data.sandblastOnly;
|
||||
return `
|
||||
<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 class="form-check form-switch border rounded py-2 px-3 mb-3 bg-light">
|
||||
<input class="form-check-input" type="checkbox" id="sandblastOnlyToggle"
|
||||
${isSandblastOnly ? 'checked' : ''} onchange="onSandblastOnlyToggle()">
|
||||
<label class="form-check-label" for="sandblastOnlyToggle">
|
||||
<strong>Sandblast / Prep Only</strong>
|
||||
<span class="text-muted fw-normal ms-2 small">— no powder coating applied</span>
|
||||
</label>
|
||||
</div>
|
||||
<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() {
|
||||
@@ -1893,8 +1918,9 @@ function renderStep4Html() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const isSandblastOnly = !!wz.data.sandblastOnly;
|
||||
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 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.
|
||||
</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
|
||||
? 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>`;
|
||||
|
||||
return `${catalogBanner}${aiBanner}${hint}${rows}`;
|
||||
return `${catalogBanner}${aiBanner}${sandblastBanner}${hint}${rows}`;
|
||||
}
|
||||
|
||||
function onPrepIncludeCostToggle() {
|
||||
@@ -2274,14 +2306,17 @@ function preFillStep2() {
|
||||
|
||||
function buildItemFromWizard() {
|
||||
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 {
|
||||
description: d.description || null,
|
||||
quantity: d.quantity || 1,
|
||||
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
|
||||
estimatedMinutes: d.estimatedMinutes || 0,
|
||||
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,
|
||||
isGenericItem: !!d.isGenericItem,
|
||||
isLaborItem: !!d.isLaborItem,
|
||||
@@ -2296,8 +2331,9 @@ function buildItemFromWizard() {
|
||||
prepServices: d.prepServices || [],
|
||||
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
|
||||
complexity: d.complexity || 'Simple',
|
||||
aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [],
|
||||
aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [],
|
||||
// Keep AI photos even for sandblast-only so they get promoted to permanent storage
|
||||
aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [],
|
||||
aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [],
|
||||
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
|
||||
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
|
||||
};
|
||||
@@ -2336,8 +2372,11 @@ function buildCardHtml(item, i) {
|
||||
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
|
||||
|
||||
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>`
|
||||
: 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')
|
||||
|
||||
Reference in New Issue
Block a user