Skip to content

Fix race condition between fetch timeout and Express response timeout#25

Merged
DominicBM merged 5 commits into
mainfrom
fix-timeout-race
Apr 14, 2026
Merged

Fix race condition between fetch timeout and Express response timeout#25
DominicBM merged 5 commits into
mainfrom
fix-timeout-race

Conversation

@DominicBM
Copy link
Copy Markdown
Contributor

@DominicBM DominicBM commented Apr 13, 2026

Summary

  • FETCH_TIMEOUT and RESPONSE_TIMEOUT were both 10s, causing them to fire simultaneously when an upstream host is unreachable (e.g. a stale IP in item metadata). The AbortSignal would throw and sendError(404) would run at the same instant res.setTimeout sent a 504, producing ERR_HTTP_HEADERS_SENT errors and 504 responses visible to users.
  • Reduces FETCH_TIMEOUT from 10s to 5s so the 404 error path always completes well before the 10s Express response timeout fires.
  • Also replaces new Date().getTime() with Date.now() in getCacheHeaders.

Context

Currently producing ~1,700 504s/day from items whose object URLs point to a stale IP (67.111.179.146) that no longer responds — these hang for the full timeout before failing. This fix ensures they fail fast with a 404 rather than racing into a 504.

Test plan

  • All 90 existing unit tests pass
  • Verify in logs after deploy: no more ERR_HTTP_HEADERS_SENT errors; hung-upstream items return 404 within ~5s instead of 504 after 10s

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance & Infrastructure

    • Centralized timeouts: request 3s, fetch 5s, response 10s for consistent behavior.
    • Runtime validation prevents an invalid timeout configuration (fetch >= response).
    • More reliable current-time usage when building Expires header; Cache-Control behavior unchanged.
  • Bug Fixes

    • Safer timeout handling in response routes: no 504 sent after response has started or ended, and connections are properly closed when headers were already sent.

…eout

FETCH_TIMEOUT and RESPONSE_TIMEOUT were both 10s, causing them to fire
simultaneously when an upstream host was unreachable. The AbortSignal
would throw (triggering sendError 404) at the same moment res.setTimeout
sent a 504, resulting in ERR_HTTP_HEADERS_SENT errors in production.

Reduce FETCH_TIMEOUT to 5s so the 404 path always completes well before
the 10s response timeout fires.

Also replace new Date().getTime() with Date.now() in getCacheHeaders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44e8a1fa-6b8c-4905-832f-13bbd3580d62

📥 Commits

Reviewing files that changed from the base of the PR and between 282c5c3 and 8843b47.

📒 Files selected for processing (1)
  • src/timeoutConfig.ts

Walkthrough

Replaced hardcoded timeout values with a new shared src/timeoutConfig.ts; ResponseHelper now uses FETCH_TIMEOUT_MS and Date.now() for Expires; ExpressSetup uses REQUEST_TIMEOUT_MS/RESPONSE_TIMEOUT_MS and adds response-timeout guards in the /thumb/* handler.

Changes

Cohort / File(s) Summary
Timeout config
src/timeoutConfig.ts
Add REQUEST_TIMEOUT_MS = 3_000, RESPONSE_TIMEOUT_MS = 10_000, FETCH_TIMEOUT_MS = 5_000 and runtime validation that FETCH_TIMEOUT_MS < RESPONSE_TIMEOUT_MS.
Response helper
src/ResponseHelper.ts
Replace hardcoded fetch timeout with imported FETCH_TIMEOUT_MS; compute Expires header using Date.now() instead of new Date().getTime().
Express setup
src/ExpressSetup.ts
Replace inline REQUEST_TIMEOUT/RESPONSE_TIMEOUT with imported constants; set server.requestTimeout = REQUEST_TIMEOUT_MS; in /thumb/* use res.setTimeout(RESPONSE_TIMEOUT_MS, ...) and add guards: return if writableEnded, res.destroy() if headersSent, otherwise res.status(504).send("Gateway Timeout").

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Express
    participant TimeoutConfig
    participant ResponseHelper
    participant UpstreamAPI

    Client->>Express: GET /thumb/...
    Note right of Express: reads REQUEST_TIMEOUT_MS from TimeoutConfig
    Express->>ResponseHelper: fetch image (uses FETCH_TIMEOUT_MS)
    ResponseHelper->>UpstreamAPI: HTTP fetch (abortable)
    UpstreamAPI-->>ResponseHelper: image / error / timeout
    alt fetch succeeds before FETCH_TIMEOUT_MS
        ResponseHelper-->>Express: image + headers (Expires uses Date.now())
        Express-->>Client: 200 OK + body
    else fetch fails or FETCH_TIMEOUT_MS reached
        ResponseHelper-->>Express: error
        Express->>Express: res.setTimeout(RESPONSE_TIMEOUT_MS) handler checks writableEnded/headersSent
        alt response still writable
            Express-->>Client: 504 Gateway Timeout
        else headers already sent
            Express->>Client: connection destroyed
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: introducing timeout configuration constants to prevent a race condition between fetch timeout and Express response timeout.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-timeout-race

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/ResponseHelper.ts (1)

9-11: Decouple timeout coordination from comments-only coupling

Line 9 documents the dependency on Express RESPONSE_TIMEOUT, but it’s still enforced manually. If that timeout changes later, this race fix can silently regress. Consider sourcing both values from one shared config constant/module instead of duplicating the relationship in comments.

Suggested refactor
+// src/timeoutConfig.ts
+export const RESPONSE_TIMEOUT_MS = 10_000;
+export const FETCH_TIMEOUT_MS = 5_000; // keep safely below RESPONSE_TIMEOUT_MS
-  FETCH_TIMEOUT = 5 * 1000; // 5 seconds
+  FETCH_TIMEOUT = FETCH_TIMEOUT_MS;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ResponseHelper.ts` around lines 9 - 11, The FETCH_TIMEOUT constant in
ResponseHelper.ts is hardcoded and comments-only coupled to Express's
RESPONSE_TIMEOUT; create or use a shared configuration constant (e.g.,
RESPONSE_TIMEOUT in a central config module) and derive FETCH_TIMEOUT from it
instead of duplicating the value in comments. Update ResponseHelper to import
the shared RESPONSE_TIMEOUT and set FETCH_TIMEOUT relative to it (for example a
safe margin or percentage), and update ExpressSetup to also import the same
shared RESPONSE_TIMEOUT so both modules read the single source of truth; ensure
the identifiers ResponseHelper.FETCH_TIMEOUT and ExpressSetup.RESPONSE_TIMEOUT
(or the chosen shared name) are used consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/ResponseHelper.ts`:
- Around line 9-11: The FETCH_TIMEOUT constant in ResponseHelper.ts is hardcoded
and comments-only coupled to Express's RESPONSE_TIMEOUT; create or use a shared
configuration constant (e.g., RESPONSE_TIMEOUT in a central config module) and
derive FETCH_TIMEOUT from it instead of duplicating the value in comments.
Update ResponseHelper to import the shared RESPONSE_TIMEOUT and set
FETCH_TIMEOUT relative to it (for example a safe margin or percentage), and
update ExpressSetup to also import the same shared RESPONSE_TIMEOUT so both
modules read the single source of truth; ensure the identifiers
ResponseHelper.FETCH_TIMEOUT and ExpressSetup.RESPONSE_TIMEOUT (or the chosen
shared name) are used consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ef456e5b-f561-4f91-9bab-659156b89c91

📥 Commits

Reviewing files that changed from the base of the PR and between d83e479 and b6b6c8f.

📒 Files selected for processing (1)
  • src/ResponseHelper.ts

Addresses CodeRabbit nitpick: FETCH_TIMEOUT and RESPONSE_TIMEOUT were
only loosely coupled via comments, so a future change to one could
silently reintroduce the race condition. Both values now live in
timeoutConfig.ts as the single source of truth, with the constraint
documented there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

✅ Actions performed

Full review triggered.

@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

✅ Actions performed

Full review triggered.

@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

✅ Actions performed

Full review triggered.

@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/timeoutConfig.ts (1)

7-11: Enforce timeout ordering as a runtime invariant

The safety condition is currently comment-only. Add a startup guard so future config edits can’t silently reintroduce the race.

Proposed diff
 export const FETCH_TIMEOUT_MS = 5_000;
+
+if (FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS) {
+  throw new Error(
+    "FETCH_TIMEOUT_MS must be strictly less than RESPONSE_TIMEOUT_MS",
+  );
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/timeoutConfig.ts` around lines 7 - 11, Add a runtime assertion at module
initialization to enforce the safety ordering between FETCH_TIMEOUT_MS and
RESPONSE_TIMEOUT_MS: verify that FETCH_TIMEOUT_MS is "well under"
RESPONSE_TIMEOUT_MS (e.g., FETCH_TIMEOUT_MS < RESPONSE_TIMEOUT_MS - some margin
or just FETCH_TIMEOUT_MS < RESPONSE_TIMEOUT_MS) and if the check fails, log an
explicit error and terminate startup (throw an Error or call process.exit(1)).
Place this guard in the same module near the
FETCH_TIMEOUT_MS/RESPONSE_TIMEOUT_MS definitions so the check runs on import and
prevents silent regressions; reference the constants FETCH_TIMEOUT_MS and
RESPONSE_TIMEOUT_MS in the check and error message.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ExpressSetup.ts`:
- Around line 88-91: The timeout handler registered on res via
res.setTimeout(RESPONSE_TIMEOUT_MS, ...) must guard against sending headers/body
after the main handler already responded; modify the callback in ExpressSetup.ts
to check res.headersSent (or res.writableEnded) and only call res.status(504)
and res.send("Gateway Timeout") when the response has not already been sent — if
headers are sent, simply return without attempting to write the response. Ensure
you update the callback associated with RESPONSE_TIMEOUT_MS accordingly.

---

Nitpick comments:
In `@src/timeoutConfig.ts`:
- Around line 7-11: Add a runtime assertion at module initialization to enforce
the safety ordering between FETCH_TIMEOUT_MS and RESPONSE_TIMEOUT_MS: verify
that FETCH_TIMEOUT_MS is "well under" RESPONSE_TIMEOUT_MS (e.g.,
FETCH_TIMEOUT_MS < RESPONSE_TIMEOUT_MS - some margin or just FETCH_TIMEOUT_MS <
RESPONSE_TIMEOUT_MS) and if the check fails, log an explicit error and terminate
startup (throw an Error or call process.exit(1)). Place this guard in the same
module near the FETCH_TIMEOUT_MS/RESPONSE_TIMEOUT_MS definitions so the check
runs on import and prevents silent regressions; reference the constants
FETCH_TIMEOUT_MS and RESPONSE_TIMEOUT_MS in the check and error message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: db604e62-6ee2-4cee-aa41-eedc5fcc440d

📥 Commits

Reviewing files that changed from the base of the PR and between d83e479 and 593b553.

📒 Files selected for processing (3)
  • src/ExpressSetup.ts
  • src/ResponseHelper.ts
  • src/timeoutConfig.ts

Comment thread src/ExpressSetup.ts
Check res.headersSent and res.writableEnded before sending 504 in
the res.setTimeout callback to prevent double-write errors when the
main handler completes before the timeout fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/timeoutConfig.ts (1)

7-11: Enforce the timeout invariant in code.

Lines 8-10 describe the key safety guarantee, but nothing fails fast if a later edit makes FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS. A small module-level assertion would keep this race from coming back silently.

Possible guard
 export const FETCH_TIMEOUT_MS = 5_000;
+
+if (FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS) {
+  throw new Error("FETCH_TIMEOUT_MS must be less than RESPONSE_TIMEOUT_MS");
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/timeoutConfig.ts` around lines 7 - 11, Add a module-level guard that
enforces the invariant FETCH_TIMEOUT_MS < RESPONSE_TIMEOUT_MS by checking these
constants at import time and throwing a clear Error if the condition is
violated; place the check immediately after the constants (e.g., referencing
FETCH_TIMEOUT_MS and RESPONSE_TIMEOUT_MS) and include both values in the error
message so any future change that makes FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS
fails fast during startup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ExpressSetup.ts`:
- Around line 88-92: The timeout handler registered in
res.setTimeout(RESPONSE_TIMEOUT_MS, ...) currently returns when res.headersSent
is true, leaving stalled connections open; update the handler so that if headers
have been sent but the response is not writableEnded, you explicitly destroy the
underlying socket (e.g., call res.socket.destroy() or res.destroy() if
available) before returning, while keeping the existing writableEnded guard and
still sending the 504 when headers have not been sent; modify the callback in
res.setTimeout to perform this socket destruction for stalled-stream cases.

---

Nitpick comments:
In `@src/timeoutConfig.ts`:
- Around line 7-11: Add a module-level guard that enforces the invariant
FETCH_TIMEOUT_MS < RESPONSE_TIMEOUT_MS by checking these constants at import
time and throwing a clear Error if the condition is violated; place the check
immediately after the constants (e.g., referencing FETCH_TIMEOUT_MS and
RESPONSE_TIMEOUT_MS) and include both values in the error message so any future
change that makes FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS fails fast during
startup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 29378d5b-763f-4797-afa6-c550507f3b1b

📥 Commits

Reviewing files that changed from the base of the PR and between d83e479 and dcd8baa.

📒 Files selected for processing (3)
  • src/ExpressSetup.ts
  • src/ResponseHelper.ts
  • src/timeoutConfig.ts

Comment thread src/ExpressSetup.ts
When headersSent is true but writableEnded is false (response started
but body stream stalled), returning from the timeout callback leaves
the socket open indefinitely. Explicitly call res.destroy() to close
the connection in that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DominicBM
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/timeoutConfig.ts (1)

8-11: Enforce the fetch-vs-response timeout invariant in code, not only comments.

The critical race-prevention rule is documented here but currently not guarded. Add a startup-time assertion so invalid future edits fail fast.

Proposed diff
 export const RESPONSE_TIMEOUT_MS = 10_000;
@@
 export const FETCH_TIMEOUT_MS = 5_000;
+
+if (FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS) {
+  throw new Error(
+    "Invalid timeout config: FETCH_TIMEOUT_MS must be less than RESPONSE_TIMEOUT_MS",
+  );
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/timeoutConfig.ts` around lines 8 - 11, Add a startup-time assertion that
enforces FETCH_TIMEOUT_MS is strictly less than RESPONSE_TIMEOUT_MS so future
edits fail-fast; locate FETCH_TIMEOUT_MS in timeoutConfig.ts and import or
reference RESPONSE_TIMEOUT_MS (wherever it’s defined) and throw an Error (or use
assert) during module initialization if FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS,
so the process will crash on invalid configuration rather than relying on the
comment alone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/timeoutConfig.ts`:
- Around line 8-11: Add a startup-time assertion that enforces FETCH_TIMEOUT_MS
is strictly less than RESPONSE_TIMEOUT_MS so future edits fail-fast; locate
FETCH_TIMEOUT_MS in timeoutConfig.ts and import or reference RESPONSE_TIMEOUT_MS
(wherever it’s defined) and throw an Error (or use assert) during module
initialization if FETCH_TIMEOUT_MS >= RESPONSE_TIMEOUT_MS, so the process will
crash on invalid configuration rather than relying on the comment alone.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0bf5f83e-cf0a-4854-95ed-b00cc4f38673

📥 Commits

Reviewing files that changed from the base of the PR and between d83e479 and 282c5c3.

📒 Files selected for processing (3)
  • src/ExpressSetup.ts
  • src/ResponseHelper.ts
  • src/timeoutConfig.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DominicBM DominicBM merged commit e512d0a into main Apr 14, 2026
5 of 6 checks passed
@DominicBM DominicBM deleted the fix-timeout-race branch April 14, 2026 14:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant