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

  1. Retry once to rule out a transient error envelope.
  2. 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.
  3. If the change is persistent, disable the ibm entry until the parser is updated.
  4. 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

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


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/c2b1f81c2f10dc4b. Report an issue: GitHub.