{"record":{"id":"c2b1f81c2f10dc4b","repo":"santifer/career-ops","slug":"ibm-unexpected-api-response-expected-hits-hits","errorCode":null,"errorMessage":"ibm: unexpected API response — expected hits.hits[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]","messagePattern":"ibm: unexpected API response — expected hits\\.hits\\[\\], got keys: \\[(.+?)\\]","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/ibm.mjs","lineNumber":54,"sourceCode":"  if (categories.length) {\n    must.push({ bool: { should: categories.map(c => ({ term: { field_keyword_08: c } })) } });\n  }\n  const country = typeof cfg.country === 'string' ? cfg.country.trim() : '';\n  if (country) must.push({ term: { field_keyword_05: country } });\n  return { bool: { must } };\n}\n\n/**\n * Normalizes one page of the IBM careers API response into job entries.\n * Throws if the response doesn't carry the expected `hits.hits[]` shape, so a\n * silent endpoint change surfaces as a hard error instead of empty results.\n * @param {any} json - A single API response page.\n * @returns {Array<{title: string, url: string, company: string, location: string}>}\n */\nexport function parseIbmResponse(json) {\n  const hits = json && json.hits && Array.isArray(json.hits.hits) ? json.hits.hits : null;\n  if (!hits) {\n    throw new Error(`ibm: unexpected API response — expected hits.hits[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);\n  }\n\n  const out = [];\n  for (const h of hits) {\n    const s = (h && h._source) || {};\n    if (typeof s.title !== 'string' || s.title.trim() === '') continue;\n    if (typeof s.url !== 'string' || !/^https?:\\/\\//i.test(s.url.trim())) continue;\n    const loc = typeof s.field_keyword_19 === 'string' ? s.field_keyword_19.trim() : '';\n    const mode = typeof s.field_keyword_17 === 'string' ? s.field_keyword_17.trim() : '';\n    out.push({\n      title: s.title.trim(),\n      url: s.url.trim(),\n      company: 'IBM',\n      location: [loc, mode].filter(Boolean).join(' · '),\n    });\n  }\n  return out;\n}","sourceCodeStart":36,"sourceCodeEnd":72,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/ibm.mjs#L36-L72","documentation":"parseIbmResponse expects IBM's careers API (Elasticsearch-style) envelope: json.hits.hits must be an array. If the response is null, missing `hits`, or hits.hits is not an array, it throws with the top-level keys of the received object for diagnosis.","triggerScenarios":"The IBM search API returns an error object ({ error: ... }), an empty body parsed as null, a rate-limit/auth JSON, or IBM migrates to a new API whose envelope no longer nests hits.hits[].","commonSituations":"IBM changes its careers search backend (Elasticsearch replaced/rewrapped); the request URL is stale and hits a redirect/error page returning JSON; a proxy strips the body; per-page fetch 2 returns an error object mid-pagination.","solutions":["Call the IBM search URL manually and inspect the returned JSON keys to confirm the envelope changed","Update parseIbmResponse to the new envelope (e.g. json.hits.hits -> json.results / json.documents)","Check for an upstream error/rate-limit payload in the printed keys and fix the request (headers, page params)","Skip the IBM board and report the provider breakage until the parser is updated"],"exampleFix":"// before\nconst hits = json && json.hits && Array.isArray(json.hits.hits) ? json.hits.hits : null;\n// after\nconst hits = (Array.isArray(json?.hits?.hits) && json.hits.hits)\n  || (Array.isArray(json?.results) && json.results)\n  || null;","handlingStrategy":"type-guard","validationCode":"function looksLikeIbmEnvelope(json) {\n  return !!json && typeof json === 'object' && Array.isArray(json?.hits?.hits);\n}\n// before parsing each page: if (!looksLikeIbmEnvelope(json)) handle gracefully;","typeGuard":"const hasHits = (v) =>\n  typeof v === 'object' && v !== null && Array.isArray(v.hits?.hits);","tryCatchPattern":"try {\n  const jobs = parseIbmResponse(json);\n} catch (err) {\n  if (/expected hits\\.hits/.test(err.message)) {\n    console.error('IBM careers API envelope changed:', err.message);\n    return [];\n  }\n  throw err;\n}","preventionTips":["Save a sample API response as a fixture and regression-test parseIbmResponse against it","Handle non-200/status errors before JSON shape checks so error bodies don't masquerade as schema changes","Log received keys on failure (the error already does) to speed triage","Monitor the IBM careers endpoint for backend migrations"],"tags":["api","schema","response-validation","elasticsearch"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}