Skip to content

feat(subscription): use ConnectRPC for subscription/billing clients - #34

Draft
abhisek wants to merge 1 commit into
mainfrom
claude/grpc-error-handling-bug-ciwhnx
Draft

feat(subscription): use ConnectRPC for subscription/billing clients#34
abhisek wants to merge 1 commit into
mainfrom
claude/grpc-error-handling-bug-ciwhnx

Conversation

@abhisek

@abhisek abhisek commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Proof of concept for the intermittent code = Internal desc = server closed the stream without sending trailers error seen on subscription/billing commands.

Root cause (not the gRPC SDK): over the native gRPC protocol, an RPC error's status and rich ErrorDetail travel only in HTTP/2 trailers, and the error response is bodyless. The gRPC proxy in front of control-tower intermittently fails to forward those bodyless, trailer-only error responses, so callers get a transport-level failure instead of the real error. Successful responses are unaffected because their payload rides in the DATA/body frame — which is exactly why this was only ever observed on error cases, and never in the web app (which already uses ConnectRPC).

Fix in this PoC: switch the subscription/billing clients to the ConnectRPC protocol, which carries errors and their ErrorDetail in the response body, sidestepping trailer forwarding entirely.

Changes

  • internal/app/app.go — add ControlPlaneCredentials() to expose the resolved/refreshed token + tenant (a Connect client needs an HTTP client + base URL + headers, not a grpc.ClientConn).
  • internal/cmd/subscription/connect.go (new) — control-plane base-URL resolution (same SAFEDEP_CLOUD_CONTROL_ADDR / cloud.safedep.io:443 / insecure-transport env logic), an auth interceptor injecting the identical authorization + x-tenant-id headers the gRPC per-RPC creds set, and the Connect-backed Service constructor.
  • internal/cmd/subscription/service.goService now wraps the generated Connect clients; requests go through connect.NewRequest, responses read res.Msg, errorReason extracts ErrorDetail from connect.Error.Details().
  • internal/cmd/subscription/flow.go — transient / deadline classification moved to connect.CodeOf / connect.Code*.
  • Six call sites + tests updated to the Connect client and error model; gRPC client imports removed from the package.

Scope

Intentionally limited to subscription/billing so it can be A/B'd against the gRPC path. Other commands still use gRPC, so the two coexist.

How to validate

Run safedep subscription ondemand enable --accept-terms against an environment where it was flapping:

  • main (gRPC): intermittent Internal … server closed the stream without sending trailers.
  • this branch (Connect): the correct on-demand billing needs a paid plan… every time, because the ErrorDetail arrives in the body like a success payload.

Verification

  • go build ./..., go vet ./internal/..., go test ./... all pass; gofmt clean; binary builds and the command tree wires.
  • golangci-lint was not run in the authoring environment (its binary is built with go1.25 vs the repo's go1.26.2 target); worth a local/CI lint pass.

🤖 Generated with Claude Code


Generated by Claude Code

The subscription and billing commands previously spoke native gRPC to the
control plane, where an RPC error's status and rich ErrorDetail travel only
in HTTP/2 trailers. The gRPC proxy in front of control-tower intermittently
fails to forward those bodyless, trailer-only error responses, so callers
saw "code = Internal desc = server closed the stream without sending
trailers" instead of the real error. Successful responses were unaffected
because their payload rides in the body.

Switch these clients to the ConnectRPC protocol, which carries errors and
their ErrorDetail in the response body, sidestepping trailer forwarding
entirely. This mirrors the web app, which uses Connect and never exhibited
the bug.

- Add ControlPlaneCredentials to expose the resolved/refreshed token and
  tenant for building a Connect client.
- Add connect.go: base URL resolution, an auth interceptor injecting the
  same authorization and x-tenant-id headers the gRPC per-RPC creds set,
  and a Connect-backed Service constructor.
- Rewrite Service over the generated Connect clients; extract ErrorReason
  from connect.Error details and classify transient/deadline/not-found via
  connect.Code.
- Update callers and tests to the Connect client and error model.

Scoped to subscription/billing as a proof of concept; other commands still
use gRPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QuAcWAG8UNvgWQW38Kx5Q
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