santifer/career-ops · error

collage: unrecognized response envelope (expected a…

Error message

collage: unrecognized response envelope (expected a positions array)

What it means

parseCollageResponse validates the JSON returned by the Collage HR public job-site API before extracting positions. The library accepts either a bare array of positions or an object with a `positions` array; any other envelope (object, string, null, or an object with differently-named fields) means the endpoint's shape changed or the wrong URL was fetched, so it throws instead of silently returning zero jobs. This guards the scanner against silent API drift.

Solutions

  1. Log the actual response body for the failing tenant and compare it with the documented `{positions: [...]}` envelope
  2. Check the Collage API URL in portals.yml resolves to the correct /v1/positions/<job-site-address> endpoint (try it with curl)
  3. Check for a provider/library update: Collage may have renamed the field; update parseCollageResponse or the provider to the new envelope
  4. Handle error bodies explicitly: if the JSON has an `error`/`message` field, surface that message instead of the generic envelope error

Example fix

// before
fetch('https://api.collage.co/v1/positions/mytenant').then(r => r.json()) // -> {data: [...]} passed straight to parseCollageResponse
// after
const json = await res.json();
if (Array.isArray(json)) return parseCollageResponse(json, name);
if (Array.isArray(json?.positions)) return parseCollageResponse(json, name);
if (Array.isArray(json?.data)) return parseCollageResponse({positions: json.data}, name); // adapt known envelope variants
throw new Error('unexpected collage envelope: ' + JSON.stringify(json).slice(0, 200));
Defensive patterns

Strategy: type-guard

Validate before calling

function isCollageEnvelope(json) {
  return Array.isArray(json) || (json && typeof json === 'object' && Array.isArray(json.positions));
}
if (!isCollageEnvelope(json)) console.warn('skipping tenant: unexpected envelope', json);

Type guard

function isCollageEnvelope(json) {
  return Array.isArray(json) || (json !== null && typeof json === 'object' && Array.isArray(json.positions));
}

Try / catch

try {
  const jobs = parseCollageResponse(json, company);
} catch (err) {
  if (String(err.message).includes('unrecognized response envelope')) {
    logger.warn({company, bodySample: JSON.stringify(json)?.slice(0, 200)}, 'collage envelope changed; skipping');
  } else throw err;
}

Prevention

When it happens

Trigger: ctx.fetchJson returns JSON that is neither an array nor `{positions: [...]}` — e.g. an HTML error page parsed loosely, a Collage API version change renaming `positions`, a rate-limit/auth JSON body like `{error: ...}`, or fetching the wrong (non-positions) endpoint.

Common situations: Collage changes their careers-api response shape; a portals.yml entry points at a stale or wrong /v1/positions/<site> address; the tenant's job site was deleted so the API returns an error object; a proxy or WAF intercepts the request and returns a non-API JSON body.

Related errors


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

Appendix: source

Thrown at providers/collage.mjs:74

function text(value) { return typeof value === 'string' ? value.trim() : ''; }

/** @param {unknown} value */
function absoluteHttpsUrl(value) {
  const raw = text(value);
  if (!raw) return '';
  try {
    const parsed = new URL(raw);
    if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) return '';
    return parsed.href;
  } catch {
    return '';
  }
}

/** @param {any} json @param {string} companyName */
export function parseCollageResponse(json, companyName) {
  const rows = Array.isArray(json) ? json : Array.isArray(json?.positions) ? json.positions : null;
  if (!rows) throw new Error('collage: unrecognized response envelope (expected a positions array)');
  return rows.filter(j => j && text(j.title)).map(j => {
    const url = absoluteHttpsUrl(j.hostedUrl) || absoluteHttpsUrl(j.url) || absoluteHttpsUrl(j.applyUrl);
    if (!url) return null;
    const location = Array.isArray(j.location) ? j.location.map(text).filter(Boolean).join('; ') : text(j.location);
    const metadata = [
      text(j.department) && `Department: ${text(j.department)}`,
      text(j.commitment) && `Commitment: ${text(j.commitment)}`,
      text(j.employmentType) && `Employment type: ${text(j.employmentType)}`,
    ].filter(Boolean).join('\n');
    const description = [text(j.descriptionPlain), metadata].filter(Boolean).join('\n\n');
    return {
      title: text(j.title), url, company: companyName,
      location, description,
      postedAt: toEpochMs(j.createdDate ?? j.createdAt ?? j.publishedAt),
    };
  }).filter(Boolean);
}

View on GitHub (pinned to aac998c7ed)