Skip to content

feat: per-route timeout and throttle (#140) - #253

Merged
umputun merged 13 commits into
masterfrom
per-route-timeout-throttle
May 13, 2026
Merged

umputun merged 13 commits into
masterfrom
per-route-timeout-throttle

Conversation

@umputun

@umputun umputun commented May 13, 2026

Copy link
Copy Markdown
Owner

adds optional per-route timeout and throttle fields to URLMapper, falling back to global when zero. Addresses #140.

per-route deadline uses context.WithTimeout + http.ResponseController.SetReadDeadline/SetWriteDeadline, which overrides --timeout.write on the connection for matched requests. Per-route throttle uses a lazy sync.Map of route-scoped tollbooth limiters; the [ip, dst] key shape is preserved so per-user throttling does not regress.

all four providers gained the new fields:

  • file (yaml): timeout: 5m, throttle: 10
  • static (csv): two new positional fields after forward-health-checks
  • docker: reproxy.<n>.timeout, reproxy.<n>.throttle labels (warn-and-zero on bad values)
  • consul-catalog: reproxy.timeout, reproxy.throttle tags (same warn-and-zero)

zero value = inherit global, positive overrides. extendMapper was updated to carry both fields through implicit-extension routes (a place provider-level tests would not catch on their own).

known limitation: per-route timeout does NOT override transport-level --timeout.resp-header (default 5s). Overriding Transport.ResponseHeaderTimeout per route would require per-destination transports, which is intentionally out of scope for this PR. Operators with slow report-generation upstreams still need to raise --timeout.resp-header globally. README documents this.

umputun added 13 commits May 12, 2026 04:10
… parsing

Adds per-route Timeout (time.Duration) and Throttle (int) fields to the
URLMapper struct. Zero values preserve existing global-fallback behavior.

extendMapper now propagates both fields through the simple-extension path
so routes like /api/ -> /dst/ don't silently lose per-route config before
reaching Match().

File (yaml) provider parses timeout as a duration string and throttle as
int. Negative timeout/throttle values surface as parse errors.

Part of plan 20260512-per-route-timeout-throttle.md (Task 1 of 10).
Extend rule format from server,source,dest[,ping[,forward-health-checks]]
to server,source,dest[,ping[,forward-health-checks[,timeout[,throttle]]]].
Empty positional fields inherit global defaults; invalid duration,
non-integer throttle, or negative values return parse errors at startup.
Read reproxy.<n>.timeout and reproxy.<n>.throttle labels via the
existing labelN helper. Invalid duration, non-integer, or negative
values warn and fall back to zero (matches the warn-and-zero pattern
of forward-health-checks/keep-host), so a single bad label cannot
crash discovery for the entire fleet.
Parse reproxy.timeout and reproxy.throttle labels with warn-and-zero
semantics matching the docker provider behavior.
When a matched URLMapper has Timeout > 0, wrap the request context with
context.WithTimeout and override the connection's read/write deadlines via
http.ResponseController. The middleware runs immediately after matchHandler
and before per-route auth/throttle so auth and limiter work runs inside the
per-route deadline. http.ErrNotSupported from ResponseController is treated
as expected (context timeout still propagates).
When matched route has Throttle > 0, apply a per-route rate limiter keyed
by [ip, dst] so per-user behavior is preserved while the route gets its
own bucket. Cache route limiters in a sync.Map keyed by server|src|rate;
including rate in the key means config edits produce new limiters on
next request.

Build the global limiter lazily via sync.Once and drop the early-return
to passThroughHandler when global rate is 0 — that path now still
consults per-route Throttle, so route overrides work even when the
global throttle is disabled.
Add integration test that wires up a real Http server with provider.File
pointing at a fixture with four routes (timeout, throttle, both, control).
Tests verify per-route timeout fires and returns 502, throttle blocks
excess rapid requests with 429, chain ordering keeps throttle 429 inside
the deadline window, and control route remains unaffected.

While building the test, two behaviors needed implementation fixes:

- routeTimeoutHandler now sets the write deadline 500ms after the ctx
  deadline (writeDeadlineGrace) so the proxy's default ErrorHandler can
  flush 502 to the client before the conn is severed; without the grace
  ctx and conn-write fire simultaneously and the client sees EOF instead
  of the documented 502.
- Both read and write deadlines are reset to zero in a defer so kept-alive
  connections do not carry expired deadlines into subsequent requests.

Updated the Task 5 deadlines unit test to verify both the arm and the
deferred clear, and the plan file to document the discovered behaviors.
Add provider-specific documentation for the new per-route timeout and
throttle options across file, static, docker, and consul-catalog providers.
Add a "Per-route timeout and throttle" subsection covering global-vs-per-route
precedence, the write-deadline override, and the response-header-timeout
limitation.
Mark Task 10 final steps complete and relocate the plan file under
docs/plans/completed/ now that all implementation, tests, README updates,
and acceptance verification have landed.
Rename local variable in file.List() from d to dur to avoid shadowing
the *File receiver d. The shadowing was harmless in scope (no receiver
calls inside the block) but flagged by smells analysis as a readability
issue inconsistent with the consulcatalog provider, which already uses
dur for the same parsed duration.
- handlers.go: use NUL separator in route limiter cache key to avoid
  collisions when regex source contains pipe characters
- handlers.go: drop unnecessary sync.Once for global limiter; build
  eagerly at handler construction
- handlers.go: log non-ErrNotSupported errors when clearing connection
  deadlines, consistent with the setter side
- handlers.go: expand godoc on limiterUserHandler and routeTimeoutHandler
  to document key composition and connection-deadline side effects
- file.go: replace contradictory "can't parse N: negative duration"
  error wording with the static provider's non-negative phrasing
- consulcatalog.go: move timeout/throttle label parsing below the
  enabled check so disabled services don't emit parse warnings
- README.md: clarify that empty positional fields skip optional values
  rather than inherit non-existent global defaults
- CLAUDE.md: list per-route overrides on the URLMapper summary
Copilot AI review requested due to automatic review settings May 13, 2026 00:13

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

Adds per-route overrides for request timeout and per-user throttling in reproxy’s routing model (URLMapper), allowing operators to tune slow endpoints and sensitive routes without changing global defaults. The change is implemented end-to-end across all discovery providers, enforced in the proxy middleware chain, and covered with unit + integration tests.

Changes:

  • Extend discovery.URLMapper with Timeout and Throttle (zero = inherit global), ensuring extendMapper preserves them.
  • Add provider support for parsing/reading per-route timeout and throttle (file/static/docker/consul-catalog) with provider-appropriate validation behavior.
  • Enforce per-route timeout and per-route throttle in the proxy via new/updated middleware, with unit and integration tests plus README/CLAUDE.md updates.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated no comments.

Show a summary per file
File Description
README.md Documents per-route timeout/throttle syntax for all providers and the response-header-timeout limitation.
docs/plans/completed/20260512-per-route-timeout-throttle.md Adds the completed implementation plan/notes for the feature (design + testing approach).
CLAUDE.md Updates architecture notes to mention the new per-route overrides in URLMapper.
app/proxy/testdata/per_route.yml Adds integration test fixture routes covering timeout/throttle/both/control.
app/proxy/proxy.go Wires routeTimeoutHandler into the middleware chain immediately after route matching.
app/proxy/proxy_test.go Adds end-to-end integration test validating per-route timeout (502) and per-route throttle (429).
app/proxy/handlers.go Implements routeTimeoutHandler and updates limiterUserHandler to support per-route limiter overrides.
app/proxy/handlers_test.go Adds unit tests for routeTimeoutHandler and per-route throttle behavior in limiterUserHandler.
app/discovery/provider/testdata/config.yml Extends file-provider fixture with routes using timeout and/or throttle.
app/discovery/provider/static.go Extends static provider CSV rule format to include optional timeout and throttle fields.
app/discovery/provider/static_test.go Adds static provider test cases for timeout/throttle parsing and validation errors.
app/discovery/provider/file.go Adds YAML parsing for timeout/throttle with validation (non-negative, duration parsing).
app/discovery/provider/file_test.go Extends file provider tests to assert parsed timeout/throttle and error cases.
app/discovery/provider/docker.go Adds docker label parsing for reproxy.<n>.timeout / reproxy.<n>.throttle (warn-and-zero on bad values).
app/discovery/provider/docker_test.go Adds docker provider tests for valid/invalid/negative timeout and throttle labels (including numbered routes).
app/discovery/provider/consulcatalog/consulcatalog.go Adds consul-catalog tag parsing for reproxy.timeout / reproxy.throttle (warn-and-zero on bad values).
app/discovery/provider/consulcatalog/consulcatalog_test.go Adds consul-catalog provider tests for timeout/throttle parsing and bad-value handling.
app/discovery/discovery.go Adds Timeout/Throttle fields to URLMapper and preserves them through extendMapper.
app/discovery/discovery_test.go Adds tests ensuring extendMapper preserves Timeout and Throttle for extension and non-extension routes.

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

@umputun
umputun merged commit d79d713 into master May 13, 2026
10 checks passed
@umputun
umputun deleted the per-route-timeout-throttle branch May 13, 2026 00:20
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