Skip to content

feat(proxy): expose target pool tuning, retry windows and request timeouts - #91

Merged
mhenrixon merged 1 commit into
dashfrom
issue-80-target-tuning
Jul 30, 2026
Merged

mhenrixon merged 1 commit into
dashfrom
issue-80-target-tuning

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

The target connection pool, the retry window, and the missing half of the timeout pair.

proxy:
  request_timeout: 30
  path_request_timeouts:
    /uploads: 600
  target:
    max_conns: 100
    max_idle_conns: 10
    idle_conn_timeout: 90
    dial_timeout: 5
    disable_keep_alives: false
    try_duration: 30
    try_interval: 1
  • lib/kamal/configuration/proxy.rb — a target_options group, request-timeout, and path_timeout_args parameterised to serve both maps.
  • lib/kamal/configuration/validator/proxy.rb — negatives and the retry pairing.
  • lib/kamal/configuration/validator.rb — path_request_timeouts joins the free-form-key branch.
  • test/proxy_flag_coverage_test.rb — issue-80 waiver deleted; all nine flags proven emitted.

Closes #80

Two deadlines, now both reachable

Clock stops when Default
response_timeout → --target-timeout response headers arrive 30s
request_timeout → --request-timeout the whole request finishes, body included 0, no limit

This pair is why the issue exists. A slow trickle of body bytes never trips response_timeout, because the app answered promptly — so an operator who only had the first key watched requests hang past it with nothing to reach for. WebSocket and event-stream responses are exempt from request_timeout.

AC3: .compact was never the hazard — but zeros still are

.compact drops nil only, 0.blank? is false, and seconds_duration(0) returns "0s" because it guards on if value and 0 is truthy in Ruby. Nothing was dropping zeros. The tests pin it regardless — that is what the AC asks for, and it is exactly the invariant a later refactor breaks silently.

What does bite is that a zero does not mean the same thing across these keys. From target_pool.go's resolver:

Key 0 means
max_conns unlimited
try_duration a single attempt
request_timeout no limit
max_idle_conns the proxy's default of 100, not "keep none"
idle_conn_timeout the proxy's default of 90s
dial_timeout the proxy's default of 30s
try_interval the proxy's default of 250ms

e1e5c10 resolves those server-side deliberately — "restoring saved state, rolling out, and older clients over RPC all bypass the CLI" — so the gem passes every value through unchanged rather than substituting defaults of its own. The docs list what each zero does, because an operator writing max_idle_conns: 0 to mean "keep none" gets 100, and the gem cannot fix that without inventing a sentinel the proxy would not understand.

Test plan

  • bundle exec rubocop --parallel — clean, 215 files
  • Unit suite — 1327 runs; 4 failures + 3 errors, all in builder_test/build_test, identical on pristine dash HEAD (host-arch dependent, pass in CI)
  • Generated command inspected by hand — all nine flags plus the two that already existed, correctly distinguished
  • Set request_timeout: 5 and confirm a slow-streaming response is cut off, while response_timeout alone does not cut it
  • Set target.max_conns: 1 and confirm requests queue rather than fail
  • Set target.disable_keep_alives: true and confirm one connection per request against the app
  • Confirm target.try_duration retries an idempotent request onto another target during a rolling deploy

Automated coverage

Test Proves
response_timeout and request_timeout are separate flags AC1
both path timeout maps are built by the same code path AC2 — one builder, not a copy
an explicit zero reaches the proxy rather than being dropped AC3, on every numeric key
an explicit zero survives all the way to the command line AC3 through optionize, not just the options hash
no tuning keys leave the deploy command unchanged AC4 — pure addition
disable_keep_alives false emits nothing matches the proxy default without a redundant flag
try_interval without try_duration / with a zero try_duration mirrors validateRetries, including the zero case
the negative-value tests mirrors TargetOptions.Validate and parsePathTimeouts
every kamal-proxy deploy flag is exposed or waived all nine really wired, waiver gone

Deviations & judgment calls

Deviations

  • None to the key surface — exactly the shape the issue proposed.

Judgment calls

  • request_timeout is rejected when negative even though kamal-proxy does not check it. TargetOptions.Validate covers the pool knobs and validateRetries the retry pair, but nothing guards RequestTimeout. A negative deadline is nonsense, and Go would read it as already-expired.
  • path_timeouts gains negative validation it never had. It is the same method now serving path_request_timeouts; leaving the older caller unchecked would mean one map rejects a bad value and the other does not. In path, per the refactor-depth rule — but it is a new rejection for an existing key, so worth a look.
  • disable_keep_alives: false emits nothing rather than --target-disable-keep-alives=false. The proxy default is already false, so they are identical on the wire and the shorter command reads better in an audit log.
  • path_request_timeouts had to join path_timeouts on the validator's free-form-key branch, and its docs example leads with a Go duration string so that both string and integer values type-check — same trick the existing path_timeouts example relies on.

…eouts

Nine flags with no deploy.yml key, including half of a timeout pair.

    proxy:
      request_timeout: 30
      path_request_timeouts:
        /uploads: 600
      target:
        max_conns: 100
        max_idle_conns: 10
        idle_conn_timeout: 90
        dial_timeout: 5
        disable_keep_alives: false
        try_duration: 30
        try_interval: 1

response_timeout and request_timeout are different deadlines and the docs now
say so side by side: the first stops once response headers arrive, the second
covers the whole request including streaming the body back. Having only the
first reachable was worse than neither -- an operator sets it, watches requests
hang anyway, and has no key for the one that would help.

path_request_timeouts is built by the same path_timeout_args as path_timeouts,
parameterised rather than copied, because kamal-proxy reads both with one
parser. That method now also rejects a negative duration, which it did not
before; leaving the older caller unchecked would have meant one of the two maps
validating and the other not.

On AC3: .compact was never the hazard. It drops nil only, 0.blank? is false, and
seconds_duration(0) is "0s" because it guards on `if value` and 0 is truthy in
Ruby. Nothing was dropping zeros. The tests pin it anyway -- that is what the AC
asks for and it is exactly the invariant a later refactor breaks quietly.

The thing that DOES bite is what a zero means, which is not uniform:

  max_conns: 0        unlimited
  try_duration: 0     a single attempt
  request_timeout: 0  no limit
  max_idle_conns: 0   the proxy's default of 100, NOT "keep none"
  idle_conn_timeout: 0 / dial_timeout: 0 / try_interval: 0   proxy defaults

e1e5c10 resolves those server-side on purpose, because restored state and older
RPC clients bypass the CLI entirely. So the gem passes every value through
unchanged rather than substituting defaults of its own, and the docs list what
each zero does -- an operator writing max_idle_conns: 0 to mean "keep none" gets
100 instead, and no gem-side cleverness can fix that without inventing a
sentinel the proxy would not understand.

Closes #80
@mhenrixon
mhenrixon merged commit 346c0fe into dash Jul 30, 2026
9 checks passed
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.

Expose target connection-pool tuning, retry windows and request timeouts in deploy.yml

1 participant