{
  "name": "DocuQuery 3 - Error Handler",
  "nodes": [
    {
      "parameters": {},
      "id": "212f25b3-b5e1-4b9f-ab41-4fb67fc5d9bf",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "typeVersion": 1,
      "position": [
        0,
        190
      ],
      "notes": "Fires for any execution that failed in a workflow pointing here."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/errors.js ---------------------------\n/**\n * Error classification for the shared error-handling sub-workflow.\n *\n * n8n hands the Error Trigger a blob describing the failed execution. Turning\n * that into a category, a plain-English cause and a retry decision is what\n * makes the difference between an alert someone can act on and a stack trace\n * nobody reads.\n */\n\nconst CATEGORIES = {\n  RATE_LIMIT: 'Rate limit',\n  AUTH: 'Authentication',\n  PDF_PARSE: 'PDF parsing',\n  QUOTA: 'Quota / billing',\n  NOT_FOUND: 'Not found',\n  TIMEOUT: 'Timeout',\n  VECTOR_DB: 'Vector database',\n  BAD_REQUEST: 'Bad request',\n  UNKNOWN: 'Unknown',\n};\n\nconst RULES = [\n  {\n    category: CATEGORIES.RATE_LIMIT,\n    retryable: true,\n    test: (t, s) => s === 429 || /rate.?limit|too many requests|429/i.test(t),\n    advice: 'Back off and retry. If it repeats, lower EMBED_BATCH_SIZE or the poll frequency.',\n  },\n  {\n    category: CATEGORIES.QUOTA,\n    retryable: false,\n    test: (t) => /insufficient_quota|billing|exceeded your current quota|payment required/i.test(t),\n    advice: 'Top up or raise the plan limit on the provider - retrying will not help.',\n  },\n  {\n    category: CATEGORIES.AUTH,\n    retryable: false,\n    test: (t, s) => s === 401 || s === 403 || /unauthor|forbidden|invalid.*api.?key|invalid_api_key/i.test(t),\n    advice: 'Re-authorise the credential on the failing node. Google OAuth tokens expire.',\n  },\n  {\n    category: CATEGORIES.PDF_PARSE,\n    retryable: false,\n    test: (t) =>\n      /pdf|no extractable text|encrypted|password|corrupt|invalid xref|bad xref|stream must have/i.test(t),\n    advice:\n      'The file is not machine-readable - likely a scan with no OCR layer, or password protected. ' +\n      'OCR it or remove it from the watched folder.',\n  },\n  {\n    category: CATEGORIES.VECTOR_DB,\n    retryable: true,\n    test: (t) => /pinecone|vector dimension|index not found|namespace/i.test(t),\n    advice:\n      'Check PINECONE_INDEX_HOST and that PINECONE_DIMENSION matches the embedding model output size.',\n  },\n  {\n    category: CATEGORIES.TIMEOUT,\n    retryable: true,\n    test: (t, s) => s === 408 || s === 504 || /timeout|etimedout|econnreset|socket hang up/i.test(t),\n    advice: 'Transient network problem. The node-level retry usually clears it.',\n  },\n  {\n    category: CATEGORIES.NOT_FOUND,\n    retryable: false,\n    test: (t, s) => s === 404 || /not found|no such file|deleted/i.test(t),\n    advice: 'The file or record no longer exists. Safe to ignore if the document was removed.',\n  },\n  {\n    category: CATEGORIES.BAD_REQUEST,\n    retryable: false,\n    test: (t, s) => s === 400 || s === 422 || /invalid request|validation|unprocessable/i.test(t),\n    advice: 'A payload was malformed - check the node that built it before retrying.',\n  },\n];\n\nfunction firstString(...values) {\n  for (const v of values) {\n    if (typeof v === 'string' && v.trim()) return v.trim();\n  }\n  return '';\n}\n\n/**\n * @param {object} payload The Error Trigger item (`$json`).\n */\nfunction classifyError(payload = {}) {\n  const execution = payload.execution ?? {};\n  const workflow = payload.workflow ?? {};\n  const error = execution.error ?? payload.error ?? {};\n  const context = error.context ?? {};\n\n  const message = firstString(\n    error.message,\n    error.description,\n    context.message,\n    typeof error === 'string' ? error : '',\n    'Unknown error',\n  );\n  const description = firstString(error.description, context.description);\n  const haystack = `${message} ${description} ${JSON.stringify(context).slice(0, 500)}`;\n\n  const status = Number(\n    error.httpCode ?? error.statusCode ?? context.httpCode ?? error.status ?? NaN,\n  );\n\n  const rule =\n    RULES.find((r) => {\n      try {\n        return r.test(haystack, status);\n      } catch {\n        return false;\n      }\n    }) ?? {\n      category: CATEGORIES.UNKNOWN,\n      retryable: true,\n      advice: 'No matching rule - open the execution in n8n to see the failing node.',\n    };\n\n  const failedNode = firstString(\n    error.node?.name,\n    execution.lastNodeExecuted,\n    payload.lastNodeExecuted,\n  );\n\n  return {\n    category: rule.category,\n    retryable: rule.retryable,\n    advice: rule.advice,\n    message,\n    description: description || null,\n    http_status: Number.isFinite(status) ? status : null,\n    failed_node: failedNode || null,\n    workflow_name: firstString(workflow.name, payload.workflowName) || null,\n    workflow_id: firstString(workflow.id, payload.workflowId) || null,\n    execution_id: firstString(execution.id, payload.executionId) || null,\n    execution_url: firstString(execution.url, payload.executionUrl) || null,\n    execution_mode: firstString(execution.mode) || null,\n    stack: typeof error.stack === 'string' ? error.stack.slice(0, 2000) : null,\n  };\n}\n\n/**\n * Pull the document identity out of the failed execution, when there is one.\n * Ingestion failures should mark that document Failed; query failures should\n * not touch the Documents table at all.\n */\nfunction extractDocument(payload = {}) {\n  const runData = payload.execution?.data?.resultData?.runData ?? {};\n  const candidates = [\n    'Chunk Document',\n    'Normalize File Event',\n    'Decide Action',\n    'Loop Over Documents',\n  ];\n\n  for (const nodeName of candidates) {\n    const runs = runData[nodeName];\n    if (!Array.isArray(runs)) continue;\n    for (const run of runs) {\n      const items = run?.data?.main?.[0];\n      if (!Array.isArray(items)) continue;\n      for (const item of items) {\n        const json = item?.json ?? {};\n        if (json.doc_id) {\n          return {\n            doc_id: String(json.doc_id),\n            filename: json.filename ? String(json.filename) : null,\n            retry_count: Number(json.retry_count ?? 0),\n          };\n        }\n      }\n    }\n  }\n  return null;\n}\n\nfunction slackAlertText(info, document) {\n  const lines = [\n    `:rotating_light: *DocuQuery ${info.category} failure*`,\n    document ? `*Document:* ${document.filename ?? document.doc_id}` : null,\n    `*Workflow:* ${info.workflow_name ?? 'unknown'}${info.failed_node ? ` - node \"${info.failed_node}\"` : ''}`,\n    `*Error:* ${info.message}`,\n    `*What to do:* ${info.advice}`,\n    info.execution_url ? `<${info.execution_url}|Open the failed execution>` : null,\n  ];\n  return lines.filter(Boolean).join('\\n');\n}\n// --- end src/lib/errors.js\n\n// --- inlined from src/lib/airtable.js -------------------------\n/**\n * Airtable helpers for the ingestion state table.\n *\n * The pipeline talks to Airtable over plain HTTP rather than through the\n * Airtable node, so that the record shape lives here in reviewable code instead\n * of inside a node's resource-mapper UI state.\n */\n\n/** Airtable formula strings are single-quoted; escape backslashes then quotes. */\nfunction escapeFormulaValue(value) {\n  return String(value ?? '')\n    .replace(/\\\\/g, '\\\\\\\\')\n    .replace(/'/g, \"\\\\'\");\n}\n\nfunction lookupFormula(docIds) {\n  const ids = [...new Set((docIds ?? []).filter(Boolean).map(String))];\n  if (!ids.length) return { formula: 'FALSE()', doc_ids: [] };\n  const clauses = ids.map((id) => `{doc_id} = '${escapeFormulaValue(id)}'`);\n  return {\n    formula: clauses.length === 1 ? clauses[0] : `OR(${clauses.join(', ')})`,\n    doc_ids: ids,\n  };\n}\n\n/** Airtable v2 responses nest under `fields`; flatten and keep the record id. */\nfunction flattenRecords(response) {\n  const records = response?.records ?? (Array.isArray(response) ? response : []);\n  return records.map((record) => ({\n    record_id: record.id ?? null,\n    ...(record.fields ?? record),\n  }));\n}\n\nfunction indexByDocId(records) {\n  const map = new Map();\n  for (const record of records) {\n    if (record.doc_id) map.set(String(record.doc_id), record);\n  }\n  return map;\n}\n\n/** Fields written when a document is successfully indexed. */\nfunction indexedFields(doc, nowIso) {\n  return {\n    doc_id: doc.doc_id,\n    filename: doc.filename,\n    drive_url: doc.web_view_link ?? null,\n    department: doc.department ?? null,\n    drive_md5: doc.drive_md5 ?? null,\n    content_hash: doc.content_hash ?? null,\n    status: 'Indexed',\n    chunk_count: doc.chunk_count ?? 0,\n    page_count: doc.page_count ?? 0,\n    char_count: doc.char_count ?? 0,\n    embedding_model: doc.embedding_model ?? null,\n    index_namespace: doc.index_namespace ?? null,\n    upload_date: doc.upload_date ? String(doc.upload_date).slice(0, 10) : null,\n    modified_time: doc.modified_time ?? null,\n    last_indexed: nowIso,\n    last_checked: nowIso,\n    retry_count: 0,\n    error_notes: '',\n  };\n}\n\n/** A document seen but deliberately not re-embedded. */\nfunction skippedFields(doc, nowIso) {\n  return {\n    doc_id: doc.doc_id,\n    filename: doc.filename,\n    drive_url: doc.web_view_link ?? null,\n    department: doc.department ?? null,\n    drive_md5: doc.drive_md5 ?? null,\n    status: doc.existing?.status === 'Indexed' ? 'Indexed' : 'Skipped',\n    last_checked: nowIso,\n  };\n}\n\nfunction failedFields(doc, error, nowIso) {\n  return {\n    doc_id: doc.doc_id,\n    filename: doc.filename ?? null,\n    drive_url: doc.web_view_link ?? null,\n    department: doc.department ?? null,\n    drive_md5: doc.drive_md5 ?? null,\n    status: 'Failed',\n    retry_count: Number(doc.retry_count ?? 0) + 1,\n    error_notes: String(error ?? 'unknown error').slice(0, 5000),\n    last_checked: nowIso,\n  };\n}\n\nfunction logFields({ docId, filename, event, detail, executionId, workflow }, nowIso) {\n  return {\n    timestamp: nowIso,\n    doc_id: docId ?? null,\n    filename: filename ?? null,\n    event,\n    detail: String(detail ?? '').slice(0, 5000),\n    execution_id: executionId ?? null,\n    workflow: workflow ?? null,\n  };\n}\n\n/** Body for Airtable's upsert-on-PATCH. `typecast` lets new select options through. */\nfunction upsertBody(fieldsList, mergeOn = ['doc_id']) {\n  return {\n    performUpsert: { fieldsToMergeOn: mergeOn },\n    typecast: true,\n    records: fieldsList.map((fields) => ({ fields })),\n  };\n}\n\nfunction createBody(fieldsList) {\n  return { typecast: true, records: fieldsList.map((fields) => ({ fields })) };\n}\n// --- end src/lib/airtable.js\n\n/**\n * Node: \"Classify Error\"\n *\n * The Error Trigger fires for anything that escaped a node's own error output -\n * an unhandled throw, a credential expiring, a provider outage. This turns the\n * raw execution blob into a category, a cause in plain English, a retry\n * decision, and (when the failure belongs to a specific document) the Airtable\n * update that marks it Failed.\n */\n\nconst payload = $input.first().json;\nconst info = classifyError(payload);\nconst document = extractDocument(payload);\nconst nowIso = new Date().toISOString();\n\nconsole.log(`${info.category}: ${info.message} (node \"${info.failed_node ?? 'unknown'}\")`);\n\nconst detail = [\n  info.message,\n  info.failed_node ? `node: ${info.failed_node}` : null,\n  info.http_status ? `HTTP ${info.http_status}` : null,\n  `retryable: ${info.retryable}`,\n].filter(Boolean).join(' | ');\n\nreturn [\n  {\n    json: {\n      ...info,\n      document,\n      // Only ingestion failures own a document row. A query failure must not\n      // mark somebody's policy PDF as broken.\n      has_document: Boolean(document),\n      alert_text: slackAlertText(info, document),\n      document_fields: document\n        ? failedFields(\n            { doc_id: document.doc_id, filename: document.filename, retry_count: document.retry_count },\n            `${info.category}: ${info.message}`,\n            nowIso,\n          )\n        : null,\n      error_fields: {\n        timestamp: nowIso,\n        category: info.category,\n        message: String(info.message).slice(0, 5000),\n        workflow: info.workflow_name,\n        failed_node: info.failed_node,\n        doc_id: document?.doc_id ?? null,\n        filename: document?.filename ?? null,\n        http_status: info.http_status,\n        retryable: info.retryable,\n        advice: info.advice,\n        execution_id: info.execution_id,\n        execution_url: info.execution_url,\n      },\n      log_fields: logFields(\n        {\n          docId: document?.doc_id ?? null,\n          filename: document?.filename ?? null,\n          event: 'Error',\n          detail,\n          executionId: info.execution_id,\n          workflow: info.workflow_name,\n        },\n        nowIso,\n      ),\n    },\n  },\n];\n"
      },
      "id": "a70bff75-2e80-4f39-afc5-355c38456646",
      "name": "Classify Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        190
      ],
      "notes": "Category, plain-English cause, retry verdict, and the document identity when the failure belongs to one."
    },
    {
      "parameters": {
        "options": {
          "timeout": 30000
        },
        "method": "POST",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/{{ encodeURIComponent($env.AIRTABLE_ERRORS_TABLE || \"Errors\") }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ typecast: true, records: [{ fields: $json.error_fields }] }) }}"
      },
      "id": "cbbee4a6-7ed2-4272-a267-e67f4ccfab6e",
      "name": "Airtable: Log Error",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        600,
        190
      ],
      "notes": "Every failure is recorded, whether or not a document is involved.",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "8db93003-fc5f-4fcc-a36a-405025bfb082",
              "leftValue": "={{ $('Classify Error').first().json.has_document }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "1065a8eb-3427-4287-afb1-a00c6af285a4",
      "name": "Belongs To A Document?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        900,
        190
      ],
      "notes": "A query-side failure must not mark somebody’s policy PDF as broken."
    },
    {
      "parameters": {
        "options": {
          "timeout": 30000
        },
        "method": "PATCH",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/{{ encodeURIComponent($env.AIRTABLE_DOCUMENTS_TABLE || \"Documents\") }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ performUpsert: { fieldsToMergeOn: [\"doc_id\"] }, typecast: true, records: [{ fields: $('Classify Error').first().json.document_fields }] }) }}"
      },
      "id": "61121f86-86c9-41b5-a0a5-e011192e82de",
      "name": "Airtable: Mark Document Failed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        38
      ],
      "notes": "Increments retry_count, so the nightly sweep eventually stops re-trying it.",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "options": {
          "timeout": 30000
        },
        "method": "POST",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/{{ encodeURIComponent($env.AIRTABLE_LOG_TABLE || \"Ingestion Log\") }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ typecast: true, records: [{ fields: $('Classify Error').first().json.log_fields }] }) }}"
      },
      "id": "ec5438e7-3054-4c6d-ab32-7a475fb546d2",
      "name": "Airtable: Log To Ingestion Log",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1500,
        38
      ],
      "notes": "Keeps the per-document timeline complete, alongside indexes and skips.",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "options": {
          "timeout": 15000
        },
        "method": "POST",
        "url": "https://slack.com/api/chat.postMessage",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "slackApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ channel: $env.SLACK_OPS_CHANNEL, text: $('Classify Error').first().json.alert_text, unfurl_links: false }) }}"
      },
      "id": "2a3ed083-a319-42d3-af3a-2d4b58ee414b",
      "name": "Slack: Alert Ops",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1800,
        190
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput",
      "notes": "Credential: Slack API. Message includes the category and what to do about it."
    },
    {
      "parameters": {},
      "id": "419fd5a4-e236-4bbf-ac65-1b43916c52a9",
      "name": "Handled",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2100,
        190
      ]
    },
    {
      "parameters": {
        "content": "## Scope\n\nThis is the **net**, not the first line of defence.\n\nExpected failures - unreadable PDF, 429, token expiry mid-loop - are handled\nin the ingestion workflow itself, on each node’s error output. That document\nmarks itself `Failed` and the queue keeps moving.\n\nWhat reaches here escaped that: a revoked credential, a provider outage, a bug.",
        "width": 560,
        "height": 280,
        "color": 3
      },
      "id": "f6914096-31b4-41f9-a59a-de61dbffa8f7",
      "name": "Note: What This Catches",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -300
      ]
    },
    {
      "parameters": {
        "content": "## Backoff lives on the nodes\n\nBy the time an Error Trigger fires, the execution is already over. Retrying\nfrom here would mean re-running an entire workflow blind, with no memory of\nhow far it got.\n\nSo rate-limit backoff sits on the nodes that make the calls\n(`retryOnFail`, `waitBetweenTries`), and anything still unfinished is picked\nup by the **Nightly Sweep** - bounded by `MAX_INGEST_RETRIES` via `retry_count`.",
        "width": 600,
        "height": 300,
        "color": 5
      },
      "id": "787b0492-c30d-4008-a33e-b405a83ff5d8",
      "name": "Note: Why No Retry Loop",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1160,
        -320
      ]
    },
    {
      "parameters": {
        "content": "## Enabling this\n\nIt does not attach itself. In **both** other workflows:\n\n`... menu -> Settings -> Error Workflow -> DocuQuery 3 - Error Handler`\n\nThen save. Test it by revoking a credential and running an ingest.",
        "width": 520,
        "height": 220,
        "color": 4
      },
      "id": "9ad4646c-0fde-414d-a5bd-789ca3f27a90",
      "name": "Note: Wiring",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1160,
        620
      ]
    }
  ],
  "connections": {
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Classify Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify Error": {
      "main": [
        [
          {
            "node": "Airtable: Log Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable: Log Error": {
      "main": [
        [
          {
            "node": "Belongs To A Document?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Belongs To A Document?": {
      "main": [
        [
          {
            "node": "Airtable: Mark Document Failed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: Alert Ops",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable: Mark Document Failed": {
      "main": [
        [
          {
            "node": "Airtable: Log To Ingestion Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable: Log To Ingestion Log": {
      "main": [
        [
          {
            "node": "Slack: Alert Ops",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack: Alert Ops": {
      "main": [
        [
          {
            "node": "Handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}