{"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/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/ibm.mjs#L36-L72","documentation":"Thrown by parseIbmResponse when the IBM careers API response lacks the expected hits.hits[] structure (an Elastic-style envelope). The guard is explicit so that an endpoint restructure surfaces as a hard error rather than silently returning zero jobs — a deliberate choice documented in the function's JSDoc.","triggerScenarios":"IBM restructured its careers API (renamed hits, nested under a different key, or switched envelopes); the endpoint returned an error object ({ error: ... }, { message: ... }); a paginated response page with a different shape; rate-limit/edge response parsed as JSON without the hits tree.","commonSituations":"IBM careers platform migration; A/B test serving a new response shape to some clients; transient backend error returned with 200; a stale cached response from a CDN after an API change.","solutions":["Retry once to rule out a transient error envelope.","Hit the IBM careers endpoint directly and inspect the top-level keys shown in the message; if the envelope changed, update the hits.hits path in parseIbmResponse.","If the change is persistent, disable the ibm entry until the parser is updated.","Add a regression test pinning the known-good response shape so drift is caught early."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Pre-flight the first page and shape-check before walking the IBM API.\nconst json = await ctx.fetchJson(pageUrl, { redirect: 'error' });\nif (!json?.hits || !Array.isArray(json.hits.hits)) {\n  console.warn(`ibm: missing hits.hits — keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);\n}","typeGuard":"/** IBM Elastic-style envelope: { hits: { hits: [...] } }. */\nfunction isIbmEnvelope(json) {\n  return !!json && typeof json === 'object'\n    && !!json.hits && typeof json.hits === 'object'\n    && Array.isArray(json.hits.hits);\n}","tryCatchPattern":"try {\n  const jobs = parseIbmResponse(json);\n} catch (err) {\n  if (/expected hits\\.hits/.test(err.message)) {\n    // Contract drift or an error envelope — log the keys, do not silently return [].\n    console.error(`ibm: ${err.message}`);\n    return [];\n  }\n  throw err;\n}","preventionTips":["Pin the IBM Elastic envelope shape in a unit test so a restructure surfaces early.","Treat a missing hits.hits as a hard signal (contract drift), not as an empty result.","When IBM migrates its careers API, update parseIbmResponse's path and keep the guard."],"tags":["external-api","data-integrity","ibm","contract-drift","elastic"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}