santifer/career-ops · error · Error
ibm: unexpected API response — expected hits.hits[], got key
Error message
ibm: unexpected API response — expected hits.hits[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}] What it means
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.
Source
Thrown at providers/ibm.mjs:54
if (categories.length) {
must.push({ bool: { should: categories.map(c => ({ term: { field_keyword_08: c } })) } });
}
const country = typeof cfg.country === 'string' ? cfg.country.trim() : '';
if (country) must.push({ term: { field_keyword_05: country } });
return { bool: { must } };
}
/**
* Normalizes one page of the IBM careers API response into job entries.
* Throws if the response doesn't carry the expected `hits.hits[]` shape, so a
* silent endpoint change surfaces as a hard error instead of empty results.
* @param {any} json - A single API response page.
* @returns {Array<{title: string, url: string, company: string, location: string}>}
*/
export function parseIbmResponse(json) {
const hits = json && json.hits && Array.isArray(json.hits.hits) ? json.hits.hits : null;
if (!hits) {
throw new Error(`ibm: unexpected API response — expected hits.hits[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
}
const out = [];
for (const h of hits) {
const s = (h && h._source) || {};
if (typeof s.title !== 'string' || s.title.trim() === '') continue;
if (typeof s.url !== 'string' || !/^https?:\/\//i.test(s.url.trim())) continue;
const loc = typeof s.field_keyword_19 === 'string' ? s.field_keyword_19.trim() : '';
const mode = typeof s.field_keyword_17 === 'string' ? s.field_keyword_17.trim() : '';
out.push({
title: s.title.trim(),
url: s.url.trim(),
company: 'IBM',
location: [loc, mode].filter(Boolean).join(' · '),
});
}
return out;
}View on GitHub (pinned to 9b17a8ac97)
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.
Defensive patterns
Strategy: try-catch
Validate before calling
// Pre-flight the first page and shape-check before walking the IBM API.
const json = await ctx.fetchJson(pageUrl, { redirect: 'error' });
if (!json?.hits || !Array.isArray(json.hits.hits)) {
console.warn(`ibm: missing hits.hits — keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
} Type guard
/** IBM Elastic-style envelope: { hits: { hits: [...] } }. */
function isIbmEnvelope(json) {
return !!json && typeof json === 'object'
&& !!json.hits && typeof json.hits === 'object'
&& Array.isArray(json.hits.hits);
} Try / catch
try {
const jobs = parseIbmResponse(json);
} catch (err) {
if (/expected hits\.hits/.test(err.message)) {
// Contract drift or an error envelope — log the keys, do not silently return [].
console.error(`ibm: ${err.message}`);
return [];
}
throw err;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- himalayas: unexpected API response - expected { jobs: [...]
- jobicy: unexpected API response — expected { jobs: [...] },
- hackernews: unexpected item response for thread ${threadId}
- Could not parse existing candidates file at ${candidatesPath
- Existing candidates file at ${candidatesPath} is not a JSON
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/c2b1f81c2f10dc4b.
Report an issue: GitHub.