Skip to content

fix: Crossref 429 resilience + salvage sweep + OpenAlex select fix (v1.3.1) - #7

Merged
tobiasosDev merged 1 commit into
mainfrom
fix-crossref-429-resilience
Jul 21, 2026
Merged

tobiasosDev merged 1 commit into
mainfrom
fix-crossref-429-resilience

Conversation

@tobiasosDev

@tobiasosDev tobiasosDev commented Jul 21, 2026 •

Copy link
Copy Markdown
Owner

Problem

A parallel batch of reference checks against Crossref's anonymous pool reliably hits 429 storms: 10 concurrent query.bibliographic searches get rate-limited, the fixed 300ms/1200ms retry backoff re-enters the same window, all attempts die, and the majority of a real bibliography lands as check_failed ("could not be checked"). Reproduced with a realistic 35-ref APA list: 14/35 check_failed, 58× HTTP 429.

Separately, every OpenAlex /works lookup silently failed with HTTP 400 because is_oa is not a valid top-level select field — no citation ever got citation-count / OA enrichment.

Fix

  • fetchRetry: honor Retry-After (delta-seconds or HTTP-date, capped 30s) on 429 and share the wait across all requests to the same host via a per-host cooldown map, with jitter so a released batch does not stampede back in lockstep. 5xx/network/timeout keep the existing backoff.
  • Salvage sweep (quickCheck + checkDocument): refs that still ended check_failed are re-checked once, sequentially, after the burst has drained, under a 60s wall-clock budget. Transient failures self-heal instead of surfacing to the user.
  • OpenAlex: select open_access (valid) and read open_access.is_oa.

Result (same 35-ref list, anonymous pool)

Before: 14 check_failed / 15 verified, OpenAlex 100% HTTP 400.
After: 0 check_failed / 25 verified / 9 partial / 1 not_found, OpenAlex enrichment working. 429s still occur but are absorbed.

Tests

10 new tests (http-retry, salvage, openalex select); full suite 138 pass.


Summary by cubic

Improves Crossref rate-limit handling and restores OpenAlex enrichment, reducing false check_failed results during parallel checks. Honors server backoff and retries failed refs once to self-heal transient errors.

  • Bug Fixes
    • 429 resilience: fetchRetry honors Retry-After (seconds or HTTP-date), applies a shared per-host cooldown with jitter, and keeps existing backoff for 5xx/network errors.
    • Salvage sweep: after parallel checks, re-run check_failed refs once, sequentially, under a 60s budget in quickCheck and checkDocument.
    • OpenAlex: select open_access and read open_access.is_oa (replaces invalid is_oa select) to restore citation-count and OA enrichment.

Written for commit a22f3be. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of rate limits by honoring server-provided retry timing and coordinating retries for requests to the same host.
    • Added a follow-up check for references affected by temporary network failures.
    • Fixed OpenAlex enrichment errors by using the correct open-access response field.
  • Tests
    • Added coverage for rate-limit retries, OpenAlex lookups, and recovery from temporary citation-check failures.

…1.3.1)

- fetchRetry honors Retry-After on 429 with a shared per-host cooldown
  (jittered), so parallel reference checks stop retrying into the same
  rate-limit window and dying as check_failed en masse.
- quickCheck/checkDocument re-run check_failed refs once, sequentially,
  after the burst drains (60s budget) — transient failures self-heal.
- OpenAlex /works select used invalid field is_oa (HTTP 400 on every
  lookup); select open_access and read open_access.is_oa.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Version 1.3.1 updates HTTP 429 retry coordination, adds transient citation salvage checks, corrects OpenAlex open-access enrichment, and adds tests covering retry, cooldown, salvage, and field-selection behavior.

Changes

Reliability and enrichment updates

Layer / File(s) Summary
Shared HTTP retry cooldowns
src/http.ts, test/http-retry.test.ts
fetchRetry honors capped Retry-After values, shares cooldowns per host, retains 5xx backoff behavior, and is covered by retry and exhaustion tests.
Transient citation salvage flow
src/quick-check.ts, src/document.ts, test/salvage.test.ts
Failed citation checks receive sequential rechecks within a 60-second deadline in both quickCheck and checkDocument, with recovery and persistent-failure coverage.
OpenAlex enrichment and release metadata
src/openalex.ts, src/quick-check.ts, src/references/match.ts, test/openalex.test.ts, package.json, CHANGELOG.md
OpenAlex requests and mappings use open_access.is_oa, with fallback support, field-selection tests, and release metadata for version 1.3.1.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

HTTP retry cooldown flow

sequenceDiagram
  participant fetchRetry
  participant HostCooldowns
  participant HTTPServer
  fetchRetry->>HostCooldowns: await shared host cooldown
  fetchRetry->>HTTPServer: send request
  HTTPServer-->>fetchRetry: return 429 and Retry-After
  fetchRetry->>HostCooldowns: note host cooldown
  fetchRetry->>HTTPServer: retry after shared wait
Loading

Citation salvage flow

sequenceDiagram
  participant quickCheck
  participant checkDocument
  participant salvageCheckFailed
  participant Crossref
  quickCheck->>salvageCheckFailed: pass failed citation results
  checkDocument->>salvageCheckFailed: pass failed document citations
  salvageCheckFailed->>Crossref: sequentially recheck failed references
  Crossref-->>salvageCheckFailed: return recovered or failed result
  salvageCheckFailed-->>quickCheck: update results in place
  salvageCheckFailed-->>checkDocument: update results in place
Loading

Possibly related PRs

  • tobiasosDev/citecheck#1: Introduces the document ingestion and reference-checking pipeline extended by the salvage sweep.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: Crossref 429 handling, salvage retries, and the OpenAlex select fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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-crossref-429-resilience

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/quick-check.ts`:
- Around line 217-234: Update salvageCheckFailed so each recheck invocation is
isolated from failures: catch exceptions from recheck(inputs[i]!) and continue
processing the remaining failed indices, while preserving successfully returned
retry results in results[i]. Ensure a thrown retry does not propagate to
quickCheck, checkDocument, or abort earlier salvaged results; add coverage for a
mid-sweep recheck rejection if the existing tests support it.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b3cf7369-3c22-4112-bb49-61c5e72ef105

📥 Commits

Reviewing files that changed from the base of the PR and between 6d1bdd7 and a22f3be.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • package.json
  • src/document.ts
  • src/http.ts
  • src/openalex.ts
  • src/quick-check.ts
  • src/references/match.ts
  • test/http-retry.test.ts
  • test/openalex.test.ts
  • test/salvage.test.ts

Comment thread src/quick-check.ts
Comment on lines +217 to +234
export async function salvageCheckFailed<T>(
results: CitationCheckResult[],
inputs: T[],
recheck: (input: T) => Promise<CitationCheckResult>,
): Promise<void> {
const failed = results
.map((r, i) => (r.status === "check_failed" ? i : -1))
.filter((i) => i >= 0);
if (failed.length === 0) return;
// Let any active rate-limit window pass before the first re-attempt.
await new Promise((r) => setTimeout(r, 1000));
const deadline = Date.now() + 60_000;
for (const i of failed) {
if (Date.now() > deadline) return;
const retry = await recheck(inputs[i]!);
if (retry.status !== "check_failed") results[i] = retry;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

salvageCheckFailed will crash the whole sweep if a single recheck call throws.

The loop's await recheck(inputs[i]!) isn't guarded. If any recheck (e.g. Crossref succeeds but a subsequent DOAJ/ISSN lookup in checkSingle/checkFreeTextRef throws) raises an unhandled exception, it propagates out of salvageCheckFailed and up through quickCheck/checkDocument, aborting the entire operation — including references already salvaged earlier in the same loop. This undermines the sweep's whole purpose: turning a resilience feature into a single point of failure. The same risk applies to the document.ts call site since it shares this function.

🛡️ Proposed fix: don't let one bad retry abort the sweep
   for (const i of failed) {
     if (Date.now() > deadline) return;
-    const retry = await recheck(inputs[i]!);
-    if (retry.status !== "check_failed") results[i] = retry;
+    try {
+      const retry = await recheck(inputs[i]!);
+      if (retry.status !== "check_failed") results[i] = retry;
+    } catch {
+      // Leave the original check_failed result; a bad retry should not
+      // abort the remaining sweep or crash the caller.
+    }
   }

Consider also adding a test where recheck throws mid-sweep, to lock in this behavior.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function salvageCheckFailed<T>(
results: CitationCheckResult[],
inputs: T[],
recheck: (input: T) => Promise<CitationCheckResult>,
): Promise<void> {
const failed = results
.map((r, i) => (r.status === "check_failed" ? i : -1))
.filter((i) => i >= 0);
if (failed.length === 0) return;
// Let any active rate-limit window pass before the first re-attempt.
await new Promise((r) => setTimeout(r, 1000));
const deadline = Date.now() + 60_000;
for (const i of failed) {
if (Date.now() > deadline) return;
const retry = await recheck(inputs[i]!);
if (retry.status !== "check_failed") results[i] = retry;
}
}
export async function salvageCheckFailed<T>(
results: CitationCheckResult[],
inputs: T[],
recheck: (input: T) => Promise<CitationCheckResult>,
): Promise<void> {
const failed = results
.map((r, i) => (r.status === "check_failed" ? i : -1))
.filter((i) => i >= 0);
if (failed.length === 0) return;
// Let any active rate-limit window pass before the first re-attempt.
await new Promise((r) => setTimeout(r, 1000));
const deadline = Date.now() + 60_000;
for (const i of failed) {
if (Date.now() > deadline) return;
try {
const retry = await recheck(inputs[i]!);
if (retry.status !== "check_failed") results[i] = retry;
} catch {
// Leave the original check_failed result; a bad retry should not
// abort the remaining sweep or crash the caller.
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/quick-check.ts` around lines 217 - 234, Update salvageCheckFailed so each
recheck invocation is isolated from failures: catch exceptions from
recheck(inputs[i]!) and continue processing the remaining failed indices, while
preserving successfully returned retry results in results[i]. Ensure a thrown
retry does not propagate to quickCheck, checkDocument, or abort earlier salvaged
results; add coverage for a mid-sweep recheck rejection if the existing tests
support it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/http.ts">

<violation number="1" location="src/http.ts:118">
P2: Concurrent retries still stampede when the shared cooldown expires: jitter is applied once to the shared deadline, so all waiters wake at the same instant. Apply a per-request randomized delay after the shared gate (while rechecking any extended cooldown) so the released batch is actually spread out.</violation>
</file>

<file name="src/quick-check.ts">

<violation number="1" location="src/quick-check.ts:231">
P2: A recheck started just before `deadline` can still block the scan for over a minute because its retry/cooldown waits are not deadline-bound. Enforce the remaining budget through the recheck/HTTP cancellation path, or at least prevent the sweep from claiming a 60-second wall-clock cap.</violation>
</file>

<file name="src/references/match.ts">

<violation number="1" location="src/references/match.ts:258">
P3: Minor cleanup: `?? oa.is_oa` fallback is dead code since `lookupByDoi` never returns `is_oa` at the top level. Could be dropped to `isOa: oa.open_access?.is_oa` (keeping `??` only if there's intent to defend against a future shape change).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/http.ts
if (attempt < retries) {
if (res.status === 429) {
const ra = parseRetryAfterMs(res) ?? 300 * (attempt + 1) ** 2;
noteCooldown(host, ra + Math.random() * 300);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Concurrent retries still stampede when the shared cooldown expires: jitter is applied once to the shared deadline, so all waiters wake at the same instant. Apply a per-request randomized delay after the shared gate (while rechecking any extended cooldown) so the released batch is actually spread out.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/http.ts, line 118:

<comment>Concurrent retries still stampede when the shared cooldown expires: jitter is applied once to the shared deadline, so all waiters wake at the same instant. Apply a per-request randomized delay after the shared gate (while rechecking any extended cooldown) so the released batch is actually spread out.</comment>

<file context>
@@ -46,22 +46,78 @@ export interface FetchOpts {
-        if (attempt < retries) {
+        if (res.status === 429) {
+          const ra = parseRetryAfterMs(res) ?? 300 * (attempt + 1) ** 2;
+          noteCooldown(host, ra + Math.random() * 300);
+          if (attempt < retries) continue; // top of loop awaits the cooldown
+        } else if (attempt < retries) {
</file context>

Comment thread src/quick-check.ts
const deadline = Date.now() + 60_000;
for (const i of failed) {
if (Date.now() > deadline) return;
const retry = await recheck(inputs[i]!);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A recheck started just before deadline can still block the scan for over a minute because its retry/cooldown waits are not deadline-bound. Enforce the remaining budget through the recheck/HTTP cancellation path, or at least prevent the sweep from claiming a 60-second wall-clock cap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/quick-check.ts, line 231:

<comment>A recheck started just before `deadline` can still block the scan for over a minute because its retry/cooldown waits are not deadline-bound. Enforce the remaining budget through the recheck/HTTP cancellation path, or at least prevent the sweep from claiming a 60-second wall-clock cap.</comment>

<file context>
@@ -207,6 +207,32 @@ async function checkSingle(item: CslItemData): Promise<CitationCheckResult> {
+  const deadline = Date.now() + 60_000;
+  for (const i of failed) {
+    if (Date.now() > deadline) return;
+    const retry = await recheck(inputs[i]!);
+    if (retry.status !== "check_failed") results[i] = retry;
+  }
</file context>

Comment thread src/references/match.ts
result.openalexMatch = {
citedByCount: oa.cited_by_count,
isOa: oa.is_oa,
isOa: oa.open_access?.is_oa ?? oa.is_oa,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Minor cleanup: ?? oa.is_oa fallback is dead code since lookupByDoi never returns is_oa at the top level. Could be dropped to isOa: oa.open_access?.is_oa (keeping ?? only if there's intent to defend against a future shape change).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/references/match.ts, line 258:

<comment>Minor cleanup: `?? oa.is_oa` fallback is dead code since `lookupByDoi` never returns `is_oa` at the top level. Could be dropped to `isOa: oa.open_access?.is_oa` (keeping `??` only if there's intent to defend against a future shape change).</comment>

<file context>
@@ -255,7 +255,7 @@ export async function checkFreeTextRef(raw: string): Promise<CitationCheckResult
       result.openalexMatch = {
         citedByCount: oa.cited_by_count,
-        isOa: oa.is_oa,
+        isOa: oa.open_access?.is_oa ?? oa.is_oa,
         journalName: oa.primary_location?.source?.display_name,
       };
</file context>

@tobiasosDev
tobiasosDev merged commit 70795e0 into main Jul 21, 2026
3 checks passed
@tobiasosDev
tobiasosDev deleted the fix-crossref-429-resilience branch July 21, 2026 16:00
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