Skip to content

Add configurable http_timeout so a hung upstream can't block sshd auth (closes #31) - #37

Merged
dolph merged 1 commit into
mainfrom
claude/http-timeout
Apr 27, 2026
Merged

Add configurable http_timeout so a hung upstream can't block sshd auth (closes #31)#37
dolph merged 1 commit into
mainfrom
claude/http-timeout

Conversation

@dolph

@dolph dolph commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Closes #31.

Summary

Adds a global http_timeout config field defaulting to 10s and propagates it to http.Client.Timeout so that a hung or stalled upstream can no longer block sshd's authentication path.

Why

NewHTTPClient previously constructed &http.Client{} with no Timeout. A misbehaving upstream — DNS that resolves but never responds, TLS handshake that hangs, slowloris-style server, anyone with control over a configured source URL — would block the per-source goroutine until the OS-default TCP timeout (typically several minutes). cmd.go:Run waits on every per-source goroutine via wg.Wait() before closing the keyChan, so one stalled source stalls the entire login. PR #10 made HTTP errors non-fatal, but a request that never returns isn't an error — it's just hung.

A timeout firing produces the same error class PR #10 already handles: GetURL logs Failed to fetch ...: context deadline exceeded ... and returns an empty slice. The source quietly contributes zero keys; healthy sources keep producing keys; the rest of the login proceeds normally.

Behavior

Config Behavior
omitted 10s default
http_timeout: 30s 30s
http_timeout: garbage logs warning, falls back to 10s
http_timeout: 0s no timeout (Go semantics — operators who want this can opt in explicitly)

The default of 10s trades a modest "slow but healthy upstream gave up" risk for tight bounds on auth-path latency under partial outages. For a tool sshd consults synchronously on every login that's the right trade; operators wanting different bounds can tune it.

Diff shape

  • config.go: add HTTPTimeout string field, defaultHTTPTimeout = 10 * time.Second, and ResolveHTTPTimeout() mirroring ResolveCacheTTL.
  • httpclient.go: NewHTTPClient now takes (ttl, httpTimeout time.Duration) and sets http.Client.Timeout.
  • cmd.go: passes c.ResolveHTTPTimeout() through.
  • README.md: parallel http_timeout section under Configuration, framing the latency-vs-tolerance trade-off.
  • CHANGELOG.md: bullets in [Unreleased] under Added (the new field) and Fixed (the underlying availability bug).

Tests

  • TestNewHTTPClient_PropagatesTimeout — wiring assertion that the constructor's httpTimeout lands on http.Client.Timeout.
  • TestGetURL_TimeoutIsNotFatal — behavioral test using a server that stalls before responding; verifies GetURL returns an empty slice within ~timeout (not the OS-default), so a hung server can't block the test process either.
  • TestResolveHTTPTimeout — table-driven: empty / unparseable fall back to default; valid 500ms / 30s / 2m parse correctly.
  • TestConfigLoadHTTPTimeouthttp_timeout: 5s round-trips through YAML and resolves to 5 * time.Second.

All run under -race (issue #16 isn't fixed yet, but I ran the suite locally with go test -race). Coverage 39.5% → 42.1%.

Out of scope (call out, don't widen)

Test plan

  • ./build.sh green; coverage 42.1%.
  • go test -race ./... green.
  • TestGetURL_TimeoutIsNotFatal completes in ~100ms (the configured timeout) — no hang.
  • CI shellcheck + build jobs both green on the PR.

https://claude.ai/code/session_013HnepY8MhhxrJJjE5ysW47


Generated by Claude Code

Closes #31.

NewHTTPClient previously constructed &http.Client{} with no Timeout.
A misbehaving upstream (DNS that resolves but never responds, TLS
handshake stall, slowloris-style server, etc.) would block the
per-source goroutine until the OS-default TCP timeout - typically
several minutes. cmd.go:Run waits on every per-source goroutine via
wg.Wait() before closing the keyChan, so one stalled source stalls
the entire login. sshd then waits on ussher's stdout for that whole
duration. PR #10 made HTTP errors non-fatal but doesn't help when
the request never returns at all.

http.Client.Timeout now defaults to 10s and is overridable via a new
http_timeout YAML field that mirrors cache_ttl: parsed by
time.ParseDuration ("500ms", "10s", "30s"); empty or unparseable
falls back to the default with a log message. The default trades a
modest "slow but healthy upstream gave up" risk for tight bounds on
auth-path latency under partial outages, which is the right trade
for a tool sshd consults synchronously on every login.

A timeout firing produces the same error class PR #10 already
covers: GetURL logs "Failed to fetch ...: context deadline exceeded
..." and returns an empty slice, so the failing source quietly
contributes zero keys instead of taking the whole invocation down.

Tests cover both the wiring (NewHTTPClient propagates the timeout to
http.Client.Timeout) and the behavior (a server that stalls before
responding causes GetURL to return empty within ~timeout, not the
OS-default). Both run under -race; the suite stays clean.

README grows a parallel http_timeout section explaining the latency
vs. healthy-upstream-tolerance trade-off, mirroring the cache_ttl
section's framing. CHANGELOG gains Added and Fixed bullets.

Out of scope: per-source http_timeout (mirrors #9 for cache_ttl;
worth a sibling design if/when that lands), and the stale-on-error
fallback from #11 (which would naturally cover the timeout-fired
case alongside other fetch failures - good follow-up).
@dolph
dolph merged commit 5233d3f into main Apr 27, 2026
4 checks passed
@dolph
dolph deleted the claude/http-timeout branch April 27, 2026 20:07
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.

No timeout on http.Client; a hung upstream blocks sshd authentication indefinitely

2 participants