{"record":{"id":"2416f03b13c471b1","repo":"santifer/career-ops","slug":"garena-unexpected-api-response-expected-jobs-got-keys-json","errorCode":null,"errorMessage":"garena: unexpected API response — expected jobs[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]","messagePattern":"garena: unexpected API response — expected jobs\\[\\], got keys: \\[(.+?)\\]","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"providers/garena.mjs","lineNumber":40,"sourceCode":"// Response shape: { jobs: [{ id, title, tags: { location: string[], ... },\n// description }] }. One request, no pagination — `jobs` is the whole board.\n\nconst API_URL = 'https://careers.garena.com/api/job/list';\nconst HOST = 'careers.garena.com';\nconst DEFAULT_OFFICE = 'global';\n\n/**\n * Normalizes one Garena API response into job entries.\n * Throws if the response doesn't carry the expected `jobs[]` shape, so a\n * silent endpoint change surfaces as a hard error instead of empty results.\n * @param {any} json - The API response.\n * @param {{ name?: string, garena?: { office?: string } }} entry\n * @returns {Array<{title: string, url: string, company: string, location: string, description?: string}>}\n */\nexport function parseGarenaResponse(json, entry) {\n  const jobs = json && Array.isArray(json.jobs) ? json.jobs : null;\n  if (!jobs) {\n    throw new Error(`garena: unexpected API response — expected jobs[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);\n  }\n\n  const officeSegment = urlSegment('office', resolveOffice(entry));\n  const company = (entry && entry.name) || 'Garena';\n\n  const out = [];\n  for (const j of jobs) {\n    if (!j || typeof j.title !== 'string' || j.title.trim() === '') continue;\n    const id = j.id != null ? String(j.id).trim() : '';\n    if (!id) continue;\n    // `id` is remote API data: a lone UTF-16 surrogate makes encodeURIComponent\n    // throw URIError, and a `.`/`..` would be a traversal segment. Either way,\n    // drop just this posting — the same thing the `!id` guard above does —\n    // instead of the throw aborting the whole page's parse loop (#3513).\n    const idSegment = idUrlSegment(id);\n    if (idSegment === null) continue;\n\n    const locations = Array.isArray(j.tags && j.tags.location)","sourceCodeStart":22,"sourceCodeEnd":58,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/garena.mjs#L22-L58","documentation":"parseGarenaResponse in providers/garena.mjs expects the Garena jobs API JSON to contain a jobs array at the top level (json.jobs). If it is absent or not an array, the function throws with the actual top-level keys listed for diagnosis. This is a strict contract check that fails fast instead of silently producing zero jobs.","triggerScenarios":"The Garena endpoint returns {error: ...}, {data: {jobs: [...]}} (wrapped envelope), an empty object {}, or a JSON error body — anything where Array.isArray(json.jobs) is false.","commonSituations":"Garena changing their API to wrap results in a data envelope; region/office parameter rejected server-side yielding an error JSON; WAF or rate-limiter returning a JSON block page; office config resolving to an endpoint that answers with a different schema.","solutions":["Inspect the 'got keys' in the message: 'data' suggests a new envelope — unwrap it; 'message'/'error' suggests an API error to address.","Check whether the Garena careers API moved or restructured; update parseGarenaResponse to the new schema.","Verify the resolved office/URL parameters (resolveOffice + urlSegment) produce a valid endpoint that returns the jobs list.","Log the raw response body once to confirm whether you are receiving the jobs API at all versus an HTML/interstitial response."],"exampleFix":"// before\nconst jobs = json && Array.isArray(json.jobs) ? json.jobs : null;\n// after\nconst jobs = Array.isArray(json?.jobs) ? json.jobs\n  : Array.isArray(json?.data?.jobs) ? json.data.jobs : null;","handlingStrategy":"type-guard","validationCode":"function hasJobsArray(json) {\n  return !!json && typeof json === 'object' && Array.isArray(json.jobs);\n}","typeGuard":"const isGarenaJobsResponse = (json) =>\n  typeof json === 'object' && json !== null && Array.isArray(json.jobs);","tryCatchPattern":"try {\n  const jobs = parseGarenaResponse(json, entry);\n} catch (e) {\n  if (e.message.includes('unexpected API response')) {\n    console.error('Garena schema drift or API error; keys:', Object.keys(json ?? {}).join(','));\n    return [];\n  }\n  throw e;\n}","preventionTips":["Snapshot-test parseGarenaResponse against a captured real response so schema drift is caught early.","Check the provider's developer announcements for API envelope changes before upgrading.","Treat zero-result scans from one provider as a signal to dump and inspect the raw body."],"tags":["api","response-shape","contract-change","json"],"backgroundTag":"unexpected-response-shape","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}