Skip to content

fix(resolve-did): batch author/contributor resolution to stop 429s - #127

Merged
holkexyz merged 2 commits into
feat/positioning-redesignfrom
fix/resolve-did-batch-429
Jun 2, 2026
Merged

fix(resolve-did): batch author/contributor resolution to stop 429s#127
holkexyz merged 2 commits into
feat/positioning-redesignfrom
fix/resolve-did-batch-429

Conversation

@hb-agent

@hb-agent hb-agent commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Symptom

On /explore and the activity (cert) detail page, author bylines and the contributor list sometimes fail to load; the console shows Failed to load resource: the server responded with a status of 429.

Root cause

/api/resolve-did is rate-limited to 60 requests/min per IP (and per session DID). The byline + contributor components each resolve one DID per row on mount, with no HTTP-level batching, so a page with many distinct authors plus a contributor-heavy activity blows the window. Two amplifiers:

  1. No denormalized fallback. Unlike account rows (which prefer the indexer's inline displayName and only fall back to a lookup), the raw ActivityRecord carries only the author DID, so the byline always resolves over the network. The home feed already avoids this by denormalizing inline.
  2. No recovery. On error the hooks did cache.delete(did) and rethrew, so a 429'd avatar settled to a stuck skeleton and never recovered on that view.
  3. Shared "anon" bucket. Signed-out users all share one "anon" DID bucket at 60/min — a global cap that compounds the bursts.

Fix

Collapse N requests into one, mirroring the home feed's inline denormalization.

  • POST /api/resolve-dids — resolves up to 50 identities (DID or handle) in one request with bounded server concurrency (8). The per-DID resolution was extracted verbatim into resolve-core.ts and is shared with the GET route, so certs/bsky precedence and the indexer fast-path are unchanged (GET stays byte-for-byte; its 16 tests pass untouched).
  • Client coalescer (resolve-did-batch.ts) — DataLoader-style. Loads in a render pass batch into one POST; dedup + bounded cache. On a 429 it degrades to a DID fallback and negative-caches with a TTL (no throw, no retry storm, self-heals on a later view). Honors a 429 cooldown across flushes.
  • Rewire useAuthorInfo / useContributorInfo / useAuthorNamesMap onto the coalescer. Public APIs unchanged.
  • Identity-weighted rate limit on the batch route: charges identities.length against a 600-identity/min budget (per IP + DID), so the budget bounds upstream fan-out rather than request count. Backward-compatible cost param on the shared limiter (cost-1 path unchanged for every existing route). This also gives anonymous users 10x more headroom than the GET path they replace.

A page needing K authors now issues ceil(K/50) requests, not K.

Preserved

  • Brand / lexicon: app.certified.*, org.hypercerts.*, "Certified" strings moved verbatim (net zero change, verified against the diff).
  • GET /api/resolve-did behavior, including own-DID cache-control and the edit-time cache bust.

Review

Two-reviewer implementation pass; decisions logged in docs/resolve-did-batch/review-round-1.md (accepted: weighted limit, cooldown-across-flushes, timer-leak fix; declined with rationale: names-map self-heal, dead error plumbing, concurrent chunking).

Verification

  • tsc --noEmit clean
  • eslint no new errors/warnings on changed files
  • vitest 536/536 (519 existing + 17 new); existing resolve-did + all rate-limit route suites green
  • next build compiles; /api/resolve-dids registered
  • Manual: load /explore and a contributor-heavy activity, confirm bylines/contributors populate without 429s, and that a forced 429 degrades to DID fallbacks rather than stuck skeletons

🤖 Generated with Claude Code

holkexyz and others added 2 commits June 2, 2026 14:50
Author bylines (/explore) and the activity-detail contributor list each
resolved one DID per row via GET /api/resolve-did on mount. That route is
capped at 60 requests/min per IP, so a page with many distinct authors
plus a contributor-heavy activity blew the window and returned 429 — the
"author/contributors not loaded" + "Failed to load resource: 429" report.
Two amplifiers: the byline has no denormalized fallback (always resolves
over the network), and the hooks deleted-but-never-recovered failed
entries, so a 429'd avatar stayed broken until reload.

Collapse N requests into one, mirroring how the home feed already avoids
N lookups by denormalizing inline:

- Extract the per-DID resolution out of the GET route into a shared
  resolve-core.ts (GET behaviour byte-identical; its 16 tests still pass).
- Add POST /api/resolve-dids: resolves up to 50 identities (DID or handle)
  in one request with bounded server concurrency — one rate-limit hit per
  page instead of per row.
- Add a DataLoader-style client coalescer (resolve-did-batch.ts): loads in
  a render pass batch into one POST; dedup + bounded cache; on 429 it
  degrades to a DID fallback and negative-caches with a TTL (no throw, no
  retry storm, self-heals on the next view).
- Rewire useAuthorInfo / useContributorInfo / useAuthorNamesMap onto the
  coalescer; public APIs unchanged.

A page needing K authors now issues ceil(K/50) requests, not K.

Tests: coalescer (batch/dedup/429-degrade/TTL-requery/chunking) + batch
route (keying/handle-resolution/cap/dedup/limiter). 533/533 pass; tsc
clean; build compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round-1 implementation review (docs/resolve-did-batch/review-round-1.md):

- Weight the batch route's rate limiter by identity count (cost =
  identities.length) against a 600-identity/min budget, instead of a flat
  60-request budget that let one IP drive ~9k upstream fetches/min through
  the 50x fan-out. Adds a backward-compatible `cost` param to
  checkHttpRateLimit (incrby only when cost>1; TTL gate generalised to
  count===cost); every existing cost-1 caller is byte-identical. Side
  benefit: lifts anonymous users off the GET route's shared "anon" 60/min
  bucket that itself contributed to the 429s.
- Coalescer: honor the 429 cooldown inside flush() (a flush armed before a
  concurrent chunk's 429 no longer fires early), and track negative-eviction
  timers in a Map so they're replaced on reschedule and cleared on reset
  (no timer leak / no fake-timer bleed across tests).
- Tests: cooldown-defers-next-batch, mid-flush re-entrancy, incrby-weighted
  429; batch mocks updated for incrby.

Declined (rationale in review doc): useAuthorNamesMap self-heal (would cause
a per-render setTick loop), dead error plumbing in useAuthorInfo (harmless
defensive code in the public type), concurrent chunk flush (sequential is
intentionally gentle on the limiter).

536/536 tests; tsc clean; build compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
certified-app Ready Ready Preview, Comment Jun 2, 2026 3:02pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05c2482e-409e-4878-a78d-c96863b00535

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/resolve-did-batch-429

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.

@holkexyz
holkexyz marked this pull request as ready for review June 2, 2026 15:06

@claude claude 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.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@holkexyz
holkexyz merged commit 91cfb03 into feat/positioning-redesign Jun 2, 2026
3 checks passed
@holkexyz
holkexyz deleted the fix/resolve-did-batch-429 branch June 2, 2026 15:06
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.

2 participants