Dispatcharr/frontend/src/utils.js
SergeantPanda 5263e18ed9 fix(api): improve error handling for API responses
- Enhanced the `formatApiError` function to better format failed API responses, preventing raw HTML error pages from being displayed in toasts. JSON error bodies are now prioritized for user-friendly messages, while HTML and empty bodies are collapsed to a concise status line. Long plain-text bodies are truncated for readability.
- Updated tests to cover new error formatting behavior, ensuring accurate extraction of error messages from various JSON structures.
2026-07-17 15:31:56 +00:00

263 lines
6.4 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { notifications } from '@mantine/notifications';
export default {
Limiter: (n, list) => {
if (!list || !list.length) {
return;
}
var tail = list.splice(n);
var head = list;
var resolved = [];
var processed = 0;
return new Promise(function (resolve) {
head.forEach(function (x) {
var res = x();
resolved.push(res);
res.then(function (y) {
runNext();
return y;
});
});
function runNext() {
if (processed == tail.length) {
resolve(Promise.all(resolved));
} else {
resolved.push(
tail[processed]().then(function (x) {
runNext();
return x;
})
);
processed++;
}
}
});
},
};
// Custom debounce hook
export function useDebounce(value, delay = 500, callback = null) {
const [debouncedValue, setDebouncedValue] = useState(value);
const isFirstRender = useRef(true);
const previousValueRef = useRef(JSON.stringify(value));
useEffect(() => {
const currentValueStr = JSON.stringify(value);
// Skip if value hasn't actually changed (prevents unnecessary state updates)
if (previousValueRef.current === currentValueStr) {
return;
}
const handler = setTimeout(() => {
setDebouncedValue(value);
// Only fire callback if not the first render
if (callback && !isFirstRender.current) {
callback();
}
isFirstRender.current = false;
previousValueRef.current = currentValueStr;
}, delay);
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
}, [value, delay]);
return debouncedValue;
}
// Format a failed API response for toast display.
const ERROR_BODY_MAX_CHARS = 200;
const STATUS_LABELS = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
409: 'Conflict',
413: 'Payload Too Large',
429: 'Too Many Requests',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
};
const formatErrorValue = (value) => {
if (value == null) return '';
if (Array.isArray(value)) {
return value.map(formatErrorValue).filter(Boolean).join('; ');
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
return String(value);
};
const formatJsonErrorBody = (body) => {
if (Array.isArray(body)) {
return formatErrorValue(body) || null;
}
if (!body || typeof body !== 'object') {
return null;
}
for (const key of ['detail', 'error', 'non_field_errors']) {
if (body[key] != null && body[key] !== '') {
const msg = formatErrorValue(body[key]);
if (msg) return msg;
}
}
const fieldParts = Object.entries(body)
.map(([key, value]) => {
const msg = formatErrorValue(value);
return msg ? `${key}: ${msg}` : null;
})
.filter(Boolean);
if (fieldParts.length) {
return fieldParts.join('; ');
}
try {
return JSON.stringify(body, null, 2);
} catch {
return null;
}
};
const formatStatusLine = (status, response) => {
const reason = response?.statusText?.trim();
const label = reason || STATUS_LABELS[status] || 'Request failed';
return `${status} - ${label}`;
};
export const formatApiError = (error) => {
if (!error || !error.status) {
return (error && error.message) || 'Unknown error';
}
const { status, body, response } = error;
if (body && typeof body === 'object') {
const formatted = formatJsonErrorBody(body);
if (formatted) return formatted;
}
const text =
typeof body === 'string' ? body.trim() : body == null ? '' : String(body);
// Sniff leading '<' only when Content-Type is missing.
const contentType = (
response?.headers?.get?.('content-type') || ''
).toLowerCase();
const isMarkup =
contentType.includes('html') ||
contentType.includes('xml') ||
(!contentType && text.startsWith('<'));
if (!text || isMarkup) {
if (text) {
console.debug(`API error ${status} response body:`, text);
}
return formatStatusLine(status, response);
}
return text.length > ERROR_BODY_MAX_CHARS
? `${status} - ${text.slice(0, ERROR_BODY_MAX_CHARS)}...`
: `${status} - ${text}`;
};
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
export const copyToClipboard = async (value, options = {}) => {
const {
successTitle = 'Copied!',
successMessage = 'Copied to clipboard',
failureTitle = 'Copy Failed',
failureMessage = 'Failed to copy to clipboard',
showNotification = true,
} = options;
let success = false;
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
success = true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
if (!success) {
// Fallback method for environments without clipboard support
try {
const textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
success = successful;
} catch (err) {
console.error('Failed to copy with fallback method: ', err);
success = false;
}
}
// Show notification if enabled
if (showNotification) {
notifications.show({
title: success ? successTitle : failureTitle,
message: success ? successMessage : failureMessage,
color: success ? 'green' : 'red',
});
}
return success;
};
export const setCustomProperty = (input, key, value, serialize = false) => {
let obj;
if (input == null) {
// matches null or undefined
obj = {};
} else if (typeof input === 'string') {
try {
obj = JSON.parse(input);
} catch (e) {
obj = {};
}
} else if (typeof input === 'object' && !Array.isArray(input)) {
obj = { ...input }; // shallow copy
} else {
obj = {};
}
obj[key] = value;
if (serialize === true) {
return JSON.stringify(obj);
}
return obj;
};