Skip to content

feat(pnpr): per-uplink proxy settings#12375

Merged
zkochan merged 3 commits into
mainfrom
feat/pnpr-per-uplink-settings
Jun 17, 2026
Merged

feat(pnpr): per-uplink proxy settings#12375
zkochan merged 3 commits into
mainfrom
feat/pnpr-per-uplink-settings

Conversation

@juanpicado

@juanpicado juanpicado commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

Adds verdaccio's per-uplink tuning knobs to pnpr's proxy uplinks, parsed from the same config.yaml shape verdaccio uses. Previously these fields were accepted and silently dropped; now each uplink can be tuned independently.

This is scoped to pnpr (the registry proxy) — no pnpm/pacquet CLI parity work is involved.

Part of the verdaccio-parity tracking issue #11973 — ticks the "Per-uplink timeouts, max_fails, fail_timeout, cache: false" item under Uplinks & caching.

Settings

Key Default Behavior
maxage falls back to global packument_ttl Per-uplink packument freshness window. When set, it overrides the global --packument-ttl-secs; when omitted, the global value still applies.
timeout 30s Per-request deadline applied to every upstream fetch (packument and tarball).
max_fails 2 Consecutive failures before the uplink's circuit breaker opens.
fail_timeout 5m Cooldown an open breaker stays open before a single probe is allowed through.
cache true When false, tarballs stream straight through to the client without being written to the local mirror.

Behavior notes

  • Circuit breaker — after max_fails consecutive failures, requests to the uplink short-circuit with 503 Service Unavailable (surfaced as UpstreamUnavailable) instead of hammering a known-down upstream. Once fail_timeout lapses, one probe is allowed through: success resets the breaker, failure restarts the cooldown. A 404/304/200 counts as success; transport errors and 5xx count as failures. On the packument path an open breaker degrades gracefully to the stale-cache fallback. max_fails: 0 disables the breaker.
  • Interval format — values accept verdaccio's syntax: suffixed (2m, 30s, 1h30m, 2m 30s), or a bare number read as seconds. An unparseable value is a named InvalidConfig error (naming the offending field) rather than a silent fallback to the default.
  • Defaults match verdaccio exactly (timeout 30s, max_fails 2, fail_timeout 5m, cache true).

Testing

  • Unit — interval parsing (suffixes/compound/bare/garbage), uplink resolution (defaults + explicit knobs + invalid-interval error), and the circuit breaker (opens after max_fails, resets on success, reopens after cooldown, disabled at 0).
  • Integrationmaxage overriding the global TTL (forces a revalidation that would otherwise be a cache hit), an open circuit hitting the upstream exactly once, and a cache: false uplink streaming a tarball without writing it to the mirror.
  • Full gate green: cargo fmt, clippy --deny warnings, rustdoc (-D warnings), dylint, and all 297 pnpr tests.

Squash commit message

feat(pnpr): per-uplink verdaccio settings (maxage, timeout, max_fails, fail_timeout, cache)

Add verdaccio's per-uplink tuning knobs to pnpr's proxy uplinks, parsed
from the same config.yaml shape verdaccio uses:

- maxage:       per-uplink packument freshness window; overrides the
                global packument_ttl when set, otherwise defers to it.
- timeout:      per-request deadline applied to every upstream fetch
                (default 30s).
- max_fails /   circuit breaker: after max_fails consecutive failures the
  fail_timeout  uplink short-circuits with a 503 until the fail_timeout
                cooldown lapses, then one probe per window is allowed
                through (defaults 2 / 5m). max_fails: 0 disables it.
- cache:        when false, tarballs stream through without being written
                to the local mirror (default true).

Interval values accept verdaccio's format ("2m", "30s", "1h30m", or a
bare number of seconds, as either a YAML string or a numeric scalar);
an unparsable or out-of-range value is a named InvalidConfig error
rather than a silent fallback or a panic. Defaults match verdaccio
(timeout 30s, max_fails 2, fail_timeout 5m, cache true).

Circuit breaker behavior:
- Only a 5xx or transport error counts as a failure; a non-404 4xx
  (auth / rate-limit / bad request) surfaces verbatim and never opens
  the circuit.
- The half-open probe is gated on the cooldown window, so exactly one
  probe runs per fail_timeout and a cancelled or dropped probe cannot
  stick the breaker open.
- An open breaker reports the configured uplink name, not its upstream
  URL, so the 503 does not disclose internal endpoints.

Part of the verdaccio-parity tracking issue pnpm/pnpm#11973.

Co-authored-by: Claude <noreply@anthropic.com>

Written by an agent (Claude Code, claude-opus-4-8).

Summary by CodeRabbit

Release Notes

  • New Features

    • Extended uplink configuration with timeout, failure threshold, and failure timeout controls
    • Per-uplink cache freshness TTL now overrides global packument settings
    • Uplink availability tracked with automatic circuit breaker for improved reliability
    • Tarballs now stream without local mirroring when uplink cache is disabled
  • Bug Fixes

    • Unavailable upstreams now return 503 Service Unavailable status
  • Tests

    • Added comprehensive test coverage for uplink configuration and circuit breaker behavior

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 13499a3f-8ae1-4ff8-8ff3-42269cfee6d1

📥 Commits

Reviewing files that changed from the base of the PR and between 2bb1fe7 and 77d6de2.

📒 Files selected for processing (7)
  • pnpr/crates/pnpr/src/config.rs
  • pnpr/crates/pnpr/src/config/tests.rs
  • pnpr/crates/pnpr/src/error.rs
  • pnpr/crates/pnpr/src/server.rs
  • pnpr/crates/pnpr/src/upstream.rs
  • pnpr/crates/pnpr/src/upstream/tests.rs
  • pnpr/crates/pnpr/tests/server.rs

📝 Walkthrough

Walkthrough

Adds Verdaccio-style uplink tuning fields (maxage, timeout, max_fails, fail_timeout, cache) to UplinkConfig and a parse_interval helper in resolve_uplink. Implements a per-uplink CircuitBreaker in Upstream, rewrites fetch_packument/fetch_tarball_response with breaker-aware logic, adds a RegistryError::UpstreamUnavailable variant (503), and wires non-caching tarball streaming and per-uplink packument TTL into the server.

Changes

Verdaccio Uplink Tuning Knobs and Circuit Breaker

Layer / File(s) Summary
UplinkConfig struct, UplinkFile YAML, and UpstreamUnavailable error
pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/src/error.rs
UplinkConfig gains maxage, timeout, max_fails, fail_timeout, cache, DEFAULT_* constants, and a with_defaults constructor; UplinkFile gains matching optional YAML fields; RegistryError::UpstreamUnavailable { uplink } is added and mapped to HTTP 503.
resolve_uplink, parse_interval, and Config::proxy wiring
pnpr/crates/pnpr/src/config.rs
resolve_uplink parses Verdaccio-style interval strings via parse_interval, validates each field, applies per-field defaults, and constructs the fully-populated UplinkConfig; Config::proxy switches to UplinkConfig::with_defaults.
CircuitBreaker type and Upstream struct refactor
pnpr/crates/pnpr/src/upstream.rs
Upstream stores timeout, maxage, cache, and an Arc<CircuitBreaker>; CircuitBreaker uses AtomicU32 for failure counting and Mutex<Option<Instant>> for cooldown gating with record_failure/record_success/available(); Upstream::new is rewritten to accept &UplinkConfig and exposes maxage() and caches().
fetch_packument/fetch_tarball_response rewrite and server wiring
pnpr/crates/pnpr/src/upstream.rs, pnpr/crates/pnpr/src/server.rs
Fetch methods gain breaker fail-fast, 404 success recording, conditional-only 304 acceptance, and transport-error breaker accounting; check_status replaced by Upstream::checked; server wires Upstream::new(uplink), a non-caching tarball streaming path when !upstream.caches(), and per-uplink maxage() TTL in load_packument_bytes.
Config and Upstream unit tests
pnpr/crates/pnpr/src/config/tests.rs, pnpr/crates/pnpr/src/upstream/tests.rs
config/tests adds parse_interval coverage and resolve_uplink default/explicit/invalid-field tests; upstream/tests introduces upstream() and breaking_upstream() helpers, refactors existing fetch tests, and adds CircuitBreaker state-transition unit tests and a breaker-trip async fetch test.
Server integration tests for maxage and cache=false
pnpr/crates/pnpr/tests/server.rs
Adds per_uplink_maxage_overrides_global_packument_ttl (upstream hit twice when maxage=0 overrides global TTL) and cache_false_uplink_streams_tarball_without_mirroring (upstream hit twice, no .tgz files written when cache=false).

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server
  participant Upstream
  participant CircuitBreaker
  participant RemoteRegistry

  Client->>Server: GET /pkg or GET /pkg/-/pkg.tgz
  Server->>Upstream: fetch_packument / fetch_tarball_response
  Upstream->>CircuitBreaker: available()?
  alt Circuit open
    CircuitBreaker-->>Upstream: false
    Upstream-->>Server: RegistryError::UpstreamUnavailable
    Server-->>Client: 503 Service Unavailable
  else Circuit closed
    Upstream->>RemoteRegistry: HTTP request (timeout, conditional headers)
    alt 404
      RemoteRegistry-->>Upstream: 404
      Upstream->>CircuitBreaker: record_success
      Upstream-->>Server: NotFound
      Server-->>Client: 404
    else 200 / tarball (cache=false)
      RemoteRegistry-->>Upstream: 200 body
      Upstream->>CircuitBreaker: record_success
      alt upstream.caches() == false
        Upstream-->>Server: bytes stream (no disk write)
      else upstream.caches() == true
        Upstream-->>Server: bytes stream (tee to disk cache)
      end
      Server-->>Client: 200 with body
    else Transport/status error
      RemoteRegistry-->>Upstream: error
      Upstream->>CircuitBreaker: record_failure
      Upstream-->>Server: RegistryError
      Server-->>Client: 5xx
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • pnpm/pnpm#12186: Directly related — introduced the resolved auth/custom headers in UplinkConfig and router_with_auth that this PR builds upon when extending UplinkConfig with tuning knobs and refactoring Upstream::new.
  • pnpm/pnpm#12195: Both PRs modify server.rs tarball cache/mirroring behavior and load_packument_bytes TTL/freshness logic in overlapping code paths.
  • pnpm/pnpm#12239: Both PRs modify the packument proxy conditional-fetch and 304 Not Modified handling in upstream.rs and server.rs::load_packument_bytes.

Suggested labels

product: pnpr

Suggested reviewers

  • zkochan
✨ 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 feat/pnpr-per-uplink-settings

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.

@juanpicado juanpicado changed the title feat(pnpr): per-uplink verdaccio settings (maxage, timeout, max_fails… feat(pnpr): per-uplink uplink settings Jun 13, 2026
@juanpicado juanpicado changed the title feat(pnpr): per-uplink uplink settings feat(pnpr): per-uplink verdaccio settings Jun 13, 2026
@juanpicado juanpicado changed the title feat(pnpr): per-uplink verdaccio settings feat(pnpr): per-uplink proxy settings Jun 13, 2026
@codecov-commenter

codecov-commenter commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.62887% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.12%. Comparing base (83f06a6) to head (55c7b4b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/config.rs 85.85% 14 Missing ⚠️
pnpr/crates/pnpr/src/upstream.rs 89.88% 9 Missing ⚠️
pnpr/crates/pnpr/src/error.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12375      +/-   ##
==========================================
- Coverage   88.12%   88.12%   -0.01%     
==========================================
  Files         311      311              
  Lines       41924    42081     +157     
==========================================
+ Hits        36947    37083     +136     
- Misses       4977     4998      +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@juanpicado juanpicado force-pushed the feat/pnpr-per-uplink-settings branch from cd376df to 7054820 Compare June 13, 2026 11:40
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.061 ± 0.149 3.901 4.346 1.91 ± 0.15
pacquet@main 3.970 ± 0.148 3.829 4.277 1.87 ± 0.14
pnpr@HEAD 2.123 ± 0.143 1.973 2.372 1.00
pnpr@main 2.165 ± 0.167 1.969 2.400 1.02 ± 0.10
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.0610706824400005,
      "stddev": 0.14927018917692705,
      "median": 4.02247400344,
      "user": 3.7198304400000004,
      "system": 3.4593697399999996,
      "min": 3.90144135494,
      "max": 4.345962477940001,
      "times": [
        3.9285775049400002,
        3.9041100239400004,
        4.22536951694,
        4.13578477994,
        4.345962477940001,
        3.90144135494,
        4.1459511969400005,
        3.98983770994,
        4.055110296940001,
        3.97856196194
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.9696701909399996,
      "stddev": 0.14760023769783973,
      "median": 3.8986793874400005,
      "user": 3.72800904,
      "system": 3.44347194,
      "min": 3.82875336894,
      "max": 4.27731359694,
      "times": [
        3.88611588894,
        4.11055817394,
        4.27731359694,
        4.11821008594,
        3.84599692594,
        3.9431590829400003,
        3.9066996429400005,
        3.8892360109400004,
        3.82875336894,
        3.89065913194
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1229439249400004,
      "stddev": 0.14291004059386447,
      "median": 2.06323769444,
      "user": 2.5943600399999993,
      "system": 2.97697504,
      "min": 1.9726825829399999,
      "max": 2.3718135309400004,
      "times": [
        2.03313345894,
        2.31421147494,
        2.05520340294,
        2.27010026694,
        2.01128504994,
        2.3718135309400004,
        2.07127198594,
        2.12196015194,
        2.00777734394,
        1.9726825829399999
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1653577430400004,
      "stddev": 0.16718165165735605,
      "median": 2.1707169859400004,
      "user": 2.58839224,
      "system": 2.96771234,
      "min": 1.9690240909399999,
      "max": 2.4001875689400003,
      "times": [
        2.06167464994,
        2.0208877319400003,
        2.28195627394,
        2.33117704694,
        2.0376453419400002,
        1.9690240909399999,
        2.29991451794,
        1.97135088594,
        2.2797593219400003,
        2.4001875689400003
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 626.8 ± 9.9 610.2 643.8 1.00
pacquet@main 640.2 ± 39.8 604.2 733.9 1.02 ± 0.07
pnpr@HEAD 664.2 ± 16.5 645.9 697.3 1.06 ± 0.03
pnpr@main 674.9 ± 23.4 651.9 718.4 1.08 ± 0.04
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.62678472968,
      "stddev": 0.009944748690332192,
      "median": 0.6278446344799999,
      "user": 0.37680127999999996,
      "system": 1.31533846,
      "min": 0.6102446674800001,
      "max": 0.64384199548,
      "times": [
        0.62224189448,
        0.63217847948,
        0.64384199548,
        0.61671390548,
        0.62861979148,
        0.6102446674800001,
        0.62005089948,
        0.62706947748,
        0.62914505448,
        0.63774113148
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.64023996628,
      "stddev": 0.03980567009053697,
      "median": 0.62901161198,
      "user": 0.37636578,
      "system": 1.3217823599999998,
      "min": 0.60420867448,
      "max": 0.73386336848,
      "times": [
        0.64273352448,
        0.61994090548,
        0.60950496648,
        0.63808231848,
        0.6147924514800001,
        0.63925696248,
        0.60420867448,
        0.6184655704800001,
        0.73386336848,
        0.68155092048
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6642405641800001,
      "stddev": 0.016482777202454495,
      "median": 0.65760180148,
      "user": 0.39311057999999993,
      "system": 1.3375949599999999,
      "min": 0.64592270048,
      "max": 0.69732299448,
      "times": [
        0.67716281448,
        0.65196931248,
        0.65640827448,
        0.64592270048,
        0.65057371148,
        0.66612366548,
        0.65879532848,
        0.65546684248,
        0.6826599974800001,
        0.69732299448
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6748860238800001,
      "stddev": 0.02339955913415537,
      "median": 0.6679308124800001,
      "user": 0.38547677999999996,
      "system": 1.34012716,
      "min": 0.65194334448,
      "max": 0.71839348348,
      "times": [
        0.71839348348,
        0.65412006648,
        0.65653034748,
        0.6570615454800001,
        0.70049252848,
        0.67471185848,
        0.67304241548,
        0.65194334448,
        0.69974543948,
        0.6628192094800001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.178 ± 0.045 4.082 4.241 1.94 ± 0.13
pacquet@main 4.184 ± 0.062 4.100 4.304 1.94 ± 0.13
pnpr@HEAD 2.158 ± 0.142 2.003 2.358 1.00
pnpr@main 2.203 ± 0.120 2.031 2.381 1.02 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.178148886,
      "stddev": 0.04517309363444247,
      "median": 4.1821814881,
      "user": 3.6710296,
      "system": 3.3225696,
      "min": 4.0824067281000005,
      "max": 4.241079792100001,
      "times": [
        4.180078748100001,
        4.1707369941,
        4.2295494981,
        4.0824067281000005,
        4.1904587991,
        4.241079792100001,
        4.1842842281,
        4.1713985011000005,
        4.1973358661,
        4.1341597051
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.1841962733999996,
      "stddev": 0.06171369972966779,
      "median": 4.1693265366,
      "user": 3.6993246999999996,
      "system": 3.3514220999999997,
      "min": 4.0997563291,
      "max": 4.3036137051,
      "times": [
        4.0997563291,
        4.1614043911000005,
        4.1196847301,
        4.175113956100001,
        4.1552864031,
        4.2479734621,
        4.2367698201,
        4.1788208201,
        4.3036137051,
        4.1635391171
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1584465013,
      "stddev": 0.14165126611594933,
      "median": 2.1510134026,
      "user": 2.4472230999999995,
      "system": 2.8841744,
      "min": 2.0027310501,
      "max": 2.3582154050999997,
      "times": [
        2.0981822901,
        2.0224058951,
        2.2038445150999997,
        2.3038761131,
        2.2721417561,
        2.0027310501,
        2.2909176611,
        2.0218555610999998,
        2.0102947661,
        2.3582154050999997
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.2026771443,
      "stddev": 0.12025837577450091,
      "median": 2.1855375810999997,
      "user": 2.4362332999999996,
      "system": 2.8722429999999997,
      "min": 2.0311656141,
      "max": 2.3809472660999997,
      "times": [
        2.2168289940999997,
        2.0311656141,
        2.0845914780999997,
        2.3336128891,
        2.2968969881,
        2.3809472660999997,
        2.1542461681,
        2.3030745160999997,
        2.1222062300999998,
        2.1032012990999998
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.317 ± 0.013 1.296 1.341 2.03 ± 0.03
pacquet@main 1.345 ± 0.015 1.318 1.372 2.08 ± 0.03
pnpr@HEAD 0.666 ± 0.028 0.648 0.743 1.03 ± 0.05
pnpr@main 0.648 ± 0.007 0.628 0.655 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3167593063999998,
      "stddev": 0.013302422011812217,
      "median": 1.3147163573,
      "user": 1.3305488600000002,
      "system": 1.70386994,
      "min": 1.2959707908,
      "max": 1.3414410038,
      "times": [
        1.3414410038,
        1.2959707908,
        1.3129698498,
        1.3211799738,
        1.3164628648,
        1.3058233368,
        1.3294036288,
        1.3113265788,
        1.3267088787999999,
        1.3063061578
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3449849321999998,
      "stddev": 0.015402126545891402,
      "median": 1.3468907042999998,
      "user": 1.33473156,
      "system": 1.7322765399999998,
      "min": 1.3184252128,
      "max": 1.3715250538,
      "times": [
        1.3427890938,
        1.3524721518,
        1.3483736447999999,
        1.3715250538,
        1.3438501828,
        1.3522075798,
        1.3218405098,
        1.3184252128,
        1.3529581287999999,
        1.3454077638
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6657039173,
      "stddev": 0.02812540336395781,
      "median": 0.6587900098000001,
      "user": 0.34954376,
      "system": 1.28956214,
      "min": 0.6478848738,
      "max": 0.7434221268000001,
      "times": [
        0.6546380908000001,
        0.6487201618,
        0.7434221268000001,
        0.6647869898000001,
        0.6576669418000001,
        0.6478848738,
        0.6511521818000001,
        0.6687184148,
        0.6601363138,
        0.6599130778000001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6476592129000001,
      "stddev": 0.007437939000548968,
      "median": 0.6487087588000001,
      "user": 0.34498895999999996,
      "system": 1.2744724399999998,
      "min": 0.6279229998000001,
      "max": 0.6545993188000001,
      "times": [
        0.6480928348000001,
        0.6467978488,
        0.6521242608000001,
        0.6518080098000001,
        0.6474575908000001,
        0.6519137568000001,
        0.6545993188000001,
        0.6465508258000001,
        0.6279229998000001,
        0.6493246828000001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.056 ± 0.046 2.996 3.167 4.67 ± 0.12
pacquet@main 3.002 ± 0.047 2.944 3.110 4.59 ± 0.12
pnpr@HEAD 0.688 ± 0.008 0.680 0.705 1.05 ± 0.02
pnpr@main 0.654 ± 0.013 0.644 0.687 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0558154421399997,
      "stddev": 0.0459558488577677,
      "median": 3.04978394464,
      "user": 1.8334910599999996,
      "system": 1.9569690799999997,
      "min": 2.99563141014,
      "max": 3.1673494141400003,
      "times": [
        3.0357952691400003,
        2.99563141014,
        3.08313995014,
        3.05221389214,
        3.04735399714,
        3.0608630531400003,
        3.04513310314,
        3.1673494141400003,
        3.05451009214,
        3.01616424014
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.00183054784,
      "stddev": 0.0469961699496609,
      "median": 2.99198614364,
      "user": 1.7963371599999998,
      "system": 1.9519771799999996,
      "min": 2.94394798314,
      "max": 3.1101242081400002,
      "times": [
        2.99314876614,
        2.94394798314,
        3.01127858214,
        3.00676132614,
        2.9877888231400003,
        2.9789217441400004,
        2.95454330414,
        3.1101242081400002,
        2.9908235211400003,
        3.04096722014
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6876922842400001,
      "stddev": 0.007706630034002713,
      "median": 0.6872709931400001,
      "user": 0.35090996,
      "system": 1.32806398,
      "min": 0.6795664691400001,
      "max": 0.70506737714,
      "times": [
        0.70506737714,
        0.6831016831400001,
        0.6816610001400001,
        0.69513654414,
        0.68779257114,
        0.68674941514,
        0.6887557681400001,
        0.6885404661400001,
        0.6795664691400001,
        0.6805515481400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6541642445400001,
      "stddev": 0.013227966136486892,
      "median": 0.65007333564,
      "user": 0.33587586,
      "system": 1.2848566799999999,
      "min": 0.64426750414,
      "max": 0.6874317681400001,
      "times": [
        0.65129509314,
        0.64575223914,
        0.65775898714,
        0.64583627314,
        0.64885157814,
        0.64521211714,
        0.64426750414,
        0.65142816514,
        0.66380872014,
        0.6874317681400001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12375
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,178.15 ms
(-0.95%)Baseline: 4,218.39 ms
5,062.06 ms
(82.54%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,055.82 ms
(+1.42%)Baseline: 3,013.03 ms
3,615.63 ms
(84.52%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,316.76 ms
(-1.08%)Baseline: 1,331.12 ms
1,597.35 ms
(82.43%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,061.07 ms
(-3.14%)Baseline: 4,192.59 ms
5,031.11 ms
(80.72%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
626.78 ms
(+1.60%)Baseline: 616.90 ms
740.29 ms
(84.67%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12375
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,158.45 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
687.69 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
665.70 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,122.94 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
664.24 ms
🐰 View full continuous benchmarking report in Bencher

@juanpicado juanpicado force-pushed the feat/pnpr-per-uplink-settings branch from 7054820 to 77d6de2 Compare June 17, 2026 19:27
@juanpicado juanpicado marked this pull request as ready for review June 17, 2026 20:15
@juanpicado juanpicado requested a review from zkochan as a code owner June 17, 2026 20:15
Copilot AI review requested due to automatic review settings June 17, 2026 20:15
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

pnpr: support verdaccio per-uplink proxy knobs (maxage/timeout/breaker/cache)
✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

Description

• Parse verdaccio-style per-uplink settings from config.yaml into runtime uplink config.
• Add per-uplink request timeouts, circuit breaker behavior, and optional tarball mirroring.
• Extend unit and integration tests to cover parsing, defaults, and runtime behavior.
Diagram

graph TD
  C{{"npm client"}} --> S["pnpr server"] --> U["Upstream client"] --> R{{"Upstream registry"}}
  Y["config.yaml"] --> P["Config resolver"] --> U["Upstream client"]
  S["pnpr server"] --> D[("Local mirror/cache")]
  subgraph Legend
    direction LR
    _ext{{"External"}} ~~~ _svc["Service"] ~~~ _file["Config file"] ~~~ _db[("Storage")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use an existing duration parser (e.g., humantime) for intervals
  • ➕ Less custom parsing code to maintain
  • ➕ Potentially better edge-case handling and test coverage upstream
  • ➖ May not match verdaccio semantics exactly (bare numbers as seconds, chained units without spaces, trailing suffix-less number)
  • ➖ Harder to produce field-specific, verdaccio-parity error messages
2. Serde custom deserializers for maxage/timeout/fail_timeout
  • ➕ Moves validation into deserialization with structured errors
  • ➕ Reduces manual parsing in resolve_uplink
  • ➖ Serde errors can be less precise/less contextual than the current InvalidConfig messages naming the uplink and field
  • ➖ Still requires a verdaccio-compatible interval parser underneath
3. Implement breaker at the routing layer instead of in Upstream
  • ➕ Centralizes policy decisions nearer request handling (packument vs tarball differences)
  • ➕ Could enable richer metrics/logging per route
  • ➖ Duplicates logic across packument/tarball paths
  • ➖ Makes Upstream less self-contained and harder to reuse/test in isolation

Recommendation: The PR’s approach—parsing verdaccio-shaped fields into UplinkConfig and enforcing them inside Upstream—is the best fit for pnpr. It keeps per-uplink behavior coherent (timeouts + breaker + cache policy live with the HTTP client) while preserving precise, field-named configuration errors by keeping raw strings in the file shape. The custom interval parser is justified to match verdaccio semantics exactly.

Files changed (7) +589 / -65

Enhancement (4) +348 / -53
config.rsAdd per-uplink verdaccio knobs + interval parsing and validation +153/-8

Add per-uplink verdaccio knobs + interval parsing and validation

• Extends UplinkConfig to carry per-uplink maxage, timeout, max_fails, fail_timeout, and cache, with verdaccio-matching defaults. Updates the YAML uplink shape to accept these fields as raw strings/values, adds a verdaccio-compatible interval parser, and rejects invalid intervals via named InvalidConfig errors. Updates programmatic default uplink construction to use a new with_defaults helper.

pnpr/crates/pnpr/src/config.rs

error.rsIntroduce UpstreamUnavailable (503) for open circuit breaker +13/-0

Introduce UpstreamUnavailable (503) for open circuit breaker

• Adds a new RegistryError variant to represent a short-circuited uplink due to an open circuit breaker. Maps the error to HTTP 503 Service Unavailable for consistent client-facing behavior.

pnpr/crates/pnpr/src/error.rs

server.rsWire per-uplink Upstream config; honor maxage and cache:false tarballs +13/-8

Wire per-uplink Upstream config; honor maxage and cache:false tarballs

• Constructs Upstream instances from the full UplinkConfig rather than only url/headers. Applies per-uplink maxage when computing packument TTL, and adds a fast path for cache:false uplinks to stream tarballs directly without writing to the local mirror.

pnpr/crates/pnpr/src/server.rs

upstream.rsAdd per-uplink timeout, cache policy, and circuit breaker enforcement +169/-37

Add per-uplink timeout, cache policy, and circuit breaker enforcement

• Extends Upstream to carry timeout/maxage/cache and introduces a shared, thread-safe circuit breaker implementing verdaccio’s max_fails/fail_timeout semantics. Applies per-request timeouts, short-circuits when the breaker is open (UpstreamUnavailable), and records success/failure based on transport errors and HTTP statuses.

pnpr/crates/pnpr/src/upstream.rs

Tests (3) +241 / -12
tests.rsUnit tests for interval parsing and uplink knob resolution +80/-3

Unit tests for interval parsing and uplink knob resolution

• Updates test helpers to populate new UplinkFile fields. Adds unit coverage for parse_interval formats (suffixes, compound values, bare seconds) and failure cases, plus resolve_uplink defaulting, explicit knob parsing, and invalid-interval error surfacing.

pnpr/crates/pnpr/src/config/tests.rs

tests.rsTests for breaker behavior and open-circuit short-circuiting +81/-9

Tests for breaker behavior and open-circuit short-circuiting

• Refactors upstream construction in tests to use UplinkConfig defaults. Adds unit tests for CircuitBreaker thresholds, cooldown behavior, and disablement at max_fails=0, plus an async test asserting an open circuit prevents subsequent upstream network calls.

pnpr/crates/pnpr/src/upstream/tests.rs

server.rsIntegration tests for per-uplink maxage and cache:false tarball streaming +80/-0

Integration tests for per-uplink maxage and cache:false tarball streaming

• Adds an integration test proving per-uplink maxage overrides the global packument TTL by forcing revalidation. Adds an integration test ensuring cache:false uplinks do not mirror tarballs and therefore re-fetch on repeated requests.

pnpr/crates/pnpr/tests/server.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends pnpr (the registry proxy) to honor Verdaccio-style per-uplink tuning knobs from config.yaml, so each uplink can independently control packument freshness (maxage), request deadlines (timeout), circuit breaking (max_fails/fail_timeout), and tarball mirroring (cache: false).

Changes:

  • Add per-uplink fields to runtime config (UplinkConfig) and parse Verdaccio interval formats with validation.
  • Implement per-uplink timeouts, a circuit breaker, and cache/no-cache tarball behavior in upstream fetch + server paths.
  • Add unit/integration tests covering interval parsing, uplink resolution defaults/errors, circuit breaker behavior, maxage TTL override, and cache: false streaming.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pnpr/crates/pnpr/tests/server.rs Adds integration tests for per-uplink maxage overriding global TTL and cache: false tarball streaming without mirroring.
pnpr/crates/pnpr/src/upstream/tests.rs Updates upstream test construction for new UplinkConfig-based API and adds circuit breaker tests.
pnpr/crates/pnpr/src/upstream.rs Implements per-uplink timeout/maxage/cache plumbing and introduces a circuit breaker that can short-circuit requests.
pnpr/crates/pnpr/src/server.rs Wires per-uplink upstream configs into router state; applies uplink maxage to packument TTL and streams tarballs when cache: false.
pnpr/crates/pnpr/src/error.rs Adds UpstreamUnavailable error variant mapped to HTTP 503 for open-circuit behavior.
pnpr/crates/pnpr/src/config/tests.rs Adds tests for interval parsing, uplink knob defaults, explicit knob parsing, and invalid-interval errors.
pnpr/crates/pnpr/src/config.rs Extends UplinkConfig/UplinkFile, adds interval parsing, and resolves per-uplink tuning knobs from config YAML.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pnpr/crates/pnpr/src/config.rs
Comment thread pnpr/crates/pnpr/src/config.rs Outdated
Comment thread pnpr/crates/pnpr/src/upstream.rs
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 17, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (3) 📜 Skill insights (0)

Context used

Grey Divider


Action required

1. Breaker probe can stick 🐞 Bug ☼ Reliability
Description
The circuit breaker sets probe_in_flight = true in try_acquire(), but if an admitted request is
cancelled/dropped before any record_*() path runs, the flag never clears and all subsequent
requests will short-circuit indefinitely with UpstreamUnavailable (503). This is reachable in
async request handling (client disconnects/cancellation) because ensure_available() runs before
awaited work in fetch_packument/fetch_tarball_response.
Code

pnpr/crates/pnpr/src/upstream.rs[R124-129]

+        let cooled_down = state.last_failure.is_none_or(|at| at.elapsed() >= self.fail_timeout);
+        if !cooled_down || state.probe_in_flight {
+            return false;
+        }
+        state.probe_in_flight = true;
+        true
Evidence
try_acquire() blocks callers when probe_in_flight is set, but that flag is only cleared by
explicit record calls; both fetch methods call ensure_available() before awaited operations, so
cancellation between admission and recording will leave the breaker stuck.

pnpr/crates/pnpr/src/upstream.rs[110-130]
pnpr/crates/pnpr/src/upstream.rs[245-308]
pnpr/crates/pnpr/src/upstream.rs[321-338]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CircuitBreaker::try_acquire()` marks a half-open probe as `probe_in_flight = true`, but that flag is only cleared by explicit `record_success`/`record_failure`/`record_neutral` calls. If the async task is cancelled/dropped after admission (e.g., client disconnect) before those methods run, the probe slot leaks and the breaker stays permanently “probe in flight”, short-circuiting all future requests.
### Issue Context
This can happen because `ensure_available()` is called before the first `.await` in both fetch paths, so cancellation can occur at any later await point.
### Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[110-149]
- pnpr/crates/pnpr/src/upstream.rs[245-338]
### Suggested fix approach
- Change `try_acquire()` to return an RAII “permit” (e.g., `BreakerPermit`) when it sets `probe_in_flight = true`.
- Implement `Drop` for the permit to clear `probe_in_flight` (equivalent to `record_neutral`) if the request exits early/cancels.
- Thread the permit through `fetch_packument` / `fetch_tarball_response` so that success/failure paths “commit” the permit (preventing the Drop handler from running twice) and update counters appropriately.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. 4xx trips circuit breaker 🐞 Bug ⛨ Security
Description
Upstream::checked() records a circuit-breaker failure for any non-2xx response (except 404 handled
earlier), so repeated upstream 401/403/429 responses can open the breaker and then all subsequent
requests to that uplink short-circuit to UpstreamUnavailable (503), enabling per-uplink denial of
service by triggering expected auth/rate-limit errors.
Code

pnpr/crates/pnpr/src/upstream.rs[R302-314]

+    /// Pass a successful response through; map any non-success status to
+    /// [`RegistryError::UpstreamStatus`] and count it against the breaker
+    /// (a 5xx/4xx-other upstream is a failure; a 404 is handled by the
+    /// callers before reaching here).
+    async fn checked(&self, response: reqwest::Response, url: &str) -> Result<reqwest::Response> {
+        if response.status().is_success() {
+            return Ok(response);
+        }
+        self.breaker.record_failure();
+        let status = response.status();
+        let body = response.text().await.unwrap_or_default();
+        Err(RegistryError::UpstreamStatus { url: url.to_string(), status: status.as_u16(), body })
  }
-    let body = response.text().await.unwrap_or_default();
-    Err(RegistryError::UpstreamStatus { url: url.to_string(), status: status.as_u16(), body })
Evidence
checked() counts any non-2xx response as a breaker failure; once failures reach the threshold,
ensure_available() returns UpstreamUnavailable before sending requests, and that error maps to
HTTP 503.

pnpr/crates/pnpr/src/upstream.rs[302-314]
pnpr/crates/pnpr/src/upstream.rs[283-291]
pnpr/crates/pnpr/src/upstream.rs[89-102]
pnpr/crates/pnpr/src/error.rs[173-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The circuit breaker currently increments its failure counter for **all** non-success upstream HTTP statuses (via `Upstream::checked()`), which means repeated upstream 401/403/429 responses can trip the breaker and cause subsequent requests to be short-circuited with `UpstreamUnavailable` (503). This makes it possible to take an uplink “down” without any actual upstream outage.
### Issue Context
- `ensure_available()` short-circuits requests when the breaker is open.
- `checked()` currently calls `record_failure()` for any non-2xx response, and its comment explicitly treats `4xx` (non-404) as failures.
- `UpstreamUnavailable` maps to HTTP 503.
### Fix Focus Areas
- Update breaker failure classification to only count **transport errors and server-side errors (5xx)** as circuit-breaker failures (optionally include 429 if you intentionally want rate-limits to open the circuit), while leaving other 4xx responses as non-breaker failures.
- Add/adjust unit tests to assert that repeated upstream 401/403 do **not** open the breaker and do not cause `UpstreamUnavailable` short-circuiting.
- pnpr/crates/pnpr/src/upstream.rs[283-314]
- pnpr/crates/pnpr/src/upstream/tests.rs[446-509]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. parse_interval() can panic 📎 Requirement gap ⛨ Security
Description
parse_interval converts attacker-controlled config strings to Duration via
Duration::from_secs_f64 without checking the representable upper bound, which can panic on
extremely large but finite values. This can crash pnpr during config load, preventing safe schema
validation diagnostics.
Code

pnpr/crates/pnpr/src/config.rs[R571-606]

+    // A bare number is seconds, matching verdaccio (`interval * 1000` ms).
+    if let Ok(seconds) = raw.parse::<f64>() {
+        return (seconds.is_finite() && seconds >= 0.0).then(|| Duration::from_secs_f64(seconds));
+    }
+    let mut total_seconds = 0f64;
+    let bytes = raw.as_bytes();
+    let mut index = 0;
+    while index < bytes.len() {
+        if bytes[index].is_ascii_whitespace() {
+            index += 1;
+            continue;
+        }
+        let number_start = index;
+        while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
+            index += 1;
+        }
+        if index == number_start {
+            return None;
+        }
+        let number: f64 = raw[number_start..index].parse().ok()?;
+        let unit_start = index;
+        while index < bytes.len() && bytes[index].is_ascii_alphabetic() {
+            index += 1;
+        }
+        let seconds = match &raw[unit_start..index] {
+            "ms" => number / 1000.0,
+            "s" | "" => number,
+            "m" => number * 60.0,
+            "h" => number * 3600.0,
+            "d" => number * 86_400.0,
+            "w" => number * 604_800.0,
+            _ => return None,
+        };
+        total_seconds += seconds;
+    }
+    total_seconds.is_finite().then(|| Duration::from_secs_f64(total_seconds))
Evidence
PR Compliance ID 8 requires safe config handling with actionable validation diagnostics. The new
interval parser calls Duration::from_secs_f64 in two places without an upper-bound check, which
can panic for very large inputs instead of producing a validation error.

Config parity: ENV substitution plus schema validation diagnostics and hot reload
pnpr/crates/pnpr/src/config.rs[571-606]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`parse_interval` calls `Duration::from_secs_f64` on values that may be finite but out of range for `Duration`, which can panic and crash the registry during config parsing.
## Issue Context
This code parses operator-provided YAML config; invalid values should surface as a named `InvalidConfig` error (diagnostic), not trigger a panic.
## Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[571-606]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Breaker allows probe stampede 🐞 Bug ☼ Reliability
Description
CircuitBreaker::is_available() returns true for all requests once fail_timeout has elapsed, so many
concurrent requests can all re-enter the uplink at once instead of permitting a single half-open
probe. This can overload a recovering upstream and defeats the breaker’s purpose under load.
Code

pnpr/crates/pnpr/src/upstream.rs[R93-102]

+    fn is_available(&self) -> bool {
+        if self.max_fails == 0 || self.failed_requests.load(Ordering::Relaxed) < self.max_fails {
+            return true;
+        }
+        let last_failure = *self.last_failure.lock().expect("circuit breaker poisoned");
+        match last_failure {
+            Some(at) => at.elapsed() >= self.fail_timeout,
+            None => true,
+        }
+    }
Evidence
The breaker’s availability check only tests whether the last failure is older than fail_timeout and
does not reserve a single probe. Because Upstream objects are stored in shared state, concurrent
requests will evaluate is_available() at the same time and all proceed once the cooldown threshold
is crossed.

pnpr/crates/pnpr/src/upstream.rs[89-103]
pnpr/crates/pnpr/src/server.rs[157-170]
pnpr/crates/pnpr/src/server.rs[1756-1764]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The circuit breaker currently re-opens the uplink to *all* callers once `fail_timeout` has elapsed, which can cause a thundering herd of concurrent retries (probe stampede). The intended behavior is to allow only a single probe request after the cooldown, keeping others short-circuited until that probe succeeds (reset) or fails (re-open cooldown).
### Issue Context
`Upstream` instances are stored in shared app state and accessed concurrently by many requests, so `CircuitBreaker::is_available()` must enforce half-open exclusivity.
### Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[66-113]
### Implementation notes
- Introduce a “half-open probe permit” (e.g., `AtomicBool probe_in_flight` or a mutex-protected state).
- When `failed_requests >= max_fails` and cooldown elapsed, allow exactly one caller to acquire the permit (via `compare_exchange`) and proceed; others should return `UpstreamUnavailable`.
- On `record_success()`, clear failures + timestamp and release the permit.
- On `record_failure()`, update failure state and release the permit while restarting cooldown.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Bare-number intervals unreliable 🐞 Bug ≡ Correctness
Description
The new interval fields (maxage, timeout, fail_timeout) are deserialized as Option, which
makes the advertised “bare number means seconds” YAML syntax unreliable unless users quote numbers
as strings. This conflicts with parse_interval() explicitly supporting bare numeric values and can
break verdaccio-shaped configs that use numeric scalars.
Code

pnpr/crates/pnpr/src/config.rs[R415-428]

+    /// Verdaccio interval strings (`"2m"`, `"30s"`, `"1h30m"`) or a bare
+    /// number of seconds; parsed by [`parse_interval`] in
+    /// [`resolve_uplink`]. Kept as raw strings here so an unparsable
+    /// value surfaces as a config error rather than a serde failure.
+    #[serde(default)]
+    maxage: Option<String>,
+    #[serde(default)]
+    timeout: Option<String>,
+    #[serde(default)]
+    max_fails: Option<u32>,
+    #[serde(default)]
+    fail_timeout: Option<String>,
+    #[serde(default)]
+    cache: Option<bool>,
Evidence
UplinkFile defines these knobs as strings, while parse_interval() is intentionally written to
accept bare numeric input; the string-only deserialization shape means “bare number” acceptance only
works when the YAML value is already a string.

pnpr/crates/pnpr/src/config.rs[404-429]
pnpr/crates/pnpr/src/config.rs[554-573]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`UplinkFile` stores `maxage`/`timeout`/`fail_timeout` as `Option<String>`, but `parse_interval()` supports bare numbers (seconds). With a string-only serde shape, YAML numeric scalars (e.g. `timeout: 30`) are not guaranteed to deserialize into these fields, forcing users to quote values and undermining the intended verdaccio compatibility.
### Issue Context
The code comment on `UplinkFile` and the parser behavior both claim bare numbers are accepted.
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[404-429]
- pnpr/crates/pnpr/src/config.rs[524-552]
- pnpr/crates/pnpr/src/config.rs[554-608]
### Suggested fix approach
- Replace `Option<String>` with an untagged scalar enum, e.g.:
- `#[serde(untagged)] enum IntervalScalar { Str(String), Int(i64), Float(f64) }`
- Normalize `IntervalScalar` to a string (or directly to seconds) before calling `parse_interval`.
- Keep the current “invalid interval becomes InvalidConfig naming the field” behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Tarball stream failures bypass breaker 📎 Requirement gap ☼ Reliability
Description
fetch_tarball_response() records circuit-breaker success after only validating response headers,
so mid-stream body errors/timeouts will not increment max_fails and the uplink may never trip to
UpstreamUnavailable. This weakens the per-uplink reliability controls and can cause repeated
failing upstream fetches under load.
Code

pnpr/crates/pnpr/src/upstream.rs[R275-280]

+        let response = self.checked(response, &url).await?;
+        // Success here covers only headers; a mid-stream body failure is
+        // the caller's to observe. Recording success on a clean status is
+        // what verdaccio does too.
+        self.breaker.record_success();
     Ok(FetchOutcome::Ok(response))
Evidence
Rule 9 requires max_fails/fail_timeout to influence runtime behavior by tracking failures. The
new tarball path records record_success() immediately after checked() (status-only), while the
actual response body is streamed later via response.bytes_stream() in serve_tarball, so stream
errors will not be counted as breaker failures.

Per-uplink reliability controls implemented (timeouts, max_fails, fail_timeout, cache disable)
pnpr/crates/pnpr/src/upstream.rs[261-280]
pnpr/crates/pnpr/src/server.rs[659-683]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The circuit breaker is updated as a success before tarball streaming completes, so body-stream failures (including request timeouts during streaming) do not count toward `max_fails`.
## Issue Context
Per-uplink reliability controls (rule 9) require failures to be tracked so the breaker can open and short-circuit requests. In the tarball path, `reqwest::Response` is returned for streaming and errors can occur after headers are read.
## Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[261-280]
- pnpr/crates/pnpr/src/server.rs[659-683]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Breaker leaks upstream URL 🐞 Bug ⛨ Security
Description
Upstream::ensure_available() constructs RegistryError::UpstreamUnavailable with `uplink:
self.base.clone() (the upstream URL), and error_response() returns err.to_string()` in the HTTP
body. This can disclose internal upstream endpoints to clients and reports the failing uplink as a
URL rather than the configured uplink name.
Code

pnpr/crates/pnpr/src/upstream.rs[R286-291]

+    fn ensure_available(&self) -> Result<()> {
+        if self.breaker.is_available() {
+            return Ok(());
+        }
+        Err(RegistryError::UpstreamUnavailable { uplink: self.base.clone() })
+    }
Evidence
ensure_available() uses self.base (configured upstream URL) as the uplink field, and
error_response() serializes errors with err.to_string() directly into the HTTP response body;
the UpstreamUnavailable Display format includes {uplink}.

pnpr/crates/pnpr/src/upstream.rs[286-291]
pnpr/crates/pnpr/src/upstream.rs[170-184]
pnpr/crates/pnpr/src/error.rs[22-32]
pnpr/crates/pnpr/src/server.rs[1975-1979]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When the circuit breaker is open, pnpr returns a 503 with a message containing the upstream base URL (because `UpstreamUnavailable.uplink` is set from `self.base`). Since `server::error_response` returns `err.to_string()` to the client, this leaks upstream endpoint details and produces less actionable errors than identifying the configured uplink name.
## Issue Context
- `Upstream` currently does not retain the configured uplink name; it only stores `base` (URL).
- `UpstreamUnavailable` is intended to identify the uplink, but it is currently populated with the URL.
## Fix Focus Areas
- Add an `uplink_name: String` (or similar) field to `Upstream` and populate it when building upstreams in the router.
- Change `ensure_available()` to emit `UpstreamUnavailable { uplink: uplink_name.clone() }` (or change the error variant to carry both name+url but only display the name).
- Keep the URL for logs/spans if needed, but avoid returning it in the client-visible error string.
### Code pointers
- pnpr/crates/pnpr/src/upstream.rs[170-184]
- pnpr/crates/pnpr/src/upstream.rs[283-291]
- pnpr/crates/pnpr/src/server.rs[160-166]
- pnpr/crates/pnpr/src/error.rs[22-32]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
8. CircuitBreaker lock expect() panics 📎 Requirement gap ☼ Reliability
Description
The new circuit breaker uses Mutex::lock().expect("circuit breaker poisoned"), which can panic in
the request path if the mutex is poisoned. A panic here can crash request handling and jeopardize
core packument/tarball serving availability.
Code

pnpr/crates/pnpr/src/upstream.rs[R93-112]

+    fn is_available(&self) -> bool {
+        if self.max_fails == 0 || self.failed_requests.load(Ordering::Relaxed) < self.max_fails {
+            return true;
+        }
+        let last_failure = *self.last_failure.lock().expect("circuit breaker poisoned");
+        match last_failure {
+            Some(at) => at.elapsed() >= self.fail_timeout,
+            None => true,
+        }
+    }
+
+    fn record_success(&self) {
+        self.failed_requests.store(0, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = None;
+    }
+
+    fn record_failure(&self) {
+        self.failed_requests.fetch_add(1, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = Some(Instant::now());
+    }
Evidence
PR Compliance ID 12 requires maintaining core registry behaviors (including serving
packuments/tarballs) without regressions. The new circuit breaker introduces panicking expect()
calls on a mutex lock in the request path, which can crash processing and break those core
behaviors.

Registry core behaviors maintained: packument/tarball serving, local search, atomic writes, on-disk layout, integration tests
pnpr/crates/pnpr/src/upstream.rs[93-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CircuitBreaker` uses `expect()` on `Mutex::lock()`, which will panic if the mutex is poisoned.
## Issue Context
This code runs on hot request paths (`is_available`, `record_success`, `record_failure`). A poisoned mutex should not take down the registry; it should recover (e.g., use `into_inner()`), reset breaker state, and continue.
## Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[93-112]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Breaker bypass race window 🐞 Bug ☼ Reliability
Description
record_failure() increments failed_requests before setting last_failure, while is_available() treats
last_failure == None as available even when failed_requests >= max_fails. Under concurrency this can
briefly allow traffic through an already-tripped breaker, reducing short-circuit effectiveness
during failures.
Code

pnpr/crates/pnpr/src/upstream.rs[R93-112]

+    fn is_available(&self) -> bool {
+        if self.max_fails == 0 || self.failed_requests.load(Ordering::Relaxed) < self.max_fails {
+            return true;
+        }
+        let last_failure = *self.last_failure.lock().expect("circuit breaker poisoned");
+        match last_failure {
+            Some(at) => at.elapsed() >= self.fail_timeout,
+            None => true,
+        }
+    }
+
+    fn record_success(&self) {
+        self.failed_requests.store(0, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = None;
+    }
+
+    fn record_failure(&self) {
+        self.failed_requests.fetch_add(1, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = Some(Instant::now());
+    }
Evidence
The code explicitly returns true when last_failure is None, and record_failure() performs the
atomic increment before setting last_failure, creating a window where the breaker should be open
but is_available() can still return true.

pnpr/crates/pnpr/src/upstream.rs[93-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CircuitBreaker` splits its state across an atomic counter and a mutex-protected timestamp. Because `record_failure()` updates the counter before the timestamp, and `is_available()` treats a missing timestamp (`None`) as available, concurrent interleavings can observe `failed_requests >= max_fails` but `last_failure == None` and incorrectly allow a request.
### Issue Context
This shows up under concurrency and weakens the breaker’s protection precisely when the uplink is failing.
### Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[93-112]
### Implementation notes
Choose one:
- Treat `last_failure == None` as **unavailable** when `failed_requests >= max_fails` (safer default), and/or
- Update `last_failure` before incrementing `failed_requests`, and/or
- Collapse breaker state into a single lock-protected struct so threshold checks + timestamp updates are consistent.
Also consider eliminating `expect("circuit breaker poisoned")` by recovering from poisoning (`into_inner()`) to avoid panics cascading into process instability.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 55c7b4b

Results up to commit 31d5f5c


🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (3) 📜 Skill insights (0)

Context used

Action required
1. Breaker probe can stick 🐞 Bug ☼ Reliability ⭐ New
Description
The circuit breaker sets probe_in_flight = true in try_acquire(), but if an admitted request is
cancelled/dropped before any record_*() path runs, the flag never clears and all subsequent
requests will short-circuit indefinitely with UpstreamUnavailable (503). This is reachable in
async request handling (client disconnects/cancellation) because ensure_available() runs before
awaited work in fetch_packument/fetch_tarball_response.
Code

pnpr/crates/pnpr/src/upstream.rs[R124-129]

+        let cooled_down = state.last_failure.is_none_or(|at| at.elapsed() >= self.fail_timeout);
+        if !cooled_down || state.probe_in_flight {
+            return false;
+        }
+        state.probe_in_flight = true;
+        true
Evidence
try_acquire() blocks callers when probe_in_flight is set, but that flag is only cleared by
explicit record calls; both fetch methods call ensure_available() before awaited operations, so
cancellation between admission and recording will leave the breaker stuck.

pnpr/crates/pnpr/src/upstream.rs[110-130]
pnpr/crates/pnpr/src/upstream.rs[245-308]
pnpr/crates/pnpr/src/upstream.rs[321-338]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`CircuitBreaker::try_acquire()` marks a half-open probe as `probe_in_flight = true`, but that flag is only cleared by explicit `record_success`/`record_failure`/`record_neutral` calls. If the async task is cancelled/dropped after admission (e.g., client disconnect) before those methods run, the probe slot leaks and the breaker stays permanently “probe in flight”, short-circuiting all future requests.

### Issue Context
This can happen because `ensure_available()` is called before the first `.await` in both fetch paths, so cancellation can occur at any later await point.

### Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[110-149]
- pnpr/crates/pnpr/src/upstream.rs[245-338]

### Suggested fix approach
- Change `try_acquire()` to return an RAII “permit” (e.g., `BreakerPermit`) when it sets `probe_in_flight = true`.
- Implement `Drop` for the permit to clear `probe_in_flight` (equivalent to `record_neutral`) if the request exits early/cancels.
- Thread the permit through `fetch_packument` / `fetch_tarball_response` so that success/failure paths “commit” the permit (preventing the Drop handler from running twice) and update counters appropriately.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. 4xx trips circuit breaker 🐞 Bug ⛨ Security
Description
Upstream::checked() records a circuit-breaker failure for any non-2xx response (except 404 handled
earlier), so repeated upstream 401/403/429 responses can open the breaker and then all subsequent
requests to that uplink short-circuit to UpstreamUnavailable (503), enabling per-uplink denial of
service by triggering expected auth/rate-limit errors.
Code

pnpr/crates/pnpr/src/upstream.rs[R302-314]

+    /// Pass a successful response through; map any non-success status to
+    /// [`RegistryError::UpstreamStatus`] and count it against the breaker
+    /// (a 5xx/4xx-other upstream is a failure; a 404 is handled by the
+    /// callers before reaching here).
+    async fn checked(&self, response: reqwest::Response, url: &str) -> Result<reqwest::Response> {
+        if response.status().is_success() {
+            return Ok(response);
+        }
+        self.breaker.record_failure();
+        let status = response.status();
+        let body = response.text().await.unwrap_or_default();
+        Err(RegistryError::UpstreamStatus { url: url.to_string(), status: status.as_u16(), body })
   }
-    let body = response.text().await.unwrap_or_default();
-    Err(RegistryError::UpstreamStatus { url: url.to_string(), status: status.as_u16(), body })
Evidence
checked() counts any non-2xx response as a breaker failure; once failures reach the threshold,
ensure_available() returns UpstreamUnavailable before sending requests, and that error maps to
HTTP 503.

pnpr/crates/pnpr/src/upstream.rs[302-314]
pnpr/crates/pnpr/src/upstream.rs[283-291]
pnpr/crates/pnpr/src/upstream.rs[89-102]
pnpr/crates/pnpr/src/error.rs[173-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The circuit breaker currently increments its failure counter for **all** non-success upstream HTTP statuses (via `Upstream::checked()`), which means repeated upstream 401/403/429 responses can trip the breaker and cause subsequent requests to be short-circuited with `UpstreamUnavailable` (503). This makes it possible to take an uplink “down” without any actual upstream outage.
### Issue Context
- `ensure_available()` short-circuits requests when the breaker is open.
- `checked()` currently calls `record_failure()` for any non-2xx response, and its comment explicitly treats `4xx` (non-404) as failures.
- `UpstreamUnavailable` maps to HTTP 503.
### Fix Focus Areas
- Update breaker failure classification to only count **transport errors and server-side errors (5xx)** as circuit-breaker failures (optionally include 429 if you intentionally want rate-limits to open the circuit), while leaving other 4xx responses as non-breaker failures.
- Add/adjust unit tests to assert that repeated upstream 401/403 do **not** open the breaker and do not cause `UpstreamUnavailable` short-circuiting.
- pnpr/crates/pnpr/src/upstream.rs[283-314]
- pnpr/crates/pnpr/src/upstream/tests.rs[446-509]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. parse_interval() can panic 📎 Requirement gap ⛨ Security
Description
parse_interval converts attacker-controlled config strings to Duration via
Duration::from_secs_f64 without checking the representable upper bound, which can panic on
extremely large but finite values. This can crash pnpr during config load, preventing safe schema
validation diagnostics.
Code

pnpr/crates/pnpr/src/config.rs[R571-606]

+    // A bare number is seconds, matching verdaccio (`interval * 1000` ms).
+    if let Ok(seconds) = raw.parse::<f64>() {
+        return (seconds.is_finite() && seconds >= 0.0).then(|| Duration::from_secs_f64(seconds));
+    }
+    let mut total_seconds = 0f64;
+    let bytes = raw.as_bytes();
+    let mut index = 0;
+    while index < bytes.len() {
+        if bytes[index].is_ascii_whitespace() {
+            index += 1;
+            continue;
+        }
+        let number_start = index;
+        while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
+            index += 1;
+        }
+        if index == number_start {
+            return None;
+        }
+        let number: f64 = raw[number_start..index].parse().ok()?;
+        let unit_start = index;
+        while index < bytes.len() && bytes[index].is_ascii_alphabetic() {
+            index += 1;
+        }
+        let seconds = match &raw[unit_start..index] {
+            "ms" => number / 1000.0,
+            "s" | "" => number,
+            "m" => number * 60.0,
+            "h" => number * 3600.0,
+            "d" => number * 86_400.0,
+            "w" => number * 604_800.0,
+            _ => return None,
+        };
+        total_seconds += seconds;
+    }
+    total_seconds.is_finite().then(|| Duration::from_secs_f64(total_seconds))
Evidence
PR Compliance ID 8 requires safe config handling with actionable validation diagnostics. The new
interval parser calls Duration::from_secs_f64 in two places without an upper-bound check, which
can panic for very large inputs instead of producing a validation error.

Config parity: ENV substitution plus schema validation diagnostics and hot reload
pnpr/crates/pnpr/src/config.rs[571-606]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`parse_interval` calls `Duration::from_secs_f64` on values that may be finite but out of range for `Duration`, which can panic and crash the registry during config parsing.
## Issue Context
This code parses operator-provided YAML config; invalid values should surface as a named `InvalidConfig` error (diagnostic), not trigger a panic.
## Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[571-606]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Breaker allows probe stampede 🐞 Bug ☼ Reliability
Description
CircuitBreaker::is_available() returns true for all requests once fail_timeout has elapsed, so many
concurrent requests can all re-enter the uplink at once instead of permitting a single half-open
probe. This can overload a recovering upstream and defeats the breaker’s purpose under load.
Code

pnpr/crates/pnpr/src/upstream.rs[R93-102]

+    fn is_available(&self) -> bool {
+        if self.max_fails == 0 || self.failed_requests.load(Ordering::Relaxed) < self.max_fails {
+            return true;
+        }
+        let last_failure = *self.last_failure.lock().expect("circuit breaker poisoned");
+        match last_failure {
+            Some(at) => at.elapsed() >= self.fail_timeout,
+            None => true,
+        }
+    }
Evidence
The breaker’s availability check only tests whether the last failure is older than fail_timeout and
does not reserve a single probe. Because Upstream objects are stored in shared state, concurrent
requests will evaluate is_available() at the same time and all proceed once the cooldown threshold
is crossed.

pnpr/crates/pnpr/src/upstream.rs[89-103]
pnpr/crates/pnpr/src/server.rs[157-170]
pnpr/crates/pnpr/src/server.rs[1756-1764]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The circuit breaker currently re-opens the uplink to *all* callers once `fail_timeout` has elapsed, which can cause a thundering herd of concurrent retries (probe stampede). The intended behavior is to allow only a single probe request after the cooldown, keeping others short-circuited until that probe succeeds (reset) or fails (re-open cooldown).
### Issue Context
`Upstream` instances are stored in shared app state and accessed concurrently by many requests, so `CircuitBreaker::is_available()` must enforce half-open exclusivity.
### Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[66-113]
### Implementation notes
- Introduce a “half-open probe permit” (e.g., `AtomicBool probe_in_flight` or a mutex-protected state).
- When `failed_requests >= max_fails` and cooldown elapsed, allow exactly one caller to acquire the permit (via `compare_exchange`) and proceed; others should return `UpstreamUnavailable`.
- On `record_success()`, clear failures + timestamp and release the permit.
- On `record_failure()`, update failure state and release the permit while restarting cooldown.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
5. Bare-number intervals unreliable 🐞 Bug ≡ Correctness ⭐ New
Description
The new interval fields (maxage, timeout, fail_timeout) are deserialized as Option<String>,
which makes the advertised “bare number means seconds” YAML syntax unreliable unless users quote
numbers as strings. This conflicts with parse_interval() explicitly supporting bare numeric values
and can break verdaccio-shaped configs that use numeric scalars.
Code

pnpr/crates/pnpr/src/config.rs[R415-428]

+    /// Verdaccio interval strings (`"2m"`, `"30s"`, `"1h30m"`) or a bare
+    /// number of seconds; parsed by [`parse_interval`] in
+    /// [`resolve_uplink`]. Kept as raw strings here so an unparsable
+    /// value surfaces as a config error rather than a serde failure.
+    #[serde(default)]
+    maxage: Option<String>,
+    #[serde(default)]
+    timeout: Option<String>,
+    #[serde(default)]
+    max_fails: Option<u32>,
+    #[serde(default)]
+    fail_timeout: Option<String>,
+    #[serde(default)]
+    cache: Option<bool>,
Evidence
UplinkFile defines these knobs as strings, while parse_interval() is intentionally written to
accept bare numeric input; the string-only deserialization shape means “bare number” acceptance only
works when the YAML value is already a string.

pnpr/crates/pnpr/src/config.rs[404-429]
pnpr/crates/pnpr/src/config.rs[554-573]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`UplinkFile` stores `maxage`/`timeout`/`fail_timeout` as `Option<String>`, but `parse_interval()` supports bare numbers (seconds). With a string-only serde shape, YAML numeric scalars (e.g. `timeout: 30`) are not guaranteed to deserialize into these fields, forcing users to quote values and undermining the intended verdaccio compatibility.

### Issue Context
The code comment on `UplinkFile` and the parser behavior both claim bare numbers are accepted.

### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[404-429]
- pnpr/crates/pnpr/src/config.rs[524-552]
- pnpr/crates/pnpr/src/config.rs[554-608]

### Suggested fix approach
- Replace `Option<String>` with an untagged scalar enum, e.g.:
 - `#[serde(untagged)] enum IntervalScalar { Str(String), Int(i64), Float(f64) }`
- Normalize `IntervalScalar` to a string (or directly to seconds) before calling `parse_interval`.
- Keep the current “invalid interval becomes InvalidConfig naming the field” behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Tarball stream failures bypass breaker 📎 Requirement gap ☼ Reliability
Description
fetch_tarball_response() records circuit-breaker success after only validating response headers,
so mid-stream body errors/timeouts will not increment max_fails and the uplink may never trip to
UpstreamUnavailable. This weakens the per-uplink reliability controls and can cause repeated
failing upstream fetches under load.
Code

pnpr/crates/pnpr/src/upstream.rs[R275-280]

+        let response = self.checked(response, &url).await?;
+        // Success here covers only headers; a mid-stream body failure is
+        // the caller's to observe. Recording success on a clean status is
+        // what verdaccio does too.
+        self.breaker.record_success();
      Ok(FetchOutcome::Ok(response))
Evidence
Rule 9 requires max_fails/fail_timeout to influence runtime behavior by tracking failures. The
new tarball path records record_success() immediately after checked() (status-only), while the
actual response body is streamed later via response.bytes_stream() in serve_tarball, so stream
errors will not be counted as breaker failures.

Per-uplink reliability controls implemented (timeouts, max_fails, fail_timeout, cache disable)
pnpr/crates/pnpr/src/upstream.rs[261-280]
pnpr/crates/pnpr/src/server.rs[659-683]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The circuit breaker is updated as a success before tarball streaming completes, so body-stream failures (including request timeouts during streaming) do not count toward `max_fails`.
## Issue Context
Per-uplink reliability controls (rule 9) require failures to be tracked so the breaker can open and short-circuit requests. In the tarball path, `reqwest::Response` is returned for streaming and errors can occur after headers are read.
## Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[261-280]
- pnpr/crates/pnpr/src/server.rs[659-683]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Breaker leaks upstream URL 🐞 Bug ⛨ Security
Description
Upstream::ensure_available() constructs RegistryError::UpstreamUnavailable with `uplink:
self.base.clone() (the upstream URL), and error_response() returns err.to_string()` in the HTTP
body. This can disclose internal upstream endpoints to clients and reports the failing uplink as a
URL rather than the configured uplink name.
Code

pnpr/crates/pnpr/src/upstream.rs[R286-291]

+    fn ensure_available(&self) -> Result<()> {
+        if self.breaker.is_available() {
+            return Ok(());
+        }
+        Err(RegistryError::UpstreamUnavailable { uplink: self.base.clone() })
+    }
Evidence
ensure_available() uses self.base (configured upstream URL) as the uplink field, and
error_response() serializes errors with err.to_string() directly into the HTTP response body;
the UpstreamUnavailable Display format includes {uplink}.

pnpr/crates/pnpr/src/upstream.rs[286-291]
pnpr/crates/pnpr/src/upstream.rs[170-184]
pnpr/crates/pnpr/src/error.rs[22-32]
pnpr/crates/pnpr/src/server.rs[1975-1979]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When the circuit breaker is open, pnpr returns a 503 with a message containing the upstream base URL (because `UpstreamUnavailable.uplink` is set from `self.base`). Since `server::error_response` returns `err.to_string()` to the client, this leaks upstream endpoint details and produces less actionable errors than identifying the configured uplink name.
## Issue Context
- `Upstream` currently does not retain the configured uplink name; it only stores `base` (URL).
- `UpstreamUnavailable` is intended to identify the uplink, but it is currently populated with the URL.
## Fix Focus Areas
- Add an `uplink_name: String` (or similar) field to `Upstream` and populate it when building upstreams in the router.
- Change `ensure_available()` to emit `UpstreamUnavailable { uplink: uplink_name.clone() }` (or change the error variant to carry both name+url but only display the name).
- Keep the URL for logs/spans if needed, but avoid returning it in the client-visible error string.
### Code pointers
- pnpr/crates/pnpr/src/upstream.rs[170-184]
- pnpr/crates/pnpr/src/upstream.rs[283-291]
- pnpr/crates/pnpr/src/server.rs[160-166]
- pnpr/crates/pnpr/src/error.rs[22-32]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
8. CircuitBreaker lock expect() panics 📎 Requirement gap ☼ Reliability
Description
The new circuit breaker uses Mutex::lock().expect("circuit breaker poisoned"), which can panic in
the request path if the mutex is poisoned. A panic here can crash request handling and jeopardize
core packument/tarball serving availability.
Code

pnpr/crates/pnpr/src/upstream.rs[R93-112]

+    fn is_available(&self) -> bool {
+        if self.max_fails == 0 || self.failed_requests.load(Ordering::Relaxed) < self.max_fails {
+            return true;
+        }
+        let last_failure = *self.last_failure.lock().expect("circuit breaker poisoned");
+        match last_failure {
+            Some(at) => at.elapsed() >= self.fail_timeout,
+            None => true,
+        }
+    }
+
+    fn record_success(&self) {
+        self.failed_requests.store(0, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = None;
+    }
+
+    fn record_failure(&self) {
+        self.failed_requests.fetch_add(1, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = Some(Instant::now());
+    }
Evidence
PR Compliance ID 12 requires maintaining core registry behaviors (including serving
packuments/tarballs) without regressions. The new circuit breaker introduces panicking expect()
calls on a mutex lock in the request path, which can crash processing and break those core
behaviors.

Registry core behaviors maintained: packument/tarball serving, local search, atomic writes, on-disk layout, integration tests
pnpr/crates/pnpr/src/upstream.rs[93-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CircuitBreaker` uses `expect()` on `Mutex::lock()`, which will panic if the mutex is poisoned.
## Issue Context
This code runs on hot request paths (`is_available`, `record_success`, `record_failure`). A poisoned mutex should not take down the registry; it should recover (e.g., use `into_inner()`), reset breaker state, and continue.
## Fix Focus Areas
- pnpr/crates/pnpr/src/upstream.rs[93-112]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Breaker bypass race window 🐞 Bug ☼ Reliability
Description
record_failure() increments failed_requests before setting last_failure, while is_available() treats
last_failure == None as available even when failed_requests >= max_fails. Under concurrency this can
briefly allow traffic through an already-tripped breaker, reducing short-circuit effectiveness
during failures.
Code

pnpr/crates/pnpr/src/upstream.rs[R93-112]

+    fn is_available(&self) -> bool {
+        if self.max_fails == 0 || self.failed_requests.load(Ordering::Relaxed) < self.max_fails {
+            return true;
+        }
+        let last_failure = *self.last_failure.lock().expect("circuit breaker poisoned");
+        match last_failure {
+            Some(at) => at.elapsed() >= self.fail_timeout,
+            None => true,
+        }
+    }
+
+    fn record_success(&self) {
+        self.failed_requests.store(0, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = None;
+    }
+
+    fn record_failure(&self) {
+        self.failed_requests.fetch_add(1, Ordering::Relaxed);
+        *self.last_failure.lock().expect("circuit breaker poisoned") = Some(Instant::now());
+    }
Evidence
The code explicitly returns true when last_failure is None, and record_failure() performs the
atomic increment before setting last_failure, creating a window where the breaker should be open
but is_available() can still return true.

pnpr/crates/pnpr/src/upstream.rs[93-112]

Agent prompt
The issue below was found dur...

Comment thread pnpr/crates/pnpr/src/config.rs Outdated
Comment thread pnpr/crates/pnpr/src/upstream.rs Outdated
Comment thread pnpr/crates/pnpr/src/upstream.rs Outdated
Comment thread pnpr/crates/pnpr/src/upstream.rs Outdated
@github-actions github-actions Bot added the reviewed: coderabbit CodeRabbit submitted an approving review label Jun 17, 2026
Comment thread pnpr/crates/pnpr/src/upstream.rs
Comment thread pnpr/crates/pnpr/src/upstream.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 77d6de2

…, fail_timeout, cache)

Add verdaccio's per-uplink tuning knobs to pnpr's proxy uplinks, parsed
from the same config.yaml shape verdaccio uses:

- maxage:       per-uplink packument freshness window; overrides the
                global packument_ttl when set, otherwise defers to it.
- timeout:      per-request deadline applied to every upstream fetch
                (default 30s).
- max_fails /   circuit breaker: after max_fails consecutive failures the
  fail_timeout  uplink short-circuits with a 503 until the fail_timeout
                cooldown lapses, then a single probe is allowed through
                (defaults 2 / 5m).
- cache:        when false, tarballs stream through without being written
                to the local mirror (default true).

Interval values accept verdaccio's format ("2m", "30s", "1h30m", or a
bare number of seconds) via a new parse_interval; an unparseable value
is a named InvalidConfig error rather than a silent fallback. Defaults
match verdaccio (timeout 30s, max_fails 2, fail_timeout 5m, cache true).

Covered by unit tests (interval parsing, uplink resolution, circuit
breaker) and integration tests (maxage overriding the global TTL, and a
cache:false uplink streaming a tarball without mirroring it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zkochan zkochan force-pushed the feat/pnpr-per-uplink-settings branch from 77d6de2 to da83f42 Compare June 17, 2026 21:16
Comment thread pnpr/crates/pnpr/src/upstream.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit da83f42

Address review feedback on the per-uplink settings:

- parse_interval: use Duration::try_from_secs_f64 so an out-of-range
  but finite interval (e.g. "1e30") surfaces as a named InvalidConfig
  error instead of panicking pnpr at config load.
- Circuit breaker: collapse the split atomic counter + mutex timestamp
  into one Mutex<BreakerState>, so a threshold check and its timestamp
  can never be observed half-updated (closes the bypass race window).
  The half-open state now admits exactly one probe (a probe-in-flight
  permit) rather than letting every waiting caller stampede a
  recovering upstream. A poisoned lock is recovered via into_inner
  rather than panicking on the request path.
- Only a 5xx counts against the breaker; a non-404 4xx (auth,
  rate-limit, bad request) is an authoritative answer, not an
  availability signal, so it no longer opens the circuit and mask the
  real status behind a 503.
- UpstreamUnavailable now carries the configured uplink name instead of
  the upstream URL, so the client-facing 503 doesn't disclose the
  internal endpoint.
- Drop the unused, self-contradictory UplinkConfig::DEFAULT_MAXAGE
  constant; an unset maxage defers to the global packument_ttl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 17, 2026 21:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread pnpr/crates/pnpr/src/upstream.rs
Comment thread pnpr/crates/pnpr/src/config.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 31d5f5c

Follow-up to the per-uplink review:

- Circuit breaker half-open probe could stick: the probe_in_flight flag
  was only cleared by a record_*() path, so a request cancelled or
  dropped after try_acquire admitted it (e.g. a client disconnect) left
  the flag set and short-circuited the uplink with 503 forever. Gate the
  probe on the cooldown window itself instead — admitting a probe re-arms
  last_failure, so concurrent callers wait one fail_timeout and an
  abandoned probe simply lets the window lapse. Drops the sticky flag
  (and the now-unneeded record_neutral); a non-404 4xx just leaves the
  breaker untouched.
- Bare-number intervals now deserialize: maxage/timeout/fail_timeout were
  Option<String>, so a verdaccio-shaped `timeout: 30` (a YAML number)
  failed to parse even though parse_interval advertises bare-number
  support. A new Interval type accepts both a string and a numeric scalar
  (read as seconds), keeping the raw value for parse_interval.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 55c7b4b

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

product: pnpr reviewed: coderabbit CodeRabbit submitted an approving review state: automerge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants