{"record":{"id":"88b5b0f0c1971b24","repo":"santifer/career-ops","slug":"echojobs-unexpected-api-response-on-page-page","errorCode":null,"errorMessage":"echojobs: unexpected API response on page ${page} — expected { jobs: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]","messagePattern":"echojobs: unexpected API response on page (.+?) — expected (.+?), got keys: \\[(.+?)\\]","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/echojobs.mjs","lineNumber":153,"sourceCode":"  id: 'echojobs',\n\n  detect(entry) {\n    return entry?.provider === 'echojobs' ? { url: FEED_BASE } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const maxPages = resolveMaxPages(entry);\n    const fallbackCompany = entry?.name;\n    const out = [];\n\n    for (let page = 1; page <= maxPages; page++) {\n      // Validate the URL actually fetched (not just a constant) so the host pin\n      // is meaningful, then redirect:'error' blocks SSRF via server-side\n      // redirects — together they keep every page request on echojobs.io.\n      const url = assertEchojobsUrl(`${FEED_BASE}?per_page=${PER_PAGE}&page=${page}`);\n      const json = /** @type {any} */ (await ctx.fetchJson(url, { redirect: 'error' }));\n      if (!json || !Array.isArray(json.jobs)) {\n        throw new Error(\n          `echojobs: unexpected API response on page ${page} — expected { jobs: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`,\n        );\n      }\n      for (const j of json.jobs) {\n        const normalized = normalizeEchojobsJob(j, fallbackCompany);\n        if (normalized) out.push(normalized);\n      }\n      if (json.jobs.length < PER_PAGE) break; // short page → last page reached\n    }\n    return out;\n  },\n};\n","sourceCodeStart":135,"sourceCodeEnd":166,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/echojobs.mjs#L135-L166","documentation":"The echojobs feed response did not match the expected { jobs: [...] } shape: either the JSON was null/non-object, or json.jobs was absent/not an array. This is a real runtime error raised after a successful fetch, signalling the upstream API changed shape, returned an error body, or served a non-JSON page. The message includes the actual top-level keys (or 'null') to aid diagnosis.","triggerScenarios":"ctx.fetchJson returned null; returned an error object like { error: '...' } or { message: '...' } (e.g. rate-limit 429 body, auth failure, maintenance); the API renamed the 'jobs' field; an HTML error page was parsed as an unexpected object.","commonSituations":"EchoJobs deploys a breaking API change (field rename, new envelope); a rate-limit or outage returns an error body with a different shape; an intermediate proxy (corporate firewall, CDN) returns a block page that parses as JSON-ish; a regional API variant with a different envelope.","solutions":["Log the actual response keys/body shown in the message to identify whether it is an error envelope, a rename, or a non-JSON page.","If the API renamed the field, update the Array.isArray(json.jobs) check in providers/echojobs.mjs to the new key.","If it is a transient error/rate-limit body, retry after a backoff or skip the run; treat a persistent shape change as a provider bug to fix.","Confirm the endpoint is still https://echojobs.io/api/jobs and that no proxy is rewriting the response."],"exampleFix":"// before — expects the documented envelope\nif (!json || !Array.isArray(json.jobs)) { throw ... }\n\n// after — API renamed 'jobs' to 'results'\nif (!json || !Array.isArray(json.results)) { throw ... }\n// (and iterate json.results below)","handlingStrategy":"try-catch","validationCode":"// Cannot validate a remote response before fetching it. Best-effort shape probe after fetch:\nasync function probeEchojobsShape(ctx, url) {\n  const json = await ctx.fetchJson(url, { redirect: 'error' });\n  return json && Array.isArray(json.jobs) ? json : null;\n}","typeGuard":"function isEchojobsEnvelope(json) {\n  return !!json && typeof json === 'object' && Array.isArray(json.jobs);\n}","tryCatchPattern":"try { await echojobs.fetch(entry, ctx); }\ncatch (e) {\n  if (/^echojobs: unexpected API response/.test(e.message)) {\n    // upstream changed shape or returned an error body — inspect the keys in the message,\n    // retry once after backoff for transient rate-limit bodies, then skip/disable if persistent\n  } else throw e;\n}","preventionTips":["Pin the provider version so a silent upstream API change surfaces immediately.","Log the actual top-level keys on failure to distinguish rename vs error envelope vs block page.","For transient error bodies, retry with backoff; for persistent shape changes, update the parser."],"tags":["echojobs","runtime","api-contract","response-shape","parsing"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}