{"record":{"id":"6fe6587d0abbe8ff","repo":"can1357/oh-my-pi","slug":"extractdetail-body-resp-statustext-http","errorCode":null,"errorMessage":"extractDetail(body) ?? resp.statusText ?? `HTTP ${resp.status}`","messagePattern":"extractDetail\\(body\\) \\?\\? resp\\.statusText \\?\\? `HTTP (.+?)`","errorType":"exception","errorClass":"ApiError","httpStatus":null,"severity":"error","filePath":"python/robomp/web/src/api.ts","lineNumber":38,"sourceCode":"  if (body == null || typeof body !== \"object\") return null;\n  const detail = (body as Record<string, unknown>).detail;\n  if (typeof detail === \"string\") return detail;\n  const message = (body as Record<string, unknown>).message;\n  if (typeof message === \"string\") return message;\n  return null;\n}\n\nasync function unwrap<T>(resp: Response): Promise<T> {\n  let body: unknown = null;\n  try {\n    body = await resp.json();\n  } catch {\n    // Endpoint returned non-JSON. For 2xx that's still valid for callers that\n    // expect an empty body; we only surface the parse failure on errors.\n  }\n  if (!resp.ok) {\n    const detail = extractDetail(body) ?? resp.statusText ?? `HTTP ${resp.status}`;\n    throw new ApiError(resp.status, detail);\n  }\n  return body as T;\n}\n\nfunction authHeaders(): Record<string, string> {\n  return { ...AUTH_HEADERS };\n}\n\nfunction jsonHeaders(): Record<string, string> {\n  return { \"Content-Type\": \"application/json\", ...AUTH_HEADERS };\n}\n\nexport const api = {\n  status(signal?: AbortSignal): Promise<StatusResponse> {\n    return fetch(\"/api/status\", { signal }).then(unwrap<StatusResponse>);\n  },\n  logs(limit = 400, signal?: AbortSignal): Promise<LogsResponse> {\n    return fetch(`/api/logs?limit=${limit}`, { signal }).then(unwrap<LogsResponse>);","sourceCodeStart":20,"sourceCodeEnd":56,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/python/robomp/web/src/api.ts#L20-L56","documentation":"The dashboard API client's unwrap() throws ApiError whenever a fetch resolves with a non-ok HTTP response. It tries resp.json(), extracts a 'detail' or 'message' string from the parsed body (FastAPI's error shape), and falls back to resp.statusText then 'HTTP {status}'. Any backend rejection — 400/401/404/409 from endpoints like /api/cancel or /api/trigger — surfaces here as a thrown ApiError with the status attached.","triggerScenarios":"Any api.* call whose response has status >= 400: cancel of unknown/finished delivery (404/409), trigger with missing/invalid payload fields, missing or wrong X-Robomp-Replay-Token auth header (401/403), server returning HTML error pages (non-JSON body → detail falls back to statusText), network-level proxy errors.","commonSituations":"AUTH_HEADERS misconfigured or token rotated so requests are rejected; dashboard stale state → cancelling an already-finished delivery; backend down behind the Vite proxy (proxy returns 500/502 with non-JSON body); bug sending undefined delivery_id → 400.","solutions":["Read err.status and err.message (the detail field) — the FastAPI backend always includes a descriptive detail for 4xx from these endpoints.","Fix the triggering request: for cancel errors, refresh state and only cancel deliveries shown as running; for trigger errors, supply a valid issue or delivery_id.","Check AUTH_HEADERS / X-Robomp-Replay-Token configuration in web/src/config if 401/403.","If the message is generic statusText, the server returned non-JSON — check backend logs and the Vite proxy target (:8080).","Handle the promise rejection in the UI (toast/error state) instead of letting it bubble as an unhandled rejection."],"exampleFix":"// before\napi.cancel(id); // unhandled ApiError\n// after\ntry {\n  await api.cancel(id);\n} catch (e) {\n  if (e instanceof ApiError && e.status === 409) showToast(\"Task already finished\");\n  else showToast(`Cancel failed: ${e instanceof ApiError ? e.message : e}`);\n}","handlingStrategy":"try-catch","validationCode":"function assertNonEmpty(v: string | undefined, name: string): string {\n  if (!v) throw new Error(`${name} is required before calling the API`);\n  return v;\n}\nassertNonEmpty(deliveryId, 'delivery_id');","typeGuard":"function isApiError(e: unknown): e is ApiError {\n  return e instanceof ApiError && typeof e.status === 'number';\n}","tryCatchPattern":"try {\n  await api.cancel(id);\n} catch (e) {\n  if (isApiError(e)) {\n    showError(e.status === 409 ? 'Already finished' : e.message);\n  } else {\n    showError('Network failure'); // fetch threw before unwrap\n  }\n}","preventionTips":["Always await api.* calls inside try/catch — unwrap throws ApiError for every non-2xx.","Validate payload fields (issue, delivery_id) before calling trigger/cancel.","Keep AUTH_HEADERS (X-Robomp-Replay-Token) in sync with the backend token.","Match FastAPI's error shape ({detail}) when adding new endpoints so extractDetail keeps working.","Check the Vite proxy target (:8080) and backend health when errors carry only statusText."],"tags":["fetch","http-error","api-client","typescript","solidjs"],"backgroundTag":"http-request-failed","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}