{"record":{"id":"c2c9c52b53cd6a1a","repo":"star7th/showdoc","slug":"http-res-status-res-statustext","errorCode":null,"errorMessage":"HTTP ${res.status}: ${res.statusText}","messagePattern":"HTTP (.+?): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web_src/src/api/aiAgent.ts","lineNumber":271,"sourceCode":"  const url = serverHost + '/api/agent/agent'\n\n  // 异步执行，不 await\n  ;(async () => {\n    let reader: ReadableStreamDefaultReader<any> | null = null\n    try {\n      const res = await fetch(url, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n          Accept: 'text/event-stream',\n          'Cache-Control': 'no-cache',\n        },\n        body: JSON.stringify(body),\n        signal: controller.signal,\n      })\n\n      if (!res.ok) {\n        throw new Error(`HTTP ${res.status}: ${res.statusText}`)\n      }\n\n      if (!res.body) {\n        throw new Error('Response body is empty, server did not return a stream. Please try again later.')\n      }\n\n      reader = res.body.getReader()\n      const decoder = new TextDecoder('utf-8')\n      let buffer = ''\n\n      while (true) {\n        const { value, done } = await reader.read()\n        if (done) break\n\n        buffer += decoder.decode(value, { stream: true })\n\n        // SSE 按双换行分割\n        const parts = buffer.split('\\n\\n')","sourceCodeStart":253,"sourceCodeEnd":289,"githubUrl":"https://github.com/star7th/showdoc/blob/6a3fa91eee5ebf36ba9d9cba17e0fea6dfd4bb89/web_src/src/api/aiAgent.ts#L253-L289","documentation":"Thrown by the frontend SSE client in sendAgentMessage() after POSTing JSON to {serverHost}/api/agent/agent: the fetch resolved but res.ok was false, i.e. the server answered with a 4xx/5xx status, so the code discards the response and throws `HTTP <status>: <statusText>`. Note that over HTTP/2 statusText is always empty, so the real message often looks like `HTTP 401: ` or `HTTP 500: `. The SSE stream is never opened; params.onError receives this Error.","triggerScenarios":"POST /api/agent/agent with an expired/invalid user_token or missing guest_token (server rejects auth -> 401/403); getServerHost() pointing at the wrong origin or an old deployment so the route 404s; a PHP fatal on the server (including the RuntimeException from server/app/Common/bootstrap.php when the SQLite/MySQL connection fails) surfacing as 500; a nginx/apache proxy in front of the PHP server timing out a long agent turn -> 502/504; request body rejected (413) when editor_content is very large.","commonSituations":"User left the tab open past token expiry and sends a new agent message; frontend deployed against a server that was rolled back or missing the agent routes; SQLite file permissions broke after a migration, making every server route return 500; reverse proxy (nginx) buffering/timeout defaults killing long-lived SSE POSTs; HTTP/2 deployments where statusText is empty making the error look malformed.","solutions":["Log/inspect res.status and the response body (await res.text() before throwing) to identify the exact status; 401/403 means token, 404 means wrong server host/route, 500 means server-side logs (check PHP error log / bootstrap failure), 502/504 means proxy timeout.","For 401/403: refresh user_token (re-login) or ensure getGuestToken() returns a valid guest token before calling sendAgentMessage.","For 404: verify getServerHost() resolves to the server that actually exposes /api/agent/agent and that the server build is current.","For 500: fix the server-side cause (see server logs; commonly the bootstrap DB failure in server/app/Common/bootstrap.php).","For 502/504 on long turns: raise proxy_read_timeout and disable buffering for the /api/agent/ location (proxy_buffering off; X-Accel-Buffering: no already sent by the app).","Include the status text and a short body excerpt in the thrown Error so users and logs see the real cause."],"exampleFix":"// before\nif (!res.ok) {\n  throw new Error(`HTTP ${res.status}: ${res.statusText}`)\n}\n\n// after\nif (!res.ok) {\n  const detail = await res.text().catch(() => '')\n  const err = new Error(`HTTP ${res.status}: ${res.statusText || ''} ${detail.slice(0, 200)}`.trim())\n  ;(err as any).status = res.status\n  throw err\n}","handlingStrategy":"try-catch","validationCode":"const userToken = getUserToken()\nif (!userToken && !getGuestToken()) {\n  params.onError(new Error('Not authenticated: no user_token or guest_token available'))\n  return { abort: () => {} }\n}","typeGuard":"function isHttpStatusError(e: unknown): e is Error & { status: number } {\n  return e instanceof Error && /^HTTP \\d{3}/.test(e.message) && typeof (e as any).status === 'number'\n}","tryCatchPattern":"// inside the existing async IIFE in sendAgentMessage, keep ONE catch that classifies:\ntry {\n  // ... fetch + SSE loop ...\n} catch (e: any) {\n  if (e.name === 'AbortError') return            // user-initiated abort, not an error\n  if (typeof e.status === 'number') {\n    if (e.status === 401 || e.status === 403) params.onError(new Error('Login expired, please re-authenticate'))\n    else if (e.status >= 500) params.onError(new Error('Server error, please retry later'))\n    else params.onError(e)\n  } else {\n    params.onError(e instanceof Error ? e : new Error(String(e)))\n  }\n}","preventionTips":["Attach the numeric status to the thrown Error so callers can branch without string-matching the message.","Read res.text() on non-OK responses before throwing; the server's error body often names the real cause.","Validate token presence before opening the stream; refresh expired tokens proactively.","For deployments behind nginx, configure proxy_buffering off and a proxy_read_timeout larger than the longest agent turn to avoid 502/504."],"tags":["http-status","sse","fetch","agent-stream","frontend"],"backgroundTag":"http-error-status","analyzedSha":"6a3fa91eee5ebf36ba9d9cba17e0fea6dfd4bb89","analyzedAt":"2026-08-21T01:16:20.916Z","schemaVersion":2},"datasetVersion":"2026-08-21T03:17:12.404Z"}