Classify a Ruby read timeout as a network error, not a status error

Merged
JE
2mo ago
+150-14

The fizzy-sdk side of basecamp/basecamp-sdk#874: the Ruby transport was extracted from the same shape, and the same defect is here.

The failure. Faraday::TimeoutError < Faraday::ServerError, so single_request and single_request_raw, which rescue ServerError, ClientError first and Faraday::Error second, caught a stalled read on the status clause and handed it to handle_error, which has no status to map. The caller got a non-retryable APIError reading Request failed (HTTP ) with http_status 0 and no cause, and the retry loop declined to retry it — while NetworkError is defined as retryable and is what every other transport failure becomes. Faraday::ConnectionFailed and SSLError inherit from Faraday::Error directly and already reached the network path; only the read timeout was misrouted, and no test had exercised the read-side class. On main:

Faraday::TimeoutError → Fizzy::APIError code=api_error status=0 retryable=false msg="Request failed (HTTP )" cause=nil

The fix. Both primitives name Faraday::TimeoutError ahead of the status clause and route it, with every other transport failure, through one transport_failure helper: a NetworkError — Request timed out for a timeout, Connection failed for the rest, matching the Go ErrNetwork / TypeScript network / Rust network_timeout classification — retryable, status-less, with the Faraday error as its cause, and reported to on_request_end the same way. A 408 response stays on the status path, since Faraday raises it as a ClientError.

The tests. FizzyTransportFailureTest, through Faraday's test adapter with the raise_error middleware the client installs: a Faraday::TimeoutError is a network, Request timed out, retryable, status-less NetworkError carrying the Faraday error as its cause, and reaches on_request_end with a nil status and the same error; a timeout is retried on GET and the second attempt's 200 is returned; ConnectionFailed keeps its shape; the raw POST path classifies the same and is not retried; a 408 response is still an APIError with status 408. The timeout cases fail on main with Fizzy::APIError where Fizzy::NetworkError is expected.

Also in this PR: the retry budget is floored at one attempt. request_with_retry broke out of its loop before the first send whenever max_retries was 0 and raised Request failed after 0 retries for a request it never made — the same pre-check basecamp-sdk#718 removed from the Go client. max_retries counts total attempts, as the Kotlin and Rust SDKs read it, so the cap is floored at one (no +1): the request always reaches the wire once, no backoff sleep or on_retry follows the final attempt, and the exhaustion message counts attempts. Tests: max_retries: 0 sends exactly once and raises that attempt's own error with no retry hook, and returns a 200 from its single attempt. basecamp-sdk's Ruby loop already floors at one (caller_cap, its #532), so no change there.

Gates run locally: make rb-check (109 runs, 0 failures; rubocop clean), make conformance-ruby (127 passed, 0 failed).

Watching the review loop on this one through to convergence.