Rebounder Tech Blog

Written by the people who actually run these systems in production.

A Non-Numeric env Becomes NaN and Slips Past ??

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

raw ? Number.parseInt(raw) : undefined only checks truthiness, and the NaN a non-numeric env produces passes straight through a later ?? default, which rejects nullish values only.

The short version

raw ? Number.parseInt(raw) : undefined only checks truthiness, and the NaN a non-numeric env produces passes straight through a later ?? default, which rejects nullish values only. Two jobs used this pattern. One failed silently, producing not a single embedding; in the other, setTimeout(abort, NaN) fired at effectively 0 ms and every fetch was aborted the instant it started.

What it looks like

Two independent bugs surfaced at the same time in a batch job environment.

The first was the embedding job. Batch size is controlled by an env var, EMBED_BATCH_SIZE, and when that value is non-numeric the chunking loop never runs a single iteration, so zero embeddings are produced. No error. The job looks like it finished normally, having processed nothing.

The second was a job fetching railway delay information. Fetch timeout is controlled by RAILWAY_FETCH_TIMEOUT_MS, and when that value is non-numeric the fetch is aborted immediately after starting. The data stays permanently stale and updates stop.

Neither is simply a case of the env value itself being wrong. The problem was how the code interpreted a non-numeric env var.

Why

The embedding job’s entry point was written like this.

const batchSizeRaw = env.EMBED_BATCH_SIZE;
const config: RunEmbeddingBatchConfig = {
  // ...
  batchSize: batchSizeRaw ? Number.parseInt(batchSizeRaw, 10) : undefined,
};

The railway job’s entry point had the same shape.

const timeoutRaw = env.RAILWAY_FETCH_TIMEOUT_MS;
const config: RunRailwayFetchConfig = {
  // ...
  timeoutMs: timeoutRaw ? Number.parseInt(timeoutRaw, 10) : undefined,
};

The ternary raw ? Number.parseInt(raw) : undefined looks like a safe way to say “fall back to the default when it is unset”. But what that ? inspects is raw’s truthiness, not whether the parse succeeded. A non-empty non-numeric string such as EMBED_BATCH_SIZE="abc" is truthy, so Number.parseInt("abc", 10) runs and its NaN is assigned to batchSize. The guard catches only “unset”; “set but invalid” walks past it.

That NaN was not stopped by the next layer either.

On the embedding side, embed-content.ts handles the batchSize it receives like this.

const batchSize = Math.max(1, Math.trunc(options.batchSize ?? 32));

When options.batchSize is NaN, NaN ?? 32 returns NaN. ?? yields the right-hand side only when the left is null or undefined, and NaN is neither, so it passes through. Math.trunc(NaN) and Math.max(1, NaN) are both NaN, and batchSize reaches the chunking loop as NaN. The loop condition is never satisfied, so it runs zero iterations and finishes with zero embeddings.

The railway job handles it in fetchMeitetsuStatus in run.ts.

const timeoutMs = config.timeoutMs ?? 10_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

For the same reason, config.timeoutMs reaches timeoutMs still as NaN. setTimeout(callback, NaN) runs essentially immediately per spec, so the AbortController fires abort() right after the call and every fetch is cancelled the moment it begins.

Both places share one structural defect. Make a single condition serve as both “reject the unset case” and “validate the value”, and a third state — non-numeric — passes through without hitting either guard.

Fixing it

We added a helper, optionalIntEnv, that validates the Number.parseInt result with Number.isFinite before returning it.

function optionalIntEnv(name: string): number | undefined {
  const raw = env[name];
  if (!raw) return undefined;
  const n = Number.parseInt(raw, 10);
  return Number.isFinite(n) && n > 0 ? n : undefined;
}

Call sites replace batchSizeRaw ? Number.parseInt(batchSizeRaw, 10) : undefined with optionalIntEnv("EMBED_BATCH_SIZE"), and a non-numeric value reliably becomes undefined, falling back to the default (32 / 10 seconds).

But fixing only the entry point does not close the paths where NaN reaches the embedding logic or run.ts directly — from tests or other callers. So the receiving side got Number.isFinite guards too.

// embed-content.ts
const rawBatch = Math.trunc(options.batchSize ?? 32);
const batchSize = Number.isFinite(rawBatch) ? Math.max(1, rawBatch) : 32;
// run.ts
const timeoutMs = Number.isFinite(config.timeoutMs) ? (config.timeoutMs as number) : 10_000;

Stopping it at the entry point is the real fix, but replacing ?? with Number.isFinite on the receiving side as well means that if either layer is later rewritten back into the same ternary, the other layer still stops the NaN.

Preventing a repeat

The fix commit added regression tests pinning that everything is still embedded when batchSize=NaN is passed, and that timeoutMs=NaN does not abort immediately. Other places may also treat a value read from env directly as a number, so the fact itself — ?? does not reject NaN — is left as a code comment, so whoever writes the next env-parsing routine does not reproduce the same ternary.

On defaults for environment variables, When the default bucket stays staging, destroy deletes production images covers a design mistake in the condition for falling back to a default. There the cause was the content of the default (which environment it pointed at); here the gap was in the test that decides whether to fall back — neither the truthy check nor nullish coalescing rejects NaN. Code that treats env vars as numbers is worth checking not just for the value but for which guard stops a non-numeric one.

よくある質問

Q1What is wrong with raw ? Number.parseInt(raw) : undefined?

If raw is empty or unset you get undefined. But a non-empty non-numeric string like "abc" passes the truthy check, Number.parseInt(raw) runs, and its NaN is returned as-is. The caller receives NaN as if it were a valid number.

Q2Why doesn't the ?? default stop NaN?

?? returns the right-hand side only when the left is null or undefined. NaN is neither, so nullish coalescing does not apply to it and an expression like config.timeoutMs ?? 10_000 passes NaN straight through.

Q3How was it fixed?

We added a helper, optionalIntEnv, that validates the Number.parseInt result with Number.isFinite before returning it. The call sites in embed-content.ts and run.ts also guard with Number.isFinite, so if a value slips past the default there is still a last line of defence.

確認した環境

  • TypeScript ^6.0.3 (apps/jobs)
  • As of the fix commit on 2026-06-16

この記事の根拠

  • TypeScriptファイル 84〜93行目コミット f0a7543
  • TypeScriptファイル 30〜65行目コミット f0a7543
  • TypeScriptファイル 31〜63行目コミット f0a7543
  • TypeScriptファイル 78〜86行目コミット f0a7543

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。