{"record":{"id":"0687c18bf9826a18","repo":"santifer/career-ops","slug":"manfred-unexpected-api-response-expected-a-json","errorCode":null,"errorMessage":"manfred: unexpected API response — expected a JSON array of offers, got ${json === null ? 'null' : typeof json}","messagePattern":"manfred: unexpected API response — expected a JSON array of offers, got (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/manfred.mjs","lineNumber":196,"sourceCode":"  return job;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'manfred',\n\n  detect(entry) {\n    return entry?.provider === 'manfred' ? { url: buildFeedUrl(entry) } : null;\n  },\n\n  async fetch(entry, ctx) {\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 the request on getmanfred.com.\n    const url = assertManfredUrl(buildFeedUrl(entry));\n    const json = /** @type {any} */ (await ctx.fetchJson(url, { redirect: 'error' }));\n    if (!Array.isArray(json)) {\n      throw new Error(\n        `manfred: unexpected API response — expected a JSON array of offers, got ${json === null ? 'null' : typeof json}`,\n      );\n    }\n    const fallbackCompany = entry?.name;\n    const out = [];\n    for (const offer of json) {\n      const normalized = normalizeManfredOffer(offer, fallbackCompany);\n      if (normalized) out.push(normalized);\n    }\n    return out;\n  },\n};\n","sourceCodeStart":178,"sourceCodeEnd":209,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/manfred.mjs#L178-L209","documentation":"Thrown by the manfred provider's fetch() after ctx.fetchJson() returns successfully but the parsed body is not a JSON array. The Manfred job feed API (getmanfred.com) is contractually expected to return a top-level array of offer objects; any other shape (object, string, number, null) is treated as a contract break or upstream API change. This is a response-shape validator, not a network error — the HTTP request succeeded.","triggerScenarios":"The URL passed assertManfredUrl() and the fetch completed with redirect:'error', but the JSON body is not an array. Specific triggers: (1) Manfred changes their API to return a wrapper object like {offers:[...]} or {data:[...]}; (2) the endpoint returns an error object like {error:'rate limited', message:'...'} with HTTP 200; (3) an HTML error page is served with Content-Type: application/json and parsed as a string; (4) the feed URL points to a page that returns a single JSON object (e.g. a 301-moved JSON envelope if redirect handling changed).","commonSituations":"The most common real-world hit is an unannounced Manfred API version bump that wraps results in an envelope. Other situations: rate-limiting responses returned as JSON objects with status 200, or a misconfigured entry.api pointing to a non-feed Manfred endpoint (e.g. a single-offer detail endpoint). Transient upstream issues where a CDN returns a JSON error object instead of the feed also trigger this.","solutions":["Check what the endpoint actually returns now: curl -sL <feed-url> | head -c 500 — if it's an envelope like {data:[...]}, the provider needs updating to unwrap it.","Verify the entry in portals.yml points to the correct Manfred feed URL (buildFeedUrl output) and not a stale or wrong endpoint.","If Manfred changed their API shape, update normalizeManfordOffer and the Array.isArray check in providers/manfred.mjs:196 to unwrap the new envelope.","If the response is a JSON error object with HTTP 200, add status-code awareness to ctx.fetchJson or check for known error keys before the array assertion."],"exampleFix":"// before\nconst json = await ctx.fetchJson(url, { redirect: 'error' });\nif (!Array.isArray(json)) {\n  throw new Error(`manfred: unexpected API response ...`);\n}\n\n// after — tolerate a known envelope shape\nconst json = await ctx.fetchJson(url, { redirect: 'error' });\nconst offers = Array.isArray(json) ? json : (Array.isArray(json?.offers) ? json.offers : null);\nif (!offers) {\n  throw new Error(`manfred: unexpected API response — expected array or {offers:[]}, got ${json === null ? 'null' : typeof json}`);\n}","handlingStrategy":"type-guard","validationCode":"// Before calling the provider, verify the Manfred feed returns an array\n// by probing the endpoint (or trust the provider's own guard and catch).\n// Pre-flight check is impractical for a remote API — the type guard is\n// the runtime check itself, best handled in a catch boundary.\nif (process.env.MANFRED_STRICT === '1') {\n  const probe = await fetch(feedUrl).then(r => r.json());\n  if (!Array.isArray(probe)) {\n    console.warn('manfred feed shape changed — expected array, got', typeof probe);\n  }\n}","typeGuard":"/** @param {unknown} json @returns {json is any[]} */\nfunction isOfferArray(json) {\n  return Array.isArray(json);\n}\n\n// usage in caller:\nconst json = await ctx.fetchJson(url, { redirect: 'error' });\nif (!isOfferArray(json)) {\n  // log and skip, or unwrap a known envelope\n  return [];\n}","tryCatchPattern":"try {\n  const jobs = await manfredProvider.fetch(entry, ctx);\n} catch (err) {\n  if (String(err.message).startsWith('manfred: unexpected API response')) {\n    // API contract drift — log for investigation, don't crash the batch\n    console.error(`manfred API shape changed for ${entry.name}:`, err.message);\n    continue; // skip this provider, keep scanning others\n  }\n  throw err; // re-throw unrelated errors\n}","preventionTips":["Wrap each provider call in a try-catch that skips the failed provider rather than aborting the entire scan batch.","Log the raw API response body when shape validation fails so contract drift can be diagnosed without reproducing.","Pin the Manfred API version in the feed URL if versioned endpoints are available.","Monitor for Manfred API changelog announcements when using this provider in production."],"tags":["api-contract","response-validation","manfred","json"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}