Add HTTP retry/backoff to ControlplaneApiDirect and remove @@api_token class state (#383)#416
Conversation
…n class state (#383) Retry policy (new nested Retrier): - GET (the only idempotent verb in API_METHODS) retries on transient network errors (Net::OpenTimeout, Net::ReadTimeout, ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT, EOFError, OpenSSL::SSL::SSLError, SocketError), on 429, and on 5xx responses. - POST/PATCH/PUT/DELETE retry only connect-phase failures raised before the request may have hit the wire (connect is a separate http.start step; anything after it is treated as potentially sent). - Net::HTTP's built-in transparent retry is disabled (max_retries = 0) so the Retrier owns the entire retry policy; without this, Net::HTTP silently re-sends GET/PUT/DELETE once on mid-flight failures, which would break the never-re-send guarantee for PUT/DELETE. - open_timeout is bounded to 10s (read_timeout stays at the 60s default) so a retried connect failure fits within the retry window. - Exponential backoff with equal jitter (base 0.5s, doubling, capped at 10s per delay); a delta-seconds Retry-After header overrides the backoff and is capped at 10s. Caps: 3 total attempts and a 120s monotonic deadline -- checked before starting a new attempt, so no new attempt begins past it (an in-flight attempt may run longer, bounded by the per-attempt timeouts). The sleep function is injectable via ControlplaneApiDirect.new(sleeper:) so unit specs run with zero real sleeping. - Only the transport phase (connect + request) sits inside the transient-error rescue: token fetch, header construction, and request building happen before it, so a failure there (e.g. an EPIPE from the `cpln profile token` subprocess) is never misclassified as a retryable network failure. - 4xx other than 429 never retries; response semantics are unchanged (200 parsed JSON, 202 true, 404 nil, 403 ForbiddenError, else the same bare RuntimeError message shape spec_helper's transient-failure matcher relies on). Token provider (new nested ApiToken, replaces @@api_token): - Process-shared default instance held in a class-level ivar (ControlplaneApiDirect.default_token_provider), created at load time, so the fresh ControlplaneApiDirect.new per API call still reuses the cached token instead of re-shelling `cpln profile token`. - Injectable per instance (ControlplaneApiDirect.new(token_provider:)) so specs use fresh providers and need no manual state reset. - All read/load/refresh/reset paths are synchronized behind a Mutex, fixing the missing thread-safety around read+refresh. - Preserves CPLN_TOKEN env priority, profile fallback via Shell.cmd, the line-break token-format guard (same RuntimeError message), and the JWT 5-minute-expiry refresh for profile-sourced tokens. - Shell.cmd success is now checked on both initial fetch and refresh; failures raise ControlplaneApiDirect::TokenRefreshError with an actionable message instead of silently caching garbage output. Preserved seams: - ControlplaneApiDirect.reset_api_token keeps its name and now resets the shared default provider, so Controlplane#profile_switch (lib/core/controlplane.rb:23) still invalidates the cached token on profile change with zero call-site churn. - #call keeps the exact signature (url, method:, host:, body:) used by the verified instance_double seam in spec/core/controlplane_api_spec.rb, and #api_token keeps returning {token:, comes_from_profile:} for lib/command/run.rb. - The `trace` class attr is left as-is: lib/core/config.rb writes it and lib/core/controlplane.rb reads it, so folding it into instance config is not a non-breaking one-file change. The spec file is now fully offline and is added to spec/README.md's documented offline suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 24 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR hardens the direct Control Plane HTTP client. The main changes are:
Confidence Score: 5/5This looks safe to merge after a small retry-classification cleanup.
lib/core/controlplane_api_direct.rb Important Files Changed
Reviews (1): Last reviewed commit: "Add HTTP retry/backoff to ControlplaneAp..." | Re-trigger Greptile |
Review SummaryThis is a solid, well-engineered hardening of What it does
Strengths
Suggestions (non-blocking, left as inline comments)
Other notes (already acknowledged in the PR's decision log, no action needed)
Overall: no correctness, security, or performance blockers found. Good test discipline and careful attention to the subtle failure modes (Net::HTTP's hidden resend, octal parsing, mutex-guarded shared token state). |
Closes #383.
Hardens
ControlplaneApiDirect— the HTTP client behind every Control Plane API call:Retrier): GET retries transient network errors (Net::OpenTimeout/ReadTimeout,ECONNRESET/ECONNREFUSED/EPIPE/ETIMEDOUT,EOFError,OpenSSL::SSL::SSLError,SocketError), 429, and 5xx. Equal-jitter exponential backoff (0.5·2^n capped at 10s);Retry-Afterdelta-seconds honored, capped at 10s; 3 attempts max; 120s deadline (no new attempt begins after it; an in-flight attempt may run past it). POST/PATCH/PUT/DELETE retry only connect-phase failures —http.max_retries = 0disables Net::HTTP's hidden internal resend of PUT/DELETE (verified on a real socket: a PUT RST mid-response was silently re-sent before this change, exactly once after it fails cleanly). 4xx ≠ 429 fails immediately.open_timeout10s so connect-timeout retries are actually reachable within the deadline.@@api_tokenclass variable removed: replaced withApiToken— a mutex-guarded, process-shared-by-default, per-instance-injectable token provider. PreservesCPLN_TOKENpriority, profile fallback, JWT 5-min-expiry refresh, the token-format guard, the{token:, comes_from_profile:}hash shape used bylib/command/run.rb, and thereset_api_tokenseam used bylib/core/controlplane.rbon profile switch (issue's claim that it was test-only was incorrect — it's a production call site).Shell.cmd[:success]and raisesTokenRefreshErrorwith an actionable message instead of silently proceeding with garbage output.class_variables.empty?, no manual state reset anywhere.spec/core/controlplane_api_direct_spec.rbadded to the documented offline suite.Codex Decision Log
TokenRefreshError < StandardError; retry classification keys off exception classes/HTTP status directly.TokenRefreshErrorinto the Replace Shell.abort/exit in library code with typed exceptions handled at the CLI boundary #380 hierarchy when it lands.traceclass attr left as-is (written byconfig.rb, read bycontrolplane.rb) — folding into instance config is not a non-breaking single-file change.call, not per attempt. Worst-case call ≈190s vs the 300s JWT refresh margin — no expiry window.validate!takes a positional hash; mutex held across thecpln profile tokensubprocess (fine for a CLI).Confidence
controlplane_api_spec.rb): 71 examples, 0 failures.@@api_tokenanywhere;reset_api_tokenonly at its definition and the preservedcontrolplane.rb:23call site; rubocop clean with three old disables removed and zero added.spec_helperretry matcher from Scope spec retries to transient failures, enable random ordering, tier timeouts (#382) #415).Batch: CPFLOW B 07-16 15:43 (
cpf-b-20260716-1543), lane issue383, epic #387.🤖 Generated with Claude Code