santifer/career-ops · error

garena: unexpected API response — expected jobs[], got keys

Error message

garena: unexpected API response — expected jobs[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]

What it means

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.

Solutions

  1. Inspect the 'got keys' in the message: 'data' suggests a new envelope — unwrap it; 'message'/'error' suggests an API error to address.
  2. Check whether the Garena careers API moved or restructured; update parseGarenaResponse to the new schema.
  3. Verify the resolved office/URL parameters (resolveOffice + urlSegment) produce a valid endpoint that returns the jobs list.
  4. Log the raw response body once to confirm whether you are receiving the jobs API at all versus an HTML/interstitial response.

Example fix

// before
const jobs = json && Array.isArray(json.jobs) ? json.jobs : null;
// after
const jobs = Array.isArray(json?.jobs) ? json.jobs
  : Array.isArray(json?.data?.jobs) ? json.data.jobs : null;
Defensive patterns

Strategy: type-guard

Validate before calling

function hasJobsArray(json) {
  return !!json && typeof json === 'object' && Array.isArray(json.jobs);
}

Type guard

const isGarenaJobsResponse = (json) =>
  typeof json === 'object' && json !== null && Array.isArray(json.jobs);

Try / catch

try {
  const jobs = parseGarenaResponse(json, entry);
} catch (e) {
  if (e.message.includes('unexpected API response')) {
    console.error('Garena schema drift or API error; keys:', Object.keys(json ?? {}).join(','));
    return [];
  }
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/2416f03b13c471b1. Report an issue: GitHub.

Appendix: source

Thrown at providers/garena.mjs:40

// Response shape: { jobs: [{ id, title, tags: { location: string[], ... },
// description }] }. One request, no pagination — `jobs` is the whole board.

const API_URL = 'https://careers.garena.com/api/job/list';
const HOST = 'careers.garena.com';
const DEFAULT_OFFICE = 'global';

/**
 * Normalizes one Garena API response into job entries.
 * Throws if the response doesn't carry the expected `jobs[]` shape, so a
 * silent endpoint change surfaces as a hard error instead of empty results.
 * @param {any} json - The API response.
 * @param {{ name?: string, garena?: { office?: string } }} entry
 * @returns {Array<{title: string, url: string, company: string, location: string, description?: string}>}
 */
export function parseGarenaResponse(json, entry) {
  const jobs = json && Array.isArray(json.jobs) ? json.jobs : null;
  if (!jobs) {
    throw new Error(`garena: unexpected API response — expected jobs[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
  }

  const officeSegment = urlSegment('office', resolveOffice(entry));
  const company = (entry && entry.name) || 'Garena';

  const out = [];
  for (const j of jobs) {
    if (!j || typeof j.title !== 'string' || j.title.trim() === '') continue;
    const id = j.id != null ? String(j.id).trim() : '';
    if (!id) continue;
    // `id` is remote API data: a lone UTF-16 surrogate makes encodeURIComponent
    // throw URIError, and a `.`/`..` would be a traversal segment. Either way,
    // drop just this posting — the same thing the `!id` guard above does —
    // instead of the throw aborting the whole page's parse loop (#3513).
    const idSegment = idUrlSegment(id);
    if (idSegment === null) continue;

    const locations = Array.isArray(j.tags && j.tags.location)

View on GitHub (pinned to aac998c7ed)