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
- Log the actual response body for the failing tenant and compare it with the documented `{positions: [...]}` envelope
- Check the Collage API URL in portals.yml resolves to the correct /v1/positions/<job-site-address> endpoint (try it with curl)
- Check for a provider/library update: Collage may have renamed the field; update parseCollageResponse or the provider to the new envelope
- 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
- Wrap the raw fetch so non-2xx/HTML bodies are rejected before parsing
- Log a sample of unexpected response bodies to detect Collage API drift early
- Pin and monitor the Collage API version you call (/v1/...)
- Test parseCollageResponse against a recorded fixture in CI
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
- 4dayweek: unexpected API response on page
- a16z-speedrun-talent: unexpected API response on page
- agentic-jobs: unexpected API response shape on page
- arbeitnow: unexpected API response on page
- garena: unexpected API response — expected jobs[], got keys
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)