pin_drop当前位置:知识文库 ❯ 图文

后端通信:19. 错误处理与重试 - 从入门到实践详解

教程简介

在网络请求中,错误是不可避免的——网络波动、服务器故障、请求超时、数据验证失败等各种情况都可能导致请求失败。如何优雅地处理这些错误,并在适当的情况下自动重试,是构建健壮前端应用的关键能力。本教程将全面讲解HTTP请求中的错误类型、错误处理策略、重试机制设计、断路器模式、降级方案等内容,帮助你构建高可用的前端应用。

核心概念

错误类型分类

错误类型 来源 HTTP状态码 可重试 示例
网络错误 浏览器 断网、DNS解析失败
超时错误 前端/后端 408/504 请求超时
客户端错误 客户端 4xx 400参数错误、401未认证、403无权限、404不存在
服务端错误 服务器 5xx 500内部错误、502网关错误、503服务不可用
业务错误 应用层 200/4xx 视情况 余额不足、库存为零
CORS错误 浏览器 跨域被拦截

重试策略对比

策略 说明 优点 缺点 适用场景
固定间隔 每次间隔相同时间 实现简单 可能加重服务器负担 简单场景
指数退避 间隔时间指数增长 减轻服务器压力 等待时间较长 服务端错误
指数退避+抖动 加入随机偏移 避免重试风暴 实现稍复杂 生产环境推荐
有限重试 限制最大重试次数 避免无限重试 需合理设置次数 通用

断路器模式

代码示例

关闭状态(CLOSED)→ 请求正常通过
     失败率超过阈值
    ↓
打开状态(OPEN)→ 请求直接失败(快速失败)
     超过冷却时间
    ↓
半开状态(HALF-OPEN)→ 允许少量请求通过
     成功 → 回到关闭状态
     失败 → 回到打开状态

语法与用法

Fetch API错误处理

代码示例

// Fetch API的错误处理特点:
// 1. 网络错误会reject Promise
// 2. HTTP错误(4xx/5xx)不会reject,需要手动检查response.ok

try {
    const response = await fetch('/api/data');
    // HTTP错误不会抛出异常!
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    const data = await response.json();
} catch (error) {
    // 这里捕获:网络错误 或 手动抛出的HTTP错误
    if (error instanceof TypeError) {
        console.error('网络错误:', error.message);
    } else {
        console.error('请求错误:', error.message);
    }
}

XHR错误处理

代码示例

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data');

xhr.onload = function() {
    if (xhr.status >= 200 && xhr.status < 300) {
        console.log(JSON.parse(xhr.responseText));
    } else {
        console.error(`HTTP ${xhr.status}`);
    }
};

xhr.onerror = function() {
    console.error('网络错误');
};

xhr.ontimeout = function() {
    console.error('请求超时');
};

xhr.timeout = 10000; // 10秒超时
xhr.send();

代码示例

示例1:错误类型与处理

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>错误类型与处理</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #0f0f23;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 30px 20px;
        }
        .container { max-width: 900px; margin: 0 auto; }
        h1 { text-align: center; color: #48dbfb; margin-bottom: 30px; }
        .card {
            background: #1a1a2e;
            border: 1px solid #2d2d44;
            border-radius: 12px;
            padding: 24px;
            margin-bottom: 16px;
        }
        .card h2 { font-size: 16px; color: #feca57; margin-bottom: 16px; }
        .error-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
            gap: 12px;
        }
        .error-card {
            background: #0f3460;
            border-radius: 10px;
            padding: 16px;
            cursor: pointer;
            transition: all 0.3s;
        }
        .error-card:hover { transform: translateY(-2px); background: #162d50; }
        .error-card h3 { font-size: 14px; margin-bottom: 8px; }
        .error-card p { font-size: 12px; color: #a6adc8; line-height: 1.6; }
        .status-code {
            display: inline-block;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 12px;
            font-weight: 600;
            margin-bottom: 6px;
        }
        .code-4xx { background: #f39c1233; color: #f39c12; }
        .code-5xx { background: #e74c3c33; color: #e74c3c; }
        .code-network { background: #9b59b633; color: #9b59b6; }
        .code-cors { background: #e67e2233; color: #e67e22; }
        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
            margin: 4px;
        }
        .btn-primary { background: #48dbfb; color: #0f0f23; }
        .btn-danger { background: #ff6b6b; color: white; }
        .result-panel {
            background: #0a0a1a;
            border-radius: 8px;
            padding: 16px;
            margin-top: 12px;
            font-family: monospace;
            font-size: 12px;
            max-height: 300px;
            overflow-y: auto;
            white-space: pre-wrap;
            word-break: break-all;
        }
        .result-success { border-left: 4px solid #2ecc71; }
        .result-error { border-left: 4px solid #ff6b6b; }
        .code-block {
            background: #0a0a1a;
            color: #a6e3a1;
            padding: 16px;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            line-height: 1.6;
            overflow-x: auto;
            white-space: pre;
            margin: 12px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>HTTP 请求错误类型与处理</h1>

        <!-- 错误类型卡片 -->
        <div class="card">
            <h2>常见错误类型</h2>
            <div class="error-grid">
                <div class="error-card" onclick="simulateError('400')">
                    <span class="status-code code-4xx">400</span>
                    <h3 style="color:#f39c12;">Bad Request</h3>
                    <p>请求参数错误或格式不正确。不可重试,需修正请求。</p>
                </div>
                <div class="error-card" onclick="simulateError('401')">
                    <span class="status-code code-4xx">401</span>
                    <h3 style="color:#f39c12;">Unauthorized</h3>
                    <p>未认证或Token过期。需刷新Token或重新登录。</p>
                </div>
                <div class="error-card" onclick="simulateError('403')">
                    <span class="status-code code-4xx">403</span>
                    <h3 style="color:#f39c12;">Forbidden</h3>
                    <p>已认证但无权限。不可重试,需申请权限。</p>
                </div>
                <div class="error-card" onclick="simulateError('404')">
                    <span class="status-code code-4xx">404</span>
                    <h3 style="color:#f39c12;">Not Found</h3>
                    <p>资源不存在。不可重试,检查URL是否正确。</p>
                </div>
                <div class="error-card" onclick="simulateError('408')">
                    <span class="status-code code-5xx">408</span>
                    <h3 style="color:#e74c3c;">Request Timeout</h3>
                    <p>请求超时。可重试,考虑增加超时时间。</p>
                </div>
                <div class="error-card" onclick="simulateError('429')">
                    <span class="status-code code-4xx">429</span>
                    <h3 style="color:#f39c12;">Too Many Requests</h3>
                    <p>请求频率过高。需等待后重试,遵循Retry-After头。</p>
                </div>
                <div class="error-card" onclick="simulateError('500')">
                    <span class="status-code code-5xx">500</span>
                    <h3 style="color:#e74c3c;">Internal Server Error</h3>
                    <p>服务器内部错误。可重试,可能是临时故障。</p>
                </div>
                <div class="error-card" onclick="simulateError('502')">
                    <span class="status-code code-5xx">502</span>
                    <h3 style="color:#e74c3c;">Bad Gateway</h3>
                    <p>网关错误。可重试,上游服务可能暂时不可用。</p>
                </div>
                <div class="error-card" onclick="simulateError('503')">
                    <span class="status-code code-5xx">503</span>
                    <h3 style="color:#e74c3c;">Service Unavailable</h3>
                    <p>服务不可用。可重试,服务器过载或维护中。</p>
                </div>
                <div class="error-card" onclick="simulateError('network')">
                    <span class="status-code code-network">ERR</span>
                    <h3 style="color:#9b59b6;">Network Error</h3>
                    <p>网络连接失败。可重试,检查网络连接。</p>
                </div>
                <div class="error-card" onclick="simulateError('timeout')">
                    <span class="status-code code-network">TIMEOUT</span>
                    <h3 style="color:#9b59b6;">Request Timeout</h3>
                    <p>请求超时。可重试,考虑优化请求或增加超时。</p>
                </div>
                <div class="error-card" onclick="simulateError('cors')">
                    <span class="status-code code-cors">CORS</span>
                    <h3 style="color:#e67e22;">CORS Error</h3>
                    <p>跨域请求被拦截。不可重试,需配置CORS。</p>
                </div>
            </div>
        </div>

        <!-- 错误处理代码 -->
        <div class="card">
            <h2>统一错误处理</h2>
            <div class="code-block">/**
 * 自定义错误类
 */
class HttpError extends Error {
    constructor(status, statusText, data) {
        super(`HTTP ${status}: ${statusText}`);
        this.name = 'HttpError';
        this.status = status;
        this.statusText = statusText;
        this.data = data;
        this.isRetryable = this._isRetryable(status);
    }

    _isRetryable(status) {
        // 5xx和408、429可重试
        return status >= 500 || status === 408 || status === 429;
    }
}

class NetworkError extends Error {
    constructor(message) {
        super(message);
        this.name = 'NetworkError';
        this.isRetryable = true;
    }
}

class TimeoutError extends Error {
    constructor(timeout) {
        super(`请求超时 (${timeout}ms)`);
        this.name = 'TimeoutError';
        this.timeout = timeout;
        this.isRetryable = true;
    }
}

/**
 * 统一错误处理函数
 */
function handleRequestError(error) {
    if (error instanceof HttpError) {
        switch (error.status) {
            case 400:
                showToast('请求参数错误,请检查输入', 'warning');
                break;
            case 401:
                // Token过期,尝试刷新
                redirectToLogin();
                break;
            case 403:
                showToast('没有操作权限', 'error');
                break;
            case 404:
                showToast('请求的资源不存在', 'error');
                break;
            case 429:
                showToast('请求过于频繁,请稍后再试', 'warning');
                break;
            case 500:
            case 502:
            case 503:
                showToast('服务器暂时不可用,请稍后再试', 'error');
                break;
            default:
                showToast(`请求失败: ${error.message}`, 'error');
        }
    } else if (error instanceof NetworkError) {
        showToast('网络连接失败,请检查网络', 'error');
    } else if (error instanceof TimeoutError) {
        showToast('请求超时,请稍后重试', 'warning');
    } else {
        showToast('未知错误', 'error');
    }
}</div>
        </div>

        <!-- 模拟测试 -->
        <div class="card">
            <h2>错误模拟测试</h2>
            <div style="display:flex; gap:8px; flex-wrap:wrap;">
                <button class="btn btn-primary" onclick="simulateError('200')">成功请求(200)</button>
                <button class="btn btn-danger" onclick="simulateError('500')">服务器错误(500)</button>
                <button class="btn btn-danger" onclick="simulateError('404')">资源不存在(404)</button>
                <button class="btn btn-danger" onclick="simulateError('timeout')">模拟超时</button>
            </div>
            <div class="result-panel" id="errorTestResult">点击按钮模拟不同类型的错误</div>
        </div>
    </div>

    <script>
        async function simulateError(type) {
            const result = document.getElementById('errorTestResult');
            result.className = 'result-panel';

            try {
                let url;
                switch (type) {
                    case '200': url = 'https://httpbin.org/get'; break;
                    case '400': url = 'https://httpbin.org/status/400'; break;
                    case '401': url = 'https://httpbin.org/status/401'; break;
                    case '403': url = 'https://httpbin.org/status/403'; break;
                    case '404': url = 'https://httpbin.org/status/404'; break;
                    case '408': url = 'https://httpbin.org/status/408'; break;
                    case '429': url = 'https://httpbin.org/status/429'; break;
                    case '500': url = 'https://httpbin.org/status/500'; break;
                    case '502': url = 'https://httpbin.org/status/502'; break;
                    case '503': url = 'https://httpbin.org/status/503'; break;
                    case 'network': throw new TypeError('Failed to fetch');
                    case 'timeout': throw new DOMException('The operation was aborted', 'AbortError');
                    case 'cors': url = 'https://restricted.example.com/api'; break;
                    default: url = 'https://httpbin.org/get';
                }

                const controller = new AbortController();
                const timeoutId = type === 'timeout'
                    ? setTimeout(() => controller.abort(), 10)
                    : null;

                const response = await fetch(url, { signal: controller.signal });
                if (timeoutId) clearTimeout(timeoutId);

                if (!response.ok) {
                    throw new HttpError(response.status, response.statusText);
                }

                const data = await response.json();
                result.className = 'result-panel result-success';
                result.textContent = `请求成功!\n\n状态码: ${response.status}\n数据: ${JSON.stringify(data, null, 2)}`;

            } catch (error) {
                result.className = 'result-panel result-error';
                let errorInfo = `错误类型: ${error.name}\n错误信息: ${error.message}\n\n`;

                if (error instanceof HttpError) {
                    errorInfo += `HTTP状态码: ${error.status}\n`;
                    errorInfo += `是否可重试: ${error.isRetryable ? '是' : '否'}\n\n`;
                    errorInfo += `处理建议:\n`;
                    switch (error.status) {
                        case 400: errorInfo += '- 检查请求参数是否正确\n- 验证数据格式'; break;
                        case 401: errorInfo += '- Token可能已过期\n- 尝试刷新Token\n- 跳转到登录页'; break;
                        case 403: errorInfo += '- 检查用户权限\n- 联系管理员'; break;
                        case 404: errorInfo += '- 检查URL是否正确\n- 资源可能已被删除'; break;
                        case 429: errorInfo += '- 降低请求频率\n- 检查Retry-After响应头\n- 实现请求限流'; break;
                        case 500: case 502: case 503:
                            errorInfo += '- 服务器临时故障\n- 可以重试(指数退避)\n- 显示友好错误页面'; break;
                    }
                } else if (error.name === 'AbortError') {
                    errorInfo += `超时错误\n是否可重试: 是\n\n处理建议:\n- 增加超时时间\n- 重试请求\n- 检查网络连接`;
                } else if (error instanceof TypeError) {
                    errorInfo += `网络错误\n是否可重试: 是\n\n处理建议:\n- 检查网络连接\n- 重试请求\n- 显示离线提示`;
                } else {
                    errorInfo += `未知错误\n\n处理建议:\n- 记录错误日志\n- 显示通用错误提示`;
                }

                result.textContent = errorInfo;
            }
        }

        function HttpError(status, statusText, data) {
            this.name = 'HttpError';
            this.message = `HTTP ${status}: ${statusText}`;
            this.status = status;
            this.statusText = statusText;
            this.data = data;
            this.isRetryable = status >= 500 || status === 408 || status === 429;
        }
        HttpError.prototype = Object.create(Error.prototype);
    </script>
</body>
</html>

示例2:重试机制实现

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>重试机制实现</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #1e1e2e;
            color: #cdd6f4;
            min-height: 100vh;
            padding: 30px 20px;
        }
        .container { max-width: 900px; margin: 0 auto; }
        h1 { text-align: center; color: #89b4fa; margin-bottom: 30px; }
        .card {
            background: #313244;
            border-radius: 12px;
            padding: 24px;
            margin-bottom: 16px;
            border: 1px solid #45475a;
        }
        .card h2 { font-size: 16px; color: #f9e2af; margin-bottom: 16px; }
        .code-block {
            background: #1e1e2e;
            color: #a6e3a1;
            padding: 16px;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            line-height: 1.6;
            overflow-x: auto;
            white-space: pre;
            margin: 12px 0;
        }
        .demo-area {
            background: #1e1e2e;
            border-radius: 8px;
            padding: 20px;
            margin-top: 16px;
        }
        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
            margin: 4px;
        }
        .btn-primary { background: #89b4fa; color: #1e1e2e; }
        .btn-success { background: #a6e3a1; color: #1e1e2e; }
        .btn-danger { background: #f38ba8; color: #1e1e2e; }
        .btn-warning { background: #f9e2af; color: #1e1e2e; }
        .retry-timeline {
            margin-top: 16px;
        }
        .retry-step {
            display: flex;
            align-items: center;
            gap: 12px;
            padding: 8px 0;
            border-bottom: 1px solid #45475a;
            font-size: 13px;
        }
        .retry-step:last-child { border-bottom: none; }
        .step-number {
            width: 28px;
            height: 28px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 12px;
            font-weight: 600;
            flex-shrink: 0;
        }
        .step-pending { background: #45475a; color: #888; }
        .step-active { background: #f9e2af; color: #1e1e2e; }
        .step-success { background: #a6e3a1; color: #1e1e2e; }
        .step-failed { background: #f38ba8; color: #1e1e2e; }
        .step-waiting { background: #89b4fa; color: #1e1e2e; }
        .log-area {
            background: #1e1e2e;
            border-radius: 8px;
            padding: 16px;
            font-family: monospace;
            font-size: 12px;
            color: #a6e3a1;
            max-height: 300px;
            overflow-y: auto;
            white-space: pre-wrap;
            margin-top: 12px;
        }
        .config-row {
            display: flex;
            gap: 12px;
            margin-bottom: 12px;
            align-items: center;
        }
        .config-row label {
            min-width: 100px;
            font-size: 13px;
            color: #a6adc8;
        }
        .config-row select, .config-row input {
            padding: 8px 12px;
            background: #45475a;
            border: 1px solid #585b70;
            border-radius: 6px;
            color: #cdd6f4;
            font-size: 14px;
            outline: none;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>请求重试机制</h1>

        <!-- 重试策略代码 -->
        <div class="card">
            <h2>指数退避重试实现</h2>
            <div class="code-block">/**
 * 带指数退避的重试函数
 * @param {Function} fn - 要重试的异步函数
 * @param {Object} options - 重试配置
 * @returns {Promise} 最终结果或最后一次错误
 */
async function retryWithBackoff(fn, options = {}) {
    const {
        maxRetries = 3,           // 最大重试次数
        baseDelay = 1000,         // 基础延迟(毫秒)
        maxDelay = 30000,         // 最大延迟
        jitter = true,            // 是否添加随机抖动
        shouldRetry = () => true, // 自定义重试条件
        onRetry = () => {},       // 重试回调
    } = options;

    let lastError;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            const result = await fn(attempt);
            return result;  // 成功,返回结果
        } catch (error) {
            lastError = error;

            // 最后一次尝试,不再重试
            if (attempt === maxRetries) break;

            // 检查是否应该重试
            if (!shouldRetry(error, attempt)) {
                throw error;  // 不可重试的错误,直接抛出
            }

            // 计算退避延迟
            let delay = Math.min(
                baseDelay * Math.pow(2, attempt),  // 指数增长
                maxDelay
            );

            // 添加随机抖动(避免重试风暴)
            if (jitter) {
                delay = delay * (0.5 + Math.random() * 0.5);
            }

            // 处理429的Retry-After头
            if (error.status === 429 && error.headers) {
                const retryAfter = error.headers.get('Retry-After');
                if (retryAfter) {
                    delay = Math.max(
                        parseInt(retryAfter) * 1000,
                        delay
                    );
                }
            }

            // 通知重试
            onRetry({
                attempt: attempt + 1,
                maxRetries,
                delay: Math.round(delay),
                error
            });

            // 等待
            await sleep(delay);
        }
    }

    throw lastError;  // 所有重试都失败
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用示例
const data = await retryWithBackoff(
    (attempt) => fetch('/api/data').then(r => {
        if (!r.ok) throw new HttpError(r.status, r.statusText);
        return r.json();
    }),
    {
        maxRetries: 3,
        baseDelay: 1000,
        shouldRetry: (error) => {
            // 只重试5xx和408
            return error.status >= 500 || error.status === 408;
        },
        onRetry: ({ attempt, delay }) => {
            console.log(`第${attempt}次重试,等待${delay}ms`);
        }
    }
);</div>
        </div>

        <!-- 交互式演示 -->
        <div class="card">
            <h2>重试机制交互演示</h2>
            <div class="demo-area">
                <div class="config-row">
                    <label>重试策略</label>
                    <select id="retryStrategy">
                        <option value="fixed">固定间隔</option>
                        <option value="exponential" selected>指数退避</option>
                        <option value="exponential-jitter">指数退避+抖动</option>
                    </select>
                </div>
                <div class="config-row">
                    <label>最大重试次数</label>
                    <select id="maxRetries">
                        <option value="1">1次</option>
                        <option value="3" selected>3次</option>
                        <option value="5">5次</option>
                    </select>
                </div>
                <div class="config-row">
                    <label>模拟失败次数</label>
                    <select id="failCount">
                        <option value="0">0次(立即成功)</option>
                        <option value="1">1次</option>
                        <option value="2" selected>2次</option>
                        <option value="3">3次</option>
                        <option value="5">5次(全部失败)</option>
                    </select>
                </div>

                <div style="display:flex; gap:8px; margin-top:8px;">
                    <button class="btn btn-primary" onclick="runRetryDemo()">开始重试演示</button>
                    <button class="btn btn-danger" onclick="resetDemo()">重置</button>
                </div>

                <div class="retry-timeline" id="retryTimeline"></div>
                <div class="log-area" id="retryLog">等待开始...</div>
            </div>
        </div>

        <!-- 断路器 -->
        <div class="card">
            <h2>断路器模式</h2>
            <div class="code-block">/**
 * 断路器
 * 当失败率超过阈值时,快速失败而非继续请求
 */
class CircuitBreaker {
    constructor(options = {}) {
        this.failureThreshold = options.failureThreshold || 5;  // 失败次数阈值
        this.resetTimeout = options.resetTimeout || 30000;      // 冷却时间(ms)
        this.halfOpenMaxRequests = options.halfOpenMaxRequests || 3;

        this.state = 'CLOSED';  // CLOSED | OPEN | HALF_OPEN
        this.failureCount = 0;
        this.successCount = 0;
        this.lastFailureTime = null;
        this.halfOpenRequests = 0;
    }

    async execute(fn) {
        if (this.state === 'OPEN') {
            // 检查是否可以进入半开状态
            if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
                this.state = 'HALF_OPEN';
                this.halfOpenRequests = 0;
            } else {
                throw new Error('断路器打开,快速失败');
            }
        }

        if (this.state === 'HALF_OPEN') {
            if (this.halfOpenRequests >= this.halfOpenMaxRequests) {
                throw new Error('断路器半开,请求已满');
            }
            this.halfOpenRequests++;
        }

        try {
            const result = await fn();
            this._onSuccess();
            return result;
        } catch (error) {
            this._onFailure();
            throw error;
        }
    }

    _onSuccess() {
        this.failureCount = 0;
        if (this.state === 'HALF_OPEN') {
            this.state = 'CLOSED';  // 半开状态成功,关闭断路器
        }
    }

    _onFailure() {
        this.failureCount++;
        this.lastFailureTime = Date.now();

        if (this.state === 'HALF_OPEN') {
            this.state = 'OPEN';  // 半开状态失败,重新打开
        } else if (this.failureCount >= this.failureThreshold) {
            this.state = 'OPEN';  // 失败次数超阈值,打开断路器
        }
    }

    get info() {
        return {
            state: this.state,
            failureCount: this.failureCount,
            lastFailureTime: this.lastFailureTime
        };
    }
}

// 使用示例
const circuitBreaker = new CircuitBreaker({
    failureThreshold: 5,
    resetTimeout: 30000
});

try {
    const data = await circuitBreaker.execute(async () => {
        const response = await fetch('/api/data');
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json();
    });
} catch (error) {
    if (error.message === '断路器打开,快速失败') {
        // 显示降级内容
        showFallbackContent();
    }
}</div>
        </div>
    </div>

    <script>
        let demoRunning = false;

        function sleep(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        }

        async function runRetryDemo() {
            if (demoRunning) return;
            demoRunning = true;

            const strategy = document.getElementById('retryStrategy').value;
            const maxRetries = parseInt(document.getElementById('maxRetries').value);
            const failCount = parseInt(document.getElementById('failCount').value);

            const log = document.getElementById('retryLog');
            const timeline = document.getElementById('retryTimeline');
            log.textContent = '';
            timeline.innerHTML = '';

            let attemptCount = 0;
            const startTime = Date.now();

            log.textContent += `=== 重试演示开始 ===\n`;
            log.textContent += `策略: ${strategy}\n`;
            log.textContent += `最大重试: ${maxRetries}次\n`;
            log.textContent += `模拟失败: ${failCount}次\n\n`;

            for (let attempt = 0; attempt <= maxRetries; attempt++) {
                attemptCount++;

                // 添加时间线步骤
                const step = document.createElement('div');
                step.className = 'retry-step';
                step.id = `step-${attempt}`;
                step.innerHTML = `
                    <div class="step-number step-active">${attempt + 1}</div>
                    <div style="flex:1;">
                        <div>第${attempt + 1}次请求</div>
                        <div id="step-detail-${attempt}" style="font-size:11px; color:#888;">请求中...</div>
                    </div>
                `;
                timeline.appendChild(step);

                // 模拟请求
                const isFail = attempt < failCount;

                await sleep(500 + Math.random() * 500);

                if (isFail) {
                    document.getElementById(`step-${attempt}`).querySelector('.step-number').className = 'step-number step-failed';
                    document.getElementById(`step-detail-${attempt}`).textContent = `失败 - 服务器错误(500)`;

                    log.textContent += `[${Date.now() - startTime}ms] 第${attempt + 1}次请求 → 失败\n`;

                    if (attempt < maxRetries) {
                        // 计算延迟
                        let delay;
                        switch (strategy) {
                            case 'fixed':
                                delay = 1000;
                                break;
                            case 'exponential':
                                delay = Math.min(1000 * Math.pow(2, attempt), 30000);
                                break;
                            case 'exponential-jitter':
                                delay = Math.min(1000 * Math.pow(2, attempt), 30000);
                                delay = delay * (0.5 + Math.random() * 0.5);
                                break;
                        }
                        delay = Math.round(delay);

                        // 等待步骤
                        const waitStep = document.createElement('div');
                        waitStep.className = 'retry-step';
                        waitStep.innerHTML = `
                            <div class="step-number step-waiting">↻</div>
                            <div style="flex:1;">
                                <div>等待 ${delay}ms 后重试</div>
                                <div style="font-size:11px; color:#888;">策略: ${strategy}</div>
                            </div>
                        `;
                        timeline.appendChild(waitStep);

                        log.textContent += `[${Date.now() - startTime}ms] 等待 ${delay}ms 后重试...\n`;
                        await sleep(Math.min(delay, 2000)); // 演示中最多等2秒
                    }
                } else {
                    document.getElementById(`step-${attempt}`).querySelector('.step-number').className = 'step-number step-success';
                    document.getElementById(`step-detail-${attempt}`).textContent = `成功 - 200 OK`;

                    log.textContent += `[${Date.now() - startTime}ms] 第${attempt + 1}次请求 → 成功!\n`;
                    log.textContent += `\n总共尝试 ${attempt + 1} 次,总耗时 ${Date.now() - startTime}ms\n`;
                    demoRunning = false;
                    return;
                }
            }

            log.textContent += `\n所有重试都失败,共尝试 ${attemptCount} 次\n`;
            demoRunning = false;
        }

        function resetDemo() {
            demoRunning = false;
            document.getElementById('retryTimeline').innerHTML = '';
            document.getElementById('retryLog').textContent = '等待开始...';
        }
    </script>
</body>
</html>

示例3:完整的请求封装

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>完整的请求封装</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f5f7fa;
            min-height: 100vh;
            padding: 30px 20px;
        }
        .container { max-width: 900px; margin: 0 auto; }
        h1 { text-align: center; color: #2c3e50; margin-bottom: 30px; }
        .card {
            background: white;
            border-radius: 12px;
            padding: 24px;
            margin-bottom: 16px;
            box-shadow: 0 2px 12px rgba(0,0,0,0.06);
        }
        .card h2 { font-size: 16px; color: #2c3e50; margin-bottom: 16px; }
        .code-block {
            background: #2c3e50;
            color: #a6e3a1;
            padding: 16px;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            line-height: 1.6;
            overflow-x: auto;
            white-space: pre;
            margin: 12px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>完整的请求封装</h1>

        <div class="card">
            <h2>生产级HTTP客户端</h2>
            <div class="code-block">/**
 * 生产级HTTP客户端
 * 集成:超时控制、重试机制、断路器、请求/响应拦截器、错误处理
 */
class HttpClient {
    constructor(config = {}) {
        this.baseURL = config.baseURL || '';
        this.timeout = config.timeout || 10000;
        this.defaultHeaders = config.headers || {};

        // 重试配置
        this.retryConfig = {
            maxRetries: config.maxRetries || 2,
            baseDelay: config.retryDelay || 1000,
            retryOn: config.retryOn || [408, 429, 500, 502, 503, 504]
        };

        // 断路器
        this.circuitBreaker = new CircuitBreaker({
            failureThreshold: config.circuitThreshold || 5,
            resetTimeout: config.circuitResetTimeout || 30000
        });

        // 拦截器
        this.requestInterceptors = [];
        this.responseInterceptors = [];
        this.errorInterceptors = [];
    }

    // 添加请求拦截器
    addRequestInterceptor(fn) {
        this.requestInterceptors.push(fn);
        return this;
    }

    // 添加响应拦截器
    addResponseInterceptor(fn) {
        this.responseInterceptors.push(fn);
        return this;
    }

    // 添加错误拦截器
    addErrorInterceptor(fn) {
        this.errorInterceptors.push(fn);
        return this;
    }

    // 核心请求方法
    async request(method, url, data, options = {}) {
        const config = {
            method,
            url: this.baseURL + url,
            headers: { ...this.defaultHeaders, ...options.headers },
            timeout: options.timeout || this.timeout,
            data,
            ...options
        };

        // 执行请求拦截器
        for (const interceptor of this.requestInterceptors) {
            await interceptor(config);
        }

        // 通过断路器执行请求
        return this.circuitBreaker.execute(async () => {
            return this._executeWithRetry(config);
        });
    }

    // 带重试的请求执行
    async _executeWithRetry(config) {
        const { maxRetries, baseDelay, retryOn } = this.retryConfig;
        let lastError;

        for (let attempt = 0; attempt <= maxRetries; attempt++) {
            try {
                const response = await this._fetch(config);

                // 执行响应拦截器
                let result = response;
                for (const interceptor of this.responseInterceptors) {
                    result = await interceptor(result, config);
                }

                return result;

            } catch (error) {
                lastError = error;

                // 最后一次尝试或不可重试的错误
                if (attempt === maxRetries || !this._isRetryable(error, retryOn)) {
                    break;
                }

                // 计算延迟
                const delay = this._calculateDelay(attempt, baseDelay, error);

                // 等待后重试
                await this._sleep(delay);
            }
        }

        // 所有重试失败,执行错误拦截器
        for (const interceptor of this.errorInterceptors) {
            await interceptor(lastError, config);
        }

        throw lastError;
    }

    // 实际fetch请求(带超时)
    async _fetch(config) {
        const controller = new AbortController();
        const timeoutId = setTimeout(
            () => controller.abort(),
            config.timeout
        );

        try {
            const fetchOptions = {
                method: config.method,
                headers: config.headers,
                signal: controller.signal
            };

            if (config.data && !['GET', 'HEAD'].includes(config.method)) {
                if (config.headers['Content-Type'] === 'application/json') {
                    fetchOptions.body = JSON.stringify(config.data);
                } else {
                    fetchOptions.body = config.data;
                }
            }

            const response = await fetch(config.url, fetchOptions);

            if (!response.ok) {
                const error = new HttpError(
                    response.status,
                    response.statusText,
                    await this._safeParseResponse(response)
                );
                error.headers = response.headers;
                throw error;
            }

            const data = await this._safeParseResponse(response);
            return { data, status: response.status, headers: response.headers };

        } catch (error) {
            if (error.name === 'AbortError') {
                throw new TimeoutError(config.timeout);
            }
            if (error instanceof TypeError) {
                throw new NetworkError(error.message);
            }
            throw error;
        } finally {
            clearTimeout(timeoutId);
        }
    }

    _isRetryable(error, retryOn) {
        if (error instanceof HttpError) {
            return retryOn.includes(error.status);
        }
        if (error instanceof NetworkError || error instanceof TimeoutError) {
            return true;
        }
        return false;
    }

    _calculateDelay(attempt, baseDelay, error) {
        let delay = baseDelay * Math.pow(2, attempt);

        // 处理429的Retry-After
        if (error.status === 429 && error.headers) {
            const retryAfter = error.headers.get('Retry-After');
            if (retryAfter) {
                delay = Math.max(parseInt(retryAfter) * 1000, delay);
            }
        }

        // 添加抖动
        delay = delay * (0.5 + Math.random() * 0.5);
        return Math.round(Math.min(delay, 30000));
    }

    _sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async _safeParseResponse(response) {
        try {
            return await response.json();
        } catch {
            return null;
        }
    }

    // 便捷方法
    get(url, options) { return this.request('GET', url, null, options); }
    post(url, data, options) { return this.request('POST', url, data, options); }
    put(url, data, options) { return this.request('PUT', url, data, options); }
    patch(url, data, options) { return this.request('PATCH', url, data, options); }
    delete(url, options) { return this.request('DELETE', url, null, options); }
}

// ===== 使用示例 =====
const api = new HttpClient({
    baseURL: 'https://api.example.com',
    timeout: 15000,
    maxRetries: 3,
    retryDelay: 1000,
    headers: {
        'Content-Type': 'application/json'
    }
});

// 添加Token拦截器
api.addRequestInterceptor((config) => {
    const token = getToken();
    if (token) {
        config.headers['Authorization'] = `Bearer ${token}`;
    }
});

// 添加401处理拦截器
api.addErrorInterceptor(async (error, config) => {
    if (error.status === 401) {
        const newToken = await refreshToken();
        if (newToken) {
            config.headers['Authorization'] = `Bearer ${newToken}`;
            return api._fetch(config);
        }
        redirectToLogin();
    }
});

// 添加日志拦截器
api.addResponseInterceptor((response, config) => {
    console.log(`[API] ${config.method} ${config.url} → ${response.status}`);
    return response;
});

// 发送请求
try {
    const { data } = await api.get('/users');
    console.log(data);
} catch (error) {
    handleRequestError(error);
}</div>
        </div>

        <!-- 降级方案 -->
        <div class="card">
            <h2>降级方案</h2>
            <div class="code-block">/**
 * 降级策略
 * 当主要请求失败时,提供备选方案
 */

// 1. 缓存降级:请求失败时返回缓存数据
async function fetchWithCacheFallback(url) {
    try {
        const response = await fetch(url);
        const data = await response.json();

        // 成功时更新缓存
        localStorage.setItem(`cache:${url}`, JSON.stringify({
            data, timestamp: Date.now()
        }));

        return { data, source: 'network' };
    } catch (error) {
        // 失败时尝试缓存
        const cached = localStorage.getItem(`cache:${url}`);
        if (cached) {
            const { data, timestamp } = JSON.parse(cached);
            const age = Date.now() - timestamp;
            return {
                data,
                source: 'cache',
                age: Math.round(age / 1000) + '秒前',
                warning: '数据可能不是最新的'
            };
        }
        throw error;
    }
}

// 2. 备用服务降级:主服务失败时请求备用服务
async function fetchWithFallback(primaryUrl, fallbackUrl) {
    try {
        return await fetch(primaryUrl).then(r => r.json());
    } catch {
        console.warn('主服务失败,尝试备用服务');
        return await fetch(fallbackUrl).then(r => r.json());
    }
}

// 3. 静态数据降级:所有请求失败时使用内置数据
const FALLBACK_DATA = {
    users: [{ id: 1, name: '示例用户' }],
    posts: [{ id: 1, title: '示例文章' }]
};

async function fetchWithStaticFallback(url) {
    try {
        return await fetch(url).then(r => r.json());
    } catch {
        const key = new URL(url).pathname.split('/').pop();
        return FALLBACK_DATA[key] || null;
    }
}

// 4. 骨架屏降级:加载失败时显示骨架屏
function showContentOrSkeleton(container, fetchFn) {
    container.innerHTML = getSkeletonHTML();

    fetchFn()
        .then(data => {
            container.innerHTML = renderContent(data);
        })
        .catch(() => {
            container.innerHTML = getErrorHTML('加载失败,点击重试');
            container.onclick = () => showContentOrSkeleton(container, fetchFn);
        });
}</div>
        </div>
    </div>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge IE
AbortController 66+ 57+ 12.1+ 16+ 不支持
fetch API 42+ 39+ 10.1+ 14+ 不支持
Promise 33+ 29+ 7.1+ 12+ 不支持
async/await 55+ 52+ 10.1+ 15+ 不支持
Retry-After头 全部 全部 全部 全部 全部

注意事项与最佳实践

1. 重试注意事项

代码示例

// 1. 不要重试非幂等请求
// GET、HEAD、OPTIONS、PUT、DELETE - 幂等,可重试
// POST - 非幂等,需谨慎重试

async function retryableRequest(method, url, options) {
    const nonIdempotent = ['POST', 'PATCH'];
    const shouldRetry = !nonIdempotent.includes(method.toUpperCase());

    return retryWithBackoff(
        () => fetch(url, options),
        {
            maxRetries: shouldRetry ? 3 : 0,
            shouldRetry: (error) => {
                if (!shouldRetry) return false;
                return error.status >= 500 || error.status === 408;
            }
        }
    );
}

// 2. 尊重Retry-After头
if (error.status === 429) {
    const retryAfter = error.headers.get('Retry-After');
    if (retryAfter) {
        const delay = parseInt(retryAfter) * 1000;
        await sleep(delay);
    }
}

// 3. 设置合理的重试次数
// 网络错误:3-5次
// 服务端错误:2-3次
// 429错误:1次(等待Retry-After时间)

// 4. 添加重试上限,避免无限重试
const MAX_TOTAL_RETRY_TIME = 60000; // 1分钟

2. 超时设置

代码示例

// 不同类型请求的超时建议
const TIMEOUT_PRESETS = {
    quick: 3000,      // 快速请求(如检查状态)
    normal: 10000,    // 普通请求(如获取列表)
    slow: 30000,      // 慢请求(如生成报告)
    upload: 300000,   // 文件上传(5分钟)
    download: 600000  // 文件下载(10分钟)
};

// 带超时的fetch
function fetchWithTimeout(url, options = {}) {
    const timeout = options.timeout || TIMEOUT_PRESETS.normal;

    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    return fetch(url, { ...options, signal: controller.signal })
        .finally(() => clearTimeout(timeoutId));
}

3. 用户友好的错误提示

代码示例

// 根据错误类型显示不同的用户提示
function getUserFriendlyMessage(error) {
    if (!navigator.onLine) {
        return '网络连接已断开,请检查网络设置';
    }

    if (error instanceof TimeoutError) {
        return '请求超时,请稍后重试';
    }

    if (error instanceof NetworkError) {
        return '网络连接失败,请检查网络';
    }

    if (error instanceof HttpError) {
        switch (error.status) {
            case 400: return '请求参数有误,请检查输入';
            case 401: return '登录已过期,请重新登录';
            case 403: return '您没有权限执行此操作';
            case 404: return '请求的内容不存在';
            case 429: return '操作过于频繁,请稍后再试';
            case 500: return '服务器开小差了,请稍后重试';
            case 502: return '服务暂时不可用,请稍后重试';
            case 503: return '服务维护中,请稍后访问';
            default: return '操作失败,请稍后重试';
        }
    }

    return '未知错误,请稍后重试';
}

代码规范示例

规范的错误处理模块

代码示例

/**
 * 错误处理模块
 * 统一管理错误分类、处理策略和用户提示
 */
const ErrorHandler = {
    // 错误分类
    classify(error) {
        if (error instanceof HttpError) {
            if (error.status >= 400 && error.status < 500) return 'CLIENT_ERROR';
            if (error.status >= 500) return 'SERVER_ERROR';
        }
        if (error instanceof NetworkError) return 'NETWORK_ERROR';
        if (error instanceof TimeoutError) return 'TIMEOUT_ERROR';
        return 'UNKNOWN_ERROR';
    },

    // 是否可重试
    isRetryable(error) {
        const category = this.classify(error);
        return ['SERVER_ERROR', 'NETWORK_ERROR', 'TIMEOUT_ERROR'].includes(category);
    },

    // 是否需要重新认证
    needsReauth(error) {
        return error instanceof HttpError && error.status === 401;
    },

    // 获取用户提示
    getUserMessage(error) {
        return getUserFriendlyMessage(error);
    },

    // 获取处理建议
    getAction(error) {
        const category = this.classify(error);
        const actions = {
            CLIENT_ERROR: { type: 'fix', message: '请检查输入' },
            SERVER_ERROR: { type: 'retry', message: '可以重试' },
            NETWORK_ERROR: { type: 'retry', message: '检查网络后重试' },
            TIMEOUT_ERROR: { type: 'retry', message: '稍后重试' },
            UNKNOWN_ERROR: { type: 'report', message: '请联系客服' }
        };
        return actions[category];
    },

    // 全局错误处理
    handle(error, context = {}) {
        // 记录错误
        this.log(error, context);

        // 分类处理
        if (this.needsReauth(error)) {
            this.handleReauth();
            return;
        }

        // 显示用户提示
        const message = this.getUserMessage(error);
        const action = this.getAction(error);
        showToast(message, action.type === 'retry' ? 'warning' : 'error');
    },

    // 错误日志
    log(error, context) {
        const logEntry = {
            timestamp: new Date().toISOString(),
            type: error.name,
            message: error.message,
            status: error.status,
            url: context.url,
            method: context.method,
            userAgent: navigator.userAgent
        };
        console.error('[ErrorHandler]', logEntry);

        // 生产环境可发送到错误监控服务
        // errorReportingService.send(logEntry);
    },

    handleReauth() {
        // 清除Token,跳转登录
        authModule.logout();
        window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
    }
};

常见问题与解决方案

问题1:重试导致重复操作

代码示例

// 问题:POST请求重试可能导致重复创建数据

// 解决方案1:使用幂等键
async function createOrder(orderData) {
    const idempotencyKey = crypto.randomUUID();
    return fetch('/api/orders', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey  // 服务端根据此键去重
        },
        body: JSON.stringify(orderData)
    });
}

// 解决方案2:POST请求不自动重试
api.addErrorInterceptor((error, config) => {
    if (config.method === 'POST' && error.status >= 500) {
        // 提示用户手动重试
        showRetryDialog();
        return;
    }
});

问题2:并发请求全部失败

代码示例

// 问题:多个请求同时失败,同时重试导致重试风暴

// 解决方案:使用断路器 + 抖动
const circuitBreaker = new CircuitBreaker({
    failureThreshold: 5,
    resetTimeout: 30000
});

// 抖动确保不同请求的重试时间不同
function calculateDelayWithJitter(attempt) {
    const baseDelay = 1000 * Math.pow(2, attempt);
    return baseDelay * (0.5 + Math.random() * 0.5);
}

问题3:错误信息泄露敏感数据

代码示例

// 问题:错误信息中包含服务器内部信息

// 解决方案:前端不直接展示原始错误信息
function sanitizeErrorMessage(error) {
    // 不展示原始错误信息
    // 使用预定义的用户友好提示
    const userMessage = getUserFriendlyMessage(error);

    // 开发环境可以展示详细信息
    if (process.env.NODE_ENV === 'development') {
        console.error('详细错误:', error);
    }

    return userMessage;
}

总结

错误处理与重试是构建高可用前端应用的关键能力,本教程涵盖了以下内容:

  1. 错误类型分类:理解网络错误、HTTP错误(4xx/5xx)、业务错误、CORS错误等不同类型的错误特征和处理方式。

  2. 错误处理策略:掌握自定义错误类、统一错误处理函数、用户友好提示等错误处理最佳实践。

  3. 重试机制:实现固定间隔、指数退避、指数退避+抖动等重试策略,理解不同策略的适用场景和优缺点。

  4. 断路器模式:学习断路器的CLOSED/OPEN/HALF_OPEN三种状态转换,防止级联失败和重试风暴。

  5. 完整请求封装:将超时控制、重试机制、断路器、拦截器集成为生产级HTTP客户端。

  6. 降级方案:掌握缓存降级、备用服务降级、静态数据降级、骨架屏降级等容错策略。

  7. 最佳实践:幂等请求重试、超时设置、用户提示、错误日志、敏感信息保护等生产环境必备知识。

常见问题

什么是19. 错误处理与重试?

19. 错误处理与重试是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。

如何学习19. 错误处理与重试的实际应用?

教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。

19. 错误处理与重试有哪些注意事项?

常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。

19. 错误处理与重试适合初学者吗?

本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。

19. 错误处理与重试的核心要点是什么?

核心要点包括教程简介等内容,建议按教程顺序逐步学习。

标签: Promise OAuth 响应码 async await 键盘输入 场景管理 JWT 请求缓存

本文由小确幸生活整理发布,转载请注明出处

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 后端通信:18. 请求缓存策略 - 从入门到实践详解 下一篇: 后端通信:20. API设计与文档 - 从入门到实践详解

poll相关推荐