{"record":{"id":"d4a22be8ece7f88c","repo":"santifer/career-ops","slug":"getonbrd-unexpected-api-response-for-category","errorCode":null,"errorMessage":"getonbrd: unexpected API response for category \"${category}\" on page ${page} — expected { data: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]","messagePattern":"getonbrd: unexpected API response for category \"(.+?)\" on page (.+?) — expected (.+?), got keys: \\[(.+?)\\]","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/getonbrd.mjs","lineNumber":182,"sourceCode":"\n  async fetch(entry, ctx) {\n    const categories = resolveCategories(entry);\n    const maxPages = resolveMaxPages(entry);\n    const fallbackCompany = entry?.name;\n    const out = [];\n    // A posting can appear under several categories; first sighting wins so the\n    // scanner never sees the same URL twice from one entry.\n    const seen = new Set();\n\n    for (const category of categories) {\n      const base = assertGetonbrdUrl(feedBase(category));\n\n      for (let page = 1; page <= maxPages; page++) {\n        const url = `${base}?per_page=${PER_PAGE}&expand[]=company&page=${page}`;\n        // redirect:'error' prevents SSRF via server-side redirects\n        const json = await ctx.fetchJson(url, { redirect: 'error' });\n        if (!json || !Array.isArray(json.data)) {\n          throw new Error(\n            `getonbrd: unexpected API response for category \"${category}\" on page ${page} — expected { data: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`,\n          );\n        }\n        for (const j of json.data) {\n          const normalized = normalizeGetonbrdJob(j, fallbackCompany);\n          if (!normalized || seen.has(normalized.url)) continue;\n          seen.add(normalized.url);\n          out.push(normalized);\n        }\n        if (json.data.length < PER_PAGE) break; // short page → last page reached\n      }\n    }\n    return out;\n  },\n};\n","sourceCodeStart":164,"sourceCodeEnd":198,"githubUrl":"https://github.com/santifer/career-ops/blob/1696bec4d021768e7359f9aad6b329cba883da20/providers/getonbrd.mjs#L164-L198","documentation":"The getonbrd provider fetch loop calls fetchJson on the Getonbrd API per category/page and expects a JSON envelope of shape { data: [...] }. If the response is null, not an object, or lacks an array `data`, the provider throws this error listing the keys actually received. It signals the API contract changed, the endpoint returned an error payload, or a non-JSON/empty body was parsed.","triggerScenarios":"fetchJson returns null (empty/204 body), an error object like { error: '...' }, a paginated envelope with a different key (e.g. { jobs: [...] }), or an HTML error page that fails to parse upstream — for any page within 1..maxPages of a category fetch.","commonSituations":"Getonbrd API version change relocating the array; rate-limit or auth responses shaped differently; category slug no longer exists so the API returns an error payload; transient 5xx rendered as a non-standard body; `expand[]=company` parameter change.","solutions":["Read the 'got keys' list in the message — it tells you the actual response shape and usually identifies the new envelope key.","Verify the category slug is valid by opening the API URL manually in a browser/curl.","Check whether Getonbrd changed its API response format and update normalize/fetch code accordingly.","Retry after a delay if a transient server issue is suspected; add rate-limit backoff if calls are too aggressive.","Confirm network middleware isn't returning an HTML error page or empty body (proxy, VPN, corporate firewall)."],"exampleFix":"// before\nconst json = await ctx.fetchJson(url, { redirect: 'error' });\nif (!json || !Array.isArray(json.data)) throw new Error(`...`);\n// after\nconst json = await ctx.fetchJson(url, { redirect: 'error' });\nconst items = json?.data ?? json?.jobs ?? json?.results;\nif (!Array.isArray(items)) throw new Error(`getonbrd: unexpected response keys: ${Object.keys(json || {}).join(',')}`);","handlingStrategy":"type-guard","validationCode":"const res = await fetch(url);\nconst json = await res.json();\nif (res.ok && json && typeof json === 'object' && Array.isArray(json.data)) {\n  // safe to proceed\n}","typeGuard":"function isGetonbrdPage(json) {\n  return typeof json === 'object' && json !== null && Array.isArray(json.data);\n}","tryCatchPattern":"try {\n  const json = await ctx.fetchJson(url, { redirect: 'error' });\n  if (!isGetonbrdPage(json)) {\n    console.warn(`getonbrd: non-standard response for ${category} page ${page}:`, Object.keys(json || {}));\n    break; // stop paging, keep earlier pages' results\n  }\n} catch (e) {\n  if (e.message.includes('unexpected API response')) {\n    await sleep(retryDelayMs);\n    continue; // retry once for transient issues\n  }\n  throw e;\n}","preventionTips":["Check the 'got keys' output against the live API whenever Getonbrd ships changes.","Add a smoke test that hits one real category/page and asserts the { data: [] } shape.","Use per_page pagination carefully and stop when data is empty rather than over-requesting.","Wrap fetchJson with retry/backoff for transient 5xx/empty bodies."],"tags":["api-response","schema-validation","network","getonbrd"],"backgroundTag":"schema-validation-failed","analyzedSha":"1696bec4d021768e7359f9aad6b329cba883da20","analyzedAt":"2026-09-01T19:19:23.111Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}