If your AI side-hustle automation script (Puppeteer poster, OpenAI batch caller, a cron job that scrapes and publishes) exits with code 0 but produces nothing, this is the checklist I ran to make it actually move bytes. By the end you will be able to (1) prove your .env is loaded before any client is constructed, (2) stop the silent ESM/CommonJS require is not defined crash, and (3) make node-fetch/native fetch survive corporate proxies and TLS interception. Real numbers from my own pipeline that posts to Qiita and Pinterest nightly.
1. Why Node.js exits 0 but your OpenAI call never fires (unhandled promise check)
The single most common reason an automation tool "runs but does nothing" is a forgotten await. Node finishes the synchronous stack, sees no pending work it's tracking, and exits 0 before your floating promise rejects. I lost about 3 hours to this on a Pinterest poster.
Run this first. It turns every silent rejection into a loud, non-zero exit:
// guard.js — require this at the very top of your entrypoint
process.on('unhandledRejection', (reason) => {
console.error('UNHANDLED REJECTION — this is why nothing happened:');
console.error(reason);
process.exitCode = 1;
});
process.on('beforeExit', (code) => {
if (code === 0) {
console.warn('Process is exiting 0. If you expected network I/O, you probably');
console.warn('forgot an await or never kept the event loop busy.');
}
});
node -r ./guard.js ./poster.js
With -r ./guard.js you don't even edit the main file. On my machine this immediately surfaced a await client.images.generate(...) that I'd written as client.images.generate(...) — the call was firing, but the script exited before the 8-second image job returned.
2. Proving your .env is loaded before the OpenAI client is constructed
dotenv does nothing if you import your API client first. ES module imports are hoisted and evaluated top-to-bottom before any of your top-level code, so this fails silently:
import OpenAI from 'openai';
import 'dotenv/config'; // too late — OpenAI already read process.env
const client = new OpenAI(); // apiKey is undefined
The fix is ordering plus a hard assertion. Never trust that the key is present — assert it and print a fingerprint, not the secret:
import 'dotenv/config'; // FIRST line, before any client import
import OpenAI from 'openai';
function requireEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing ${name}. Check .env path and quotes.`);
// log a safe fingerprint so you know WHICH key is loaded
console.log(`${name} loaded: ${v.slice(0, 6)}…${v.slice(-4)} (len ${v.length})`);
return v;
}
const apiKey = requireEnv('OPENAI_API_KEY');
const client = new OpenAI({ apiKey });
Two real failures this caught for me: a trailing space inside OPENAI_API_KEY="sk-... " (the quotes were literal and dotenv kept them), and a .env sitting one directory above where node was launched. The len print is gold — a real OpenAI key is ~164 chars for project keys; if you see len 0 or len 51, you've got the wrong value.
3. The require is not defined in ES module scope crash (package.json type field)
If your repo's package.json has "type": "module", every .js file is ESM and require/module.exports/__dirname throw. Half the AI automation snippets on the internet are CommonJS, so you paste one in and it explodes. Decide deliberately:
# Check what mode you're in
node -e "console.log(require('./package.json').type || 'commonjs')"
If you need __dirname under ESM (common for resolving a prompt template next to your script), reconstruct it:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync } from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const prompt = readFileSync(join(__dirname, 'system-prompt.txt'), 'utf8');
console.log(`Loaded prompt (${prompt.length} chars)`);
Mixing modes is the other trap: a .cjs file can't import an ESM-only package like node-fetch@3. That single incompatibility is why node-fetch@3 breaks so many older posters — pin to node-fetch@2 if you're stuck on CommonJS, or migrate the whole file to ESM.
4. Native fetch vs node-fetch: which Node version actually has global fetch
Global fetch landed unflagged in Node 18 and is stable in Node 20+. If your automation tool's CI uses Node 16 (still the default on some older GitHub Actions runners), fetch is not defined is your error. Check and branch:
node -e "console.log(process.version, typeof fetch)"
# v20.11.0 function <- good
# v16.20.2 undefined <- you need node-fetch or an upgrade
In GitHub Actions, pin the version explicitly so local and CI match — a mismatch here is why "works on my machine" tools die in cron:
# .github/workflows/post.yml
jobs:
post:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20' # not 'lts/*' — be exact
- run: npm ci
- run: node -r ./guard.js ./poster.js
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
5. Puppeteer can't find Chrome: the PUPPETEER_EXECUTABLE_PATH miss
Puppeteer-based posters (Pinterest, note, anything without an API) fail on a fresh machine because the bundled Chromium didn't download, or you're on puppeteer-core which downloads nothing. The error is a vague Could not find expected browser. Verify the path exists before you launch:
import { existsSync } from 'node:fs';
import puppeteer from 'puppeteer';
const exe = process.env.PUPPETEER_EXECUTABLE_PATH;
if (exe && !existsSync(exe)) {
throw new Error(`PUPPETEER_EXECUTABLE_PATH set but file missing: ${exe}`);
}
const browser = await puppeteer.launch({
headless: 'new',
executablePath: exe || undefined, // undefined -> use bundled Chromium
args: ['--no-sandbox', '--disable-dev-shm-usage'], // required in most CI containers
});
console.log('Browser launched:', (await browser.version()));
await browser.close();
The --disable-dev-shm-usage flag is not optional in Docker/Actions: the default /dev/shm is 64 MB and Chrome will crash mid-screenshot with a confusing Target closed error. I chased that for an evening before adding the flag fixed it on the first retry.
6. The 429 you misread as a config bug: rate limits look like silence
When an OpenAI call hits a 429 and your code doesn't log the response body, it looks identical to "nothing happened." Always log status and a slice of the body, and back off:
async function callWithRetry(fn, max = 4) {
for (let attempt = 1; attempt <= max; attempt++) {
try {
return await fn();
} catch (err) {
const status = err?.status ?? err?.response?.status;
if (status === 429 || status >= 500) {
const waitMs = Math.min(1000 * 2 ** attempt, 16000);
console.warn(`Attempt ${attempt} got ${status}. Waiting ${waitMs}ms.`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
throw err; // 401/400 are config bugs — don't retry, fail loud
}
}
throw new Error(`Failed after ${max} attempts`);
}
Key distinction: 429 and 5xx are worth retrying; 401 (bad key) and 400 (bad request body) are env/config bugs and retrying just hides them. My nightly job went from "randomly empty" to 100% completion once I separated these two classes.
7. TLS interception and HTTP_PROXY: the SELF_SIGNED_CERT error on home Wi-Fi and corp VPN
If fetch or the OpenAI SDK throws SELF_SIGNED_CERT_IN_CHAIN or UNABLE_TO_VERIFY_LEAF_SIGNATURE, a proxy is rewriting TLS. The wrong fix is NODE_TLS_REJECT_UNAUTHORIZED=0 (it disables all cert checking, globally). The right fix is to point Node at the corporate CA bundle:
# Correct: trust the specific CA, keep verification on
export NODE_EXTRA_CA_CERTS=/path/to/corp-root-ca.pem
node ./poster.js
Also confirm your proxy vars are consistent — a set HTTP_PROXY with an unset HTTPS_PROXY sends HTTPS traffic direct and it hangs:
node -e "['HTTP_PROXY','HTTPS_PROXY','NO_PROXY','NODE_EXTRA_CA_CERTS'].forEach(k => console.log(k, '=', process.env[k] || '(unset)'))"
8. Cron runs with a different PATH and CWD than your terminal
The nastiest "works manually, fails at 7am" bug: cron does not source your .bashrc, so node may not be on PATH, and the working directory is $HOME, not your project — so relative .env and template paths break. Make the job environment-independent:
# Bad crontab line — relies on login shell
0 7 * * * node poster.js
# Good — absolute node, absolute cwd, log everything
0 7 * * * cd /home/me/auto-money && /usr/bin/node -r ./guard.js ./poster.js >> /home/me/auto-money/cron.log 2>&1
The 2>&1 is what saved me — without it, cron emails stderr into the void and you never see the stack trace. After redirecting to cron.log, the actual error (a missing .env because CWD was $HOME) was visible in 30 seconds.
9. Verifying the model name exists before you burn a 7am run
A mistyped or deprecated model string (gpt-4o-mini typed as gpt4o-mini, or a retired snapshot) returns a 404 that reads like an outage. List models at startup and assert yours is present, so the job fails fast at second 1 instead of after a long pipeline:
const WANT = process.env.OPENAI_MODEL || 'gpt-4o-mini';
const { data } = await client.models.list();
const ids = data.map(m => m.id);
if (!ids.includes(WANT)) {
throw new Error(`Model '${WANT}' not available to this key. ` +
`Closest matches: ${ids.filter(i => i.startsWith('gpt-4o')).join(', ')}`);
}
console.log(`Model '${WANT}' confirmed available.`);
This also catches the case where your key belongs to a project/org that simply isn't granted the model — the error message tells you exactly what you can call.
10. JSON output that isn't JSON: parsing the model reply safely
Automation tools that feed model output into a poster die on the day the model wraps its JSON in
```json
fences or adds a sentence of preamble. Don't JSON.parse raw; strip and validate, and use response_format when the model supports it:
const res = await client.chat.completions.create({
model: WANT,
response_format: { type: 'json_object' }, // forces valid JSON on supported models
messages: [{ role: 'user', content: 'Return {"title":"...","tags":[...]}' }],
});
let raw = res.choices[0].message.content.trim();
raw = raw.replace(/^```
{% endraw %}
(?:json)?/i, '').replace(/
{% raw %}
```$/, '').trim(); // belt and suspenders
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('Model returned non-JSON. First 200 chars:\n' + raw.slice(0, 200));
}
if (!parsed.title) throw new Error('JSON missing required field: title');
console.log('Parsed OK:', parsed.title);
Printing the first 200 chars on failure is the difference between "my bot is broken" and "oh, the model prepended a markdown fence again."
What actually moved the needle
Of these ten, three fixed 80% of my real outages: the unhandledRejection guard (#1), dotenv ordering with a fingerprint assert (#2), and absolute-path cron with 2>&1 logging (#8). If you only have ten minutes, do those three. Run node -r ./guard.js against your tool right now, watch what it prints at beforeExit, and you'll usually find the silent failure in the first run. Pin your Node version in CI to match local, assert your env at startup instead of trusting it, and your 7am job stops being a mystery.
Top comments (0)