santifer/career-ops · error · Error

Notion ${method} ${path} -> ${j.code}: ${j.message}

Error message

Notion ${method} ${path} -> ${j.code}: ${j.message}

What it means

This is the generic Notion API error path: after every fetch, the client parses JSON and, if the HTTP status is not ok (r.ok is false), throws an Error embedding the Notion API's own code and message. This surfaces upstream API problems (auth, rate limit, validation, not-found, conflict) with the API's own diagnostic text rather than a generic network error.

Source

Thrown at plugins/notion/_notion.mjs:77

/**
 * Build a Notion client bound to one user's token + parent page. Network goes
 * through the injected `fetchFn` (the plugin passes ctx.fetch so the engine's
 * allowedHosts/HTTPS/redirect guard applies); falls back to global fetch for
 * standalone use. Nothing here reads process.env.
 * @param {{ token: string, parent: string, fetch?: Function }} cfg
 */
export function createNotionClient({ token, parent, fetch: fetchFn = globalThis.fetch }) {
  if (!token) throw new Error('NOTION_ACCESS_TOKEN is not set (.env) — the Notion plugin needs it to read/write.');
  const HEADERS = { Authorization: `Bearer ${token}`, 'Notion-Version': '2025-09-03', 'Content-Type': 'application/json' };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  async function api(path, method, body) {
    await sleep(360); // ~3 req/s
    // ctx.fetch throws on non-2xx (its message carries the body); the !r.ok
    // branch below is the fallback when a plain global fetch is injected.
    const r = await fetchFn(`https://api.notion.com/v1/${path}`, { method, headers: HEADERS, body: body ? JSON.stringify(body) : undefined });
    const j = await r.json();
    if (!r.ok) throw new Error(`Notion ${method} ${path} -> ${j.code}: ${j.message}`);
    return j;
  }

  /** Create a page in a data source. `markdown` (optional) becomes the page body. */
  async function createPage(dataSourceId, properties, markdown) {
    const body = { parent: { type: 'data_source_id', data_source_id: dataSourceId }, properties };
    if (markdown) body.markdown = markdown;
    return api('pages', 'POST', body);
  }

  /** Map of DB name → primary data source id for every DB under the parent page. */
  async function resolveDBs() {
    if (!parent) throw new Error('Set NOTION_PARENT_PAGE_ID in .env (the "Career Ops" parent page id).');
    const out = {};
    let cursor;
    do {
      const j = await api(`blocks/${parent}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`, 'GET');
      for (const b of j.results) {

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Read the `code` and `message` in the error text — Notion's codes are specific (e.g. unauthorized_for_token, validation_error, restricted_resource, object_not_found, rate_limited).
  2. For unauthorized_for_token: regenerate the integration secret and update NOTION_ACCESS_TOKEN in .env.
  3. For object_not_found / restricted_resource: open the target page in Notion and connect (share) it with the integration.
  4. For rate_limited: the client already throttles; if it still fires, reduce the batch size of the operation.
  5. For 5xx: retry after a short wait (Notion outages are transient).

Example fix

// The error itself is diagnostic — fix the underlying cause, not this line.
// Example: page not shared with integration
//   Notion → open "Career Ops" page → ⋯ menu → Connect to → <integration name>
// Then retry; the POST pages call returns 200.
Defensive patterns

Strategy: retry

Validate before calling

// No caller-side validation prevents an upstream API error; validate inputs that commonly cause 4xx instead.
function validatePageProps(properties) {
  if (!properties || typeof properties !== 'object') throw new Error('properties must be an object');
  // Notion rejects > 256 chars titles and unknown property names; trim/whitelist here.
  return true;
}

Type guard

/** @param {unknown} r @returns {r is { ok: boolean, json: any }} */
function isFetchResponse(r) {
  return !!r && typeof r === 'object' && typeof r.ok === 'boolean' && typeof r.json === 'function';
}

Try / catch

async function notionCallWithRetry(client, op, args, retries = 2) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await client[op](...args);
    } catch (err) {
      const msg = String(err?.message || '');
      const transient = /rate_limited|5\d\d|conflict_error/.test(msg);
      if (transient && attempt < retries) {
        await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
        continue;
      }
      throw err;
    }
  }
}

Prevention

When it happens

Trigger: Any Notion API call returns a non-2xx: a 401 (invalid/expired token), 404 (page/data_source not found or not shared with integration), 400 (malformed request body), 429 (rate limit — though the client already self-throttles to ~3 req/s via a 360ms sleep), or 5xx (Notion-side outage). The method and path in the message identify which call failed (e.g. `POST pages`, `GET blocks/<id>/children`).

Common situations: Integration token revoked or regenerated (old secret in .env); target database or page not shared with the integration (404 object_not_found); hitting Notion's request-size limit by sending too many properties; transient 5xx during Notion incident.

Related errors


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