santifer/career-ops · error · Error

remotive: unexpected API response — expected { jobs: [...] }

Error message

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

What it means

The remotive provider fetches FEED_URL and asserts the response is an object with a jobs array ({ jobs: [...] }). It throws when json is falsy or json.jobs is not an array, listing the actual top-level keys in the message for diagnostics. This catches upstream contract changes, error envelopes, and outages.

Source

Thrown at providers/remotive.mjs:28

// Wire in via a `job_boards:` entry with `provider: remotive`.

const FEED_URL = 'https://remotive.com/api/remote-jobs';

/** @type {Provider} */
export default {
  id: 'remotive',

  /**
   * Fetches and normalizes postings from the Remotive public feed.
   * @param {{ name?: string }} entry - The job_boards entry being processed.
   * @param {{ fetchJson: (url: string, opts?: { redirect?: 'error'|'follow'|'manual' }) => Promise<any> }} ctx - HTTP context.
   * @returns {Promise<Array<{title: string, url: string, company: string, location: string}>>}
   */
  async fetch(entry, ctx) {
    // redirect:'error' prevents SSRF via server-side redirects
    const json = await ctx.fetchJson(FEED_URL, { redirect: 'error' });
    if (!json || !Array.isArray(json.jobs)) {
      throw new Error(`remotive: unexpected API response — expected { jobs: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
    }

    return json.jobs
      .filter(j => j && typeof j === 'object'
        && typeof j.title === 'string' && j.title.trim() !== ''
        && typeof j.url === 'string' && /^https?:\/\//i.test(j.url.trim()))
      .map(j => ({
        title: j.title.trim(),
        url: j.url.trim(),
        company: typeof j.company_name === 'string' && j.company_name.trim() ? j.company_name.trim() : (entry.name || 'Remotive'),
        location: typeof j.candidate_required_location === 'string' ? j.candidate_required_location.trim() : '',
      }));
  },
};

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Retry after a delay — outages and rate limits are often transient.
  2. curl the FEED_URL to see the current top-level keys; the error message already lists them.
  3. If remotive renamed the field, update the parser to read the new key (e.g. results) into json.jobs.
  4. Confirm the FEED_URL constant matches the current documented endpoint.

Example fix

// before
if (!json || !Array.isArray(json.jobs)) throw new Error(...);
// after — tolerate renamed field
const jobs = json?.jobs ?? json?.results;
if (!Array.isArray(jobs)) throw new Error('remotive: unexpected API response');
Defensive patterns

Strategy: type-guard

Validate before calling

async function probeRemotive(ctx) {
  const json = await ctx.fetchJson(FEED_URL, { redirect: 'error' });
  return !!json && Array.isArray(json.jobs);
}
if (!(await probeRemotive(ctx))) {
  console.warn('remotive: feed missing jobs array — skipping');
}

Type guard

/** @param {unknown} d */
function isRemotiveFeed(d) {
  return !!d && typeof d === 'object' && Array.isArray(/** @type{any}*/(d).jobs);
}

Try / catch

try {
  await provider.fetch(entry, ctx);
} catch (e) {
  if (/unexpected API response/.test(e.message)) {
    await new Promise(r => setTimeout(r, 5000));
    await provider.fetch(entry, ctx);
  } else throw e;
}

Prevention

When it happens

Trigger: The feed returns null; an error object like { error, message } without a jobs key; a maintenance page parsed as { status: 'down' }; a version change that renamed jobs to results or offers.

Common situations: Remotive API is under maintenance; a rate-limit response replaced the normal payload; the FEED_URL was updated to a new API version with a different envelope; a proxy returned a JSON error block.

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/06d9547434b3e161. Report an issue: GitHub.