Repository navigation
Replies: 2 comments
Revision 1: the open questions answeredThe open questions:
Outbound HTTP clients' revision 1 also reaches this proposal. Its "The other clients" gives The charter needs no revision. Each section below replaces or adds to the part of the same name in the post. Background, The end user's side, Closing, The seam for failure injection, Migration from today and References are unchanged. SummaryThe third bullet, and the bullet on
Proposed API
type Settings struct {
// Addr, TLS, Timeout, MaxAttempts, BackoffBase, BackoffMax,
// RetryBudget, Breaker and BreakerPolicy, unchanged.
// Classify decides how each attempt's result counts: whether another
// attempt follows, and whether the result counts against the
// dependency. It is set in code, not sourced, and one function may be
// set on every Client whose dependencies agree on what their codes
// mean.
//
// Classify gets the RPC's full method name, such as
// "/cart.v1.CartService/AddItem", the error the attempt ended with, nil
// for OK, and the attempt's trailer, which holds grpc-retry-pushback-ms.
// status.FromError gives the error's code and its details, such as a
// google.rpc.RetryInfo. It isn't asked about what the Client decides
// itself: a rejection by the breaker, and an error once the call's
// context has ended.
//
// For a stream, it is asked about an open that failed, with no trailer,
// and about the end of each stream that opened, with the stream's
// trailer. Only the Breaker of a stream's end counts: an open stream is
// never retried.
//
// It is called on the goroutine that made the call, or, for a stream's
// end, the goroutine whose RecvMsg ended it, once for each attempt, so
// it must be safe for concurrent use. A Classification with a zero
// Outcome, or with retry.Retry and a negative RetryAfter, panics, as
// bedrock's classifiers do.
//
// Default: the classification under "How an attempt's result counts".
// Valid: set, which NewClient checks.
Classify func(method string, err error, trailer metadata.MD) Classification
}
// Classification is how one attempt's result counts, as Settings.Classify
// decides it.
type Classification struct {
// Retry is whether another attempt follows. retry.Success and
// retry.Stop end the call. retry.Retry asks for another attempt, which
// MaxAttempts, the budget and the call's deadline must all allow.
Retry retry.Outcome
// RetryAfter is, with retry.Retry, the least time to wait before the
// next attempt, such as the server's pushback, or 0. A RetryAfter
// longer than BackoffMax isn't waited for: the call ends, and returns
// the attempt's error.
RetryAfter time.Duration
// Breaker is how the result counts in the dependency's circuit
// breaker: circuit.Success, circuit.Failure, or circuit.Ignored for a
// result that says nothing about the dependency.
Breaker circuit.Outcome
}
// Connected returns a monitor that is healthy once the Client has
// connected to Addr: unhealthy until its connection is first ready, its
// TLS handshake done, and healthy from then until the connection is
// closed. A later outage doesn't make it unhealthy. It never returns an
// error. A service is made ready once its dependency is reachable with
//
// health.Readiness(client.Connected())
//
// Until the connection is first ready, each check starts it, if no RPC
// has, and waits until it is ready or the check's context is done. It
// makes no RPC. It is for readiness only: restarting the pod doesn't bring
// a dependency back.
func (c *Client) Connected() health.Monitor
Dialling and namesThese are added after "The Host resolves the name": A name spreads load as an address does. Passthrough hands grpc-go
It is what Client-side load balancing wouldn't help a ClusterIP Service. Its name has one address, whichever resolver looks it up. Spreading RPCs across a headless Service's pods would need grpc-go told every address, which only a lookup on the Host could give it, and that is out of scope. The last paragraph of "Spreading the load" becomes: The docs page for this package says so, with an example of TLSThe first paragraph's last sentence, "Open question 2 asks whether to require it instead, as humus can't control what a service connects to, so plain text is allowed, as RetriesThe code becomes: func (c *Client) Invoke(ctx context.Context, method string, args, reply any, opts ...grpc.CallOption) error {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
var err error
for a := range c.retrier.Attempts(ctx) {
var trailer metadata.MD
r := c.attempt(ctx, method, &trailer, func(ctx context.Context) error {
return c.cc.Invoke(ctx, method, args, reply, append(opts, grpc.Trailer(&trailer))...)
})
a.Done(r) // retry's classifier reads r's Classification
err = r.err
}
return err
}
// attempt makes one attempt through the breaker, and classifies its
// result once, for both retry and circuit.
func (c *Client) attempt(ctx context.Context, method string, trailer *metadata.MD, call func(context.Context) error) *result {
t, err := c.breaker.Allow(ctx)
if err != nil {
return c.rejected(ctx, method, err) // a failed span, status Unavailable, and Stop
}
defer t.Release()
err = call(ctx) // failure injection's seam, in wave 5
r := &result{err: err, class: c.classify(ctx, method, err, *trailer)} // the Client's own rules, then Settings.Classify
t.Done(r) // circuit's classifier reads r's Classification
return r
}
"What is retried" and its table are replaced by How an attempt's result counts, below. Of its bullets:
The other bullets, and grpc-go's own retries, are unchanged. How an attempt's result countsThis section is new. It replaces "What is retried" under Retries, and "What counts" under The circuit breaker. Every attempt's result is classified twice: for What the Client decides.
And whatever
What
A
Changing it. The end user's d := grpcclient.DefaultSettings()
classify := d.Classify
settings.Classify = func(method string, err error, trailer metadata.MD) grpcclient.Classification {
if status.Code(err) == codes.Aborted {
// The ledger aborts a write that races another write to the same
// account. That write is over by the next attempt.
return grpcclient.Classification{Retry: retry.Retry, Breaker: circuit.Success}
}
return classify(method, err, trailer)
}An API that sends The pushback. The circuit breaker"What counts" and its table are replaced by How an attempt's result counts, above. Its paragraph on StreamsThe bullets "Opening is retried" and "The breaker counts the stream's end" become:
TelemetryThe bullet on Metrics becomes:
Readiness:
|
| Field | Default | Valid | Check |
|---|---|---|---|
Classify |
the table under How an attempt's result counts | set | NewClient; set in code, not sourced |
These bullets are added:
TLSis nil for plain text, askafka.Settings.TLS,telemetry.OTLPSettings.TLSandhttpclient.Settings.TLSare. humus can't control what a service connects to: a mesh's sidecar, a vendor's agent, a local emulator. Every humus server requires TLS, so a call to one always sets it.Classifyis set in code, likeBreakerPolicy. What a dependency's codes mean is the end user's knowledge of their API, not an operator's choice.NewClientrejects a nilClassify, which would otherwise panic on the first call.
Design decisions
These bullets become:
- A
Clientof its own, not a*grpc.ClientConn. The retries and the breaker sit inInvokeandNewStream, outside everything inopts, whatever interceptor options the end user passes. A*grpc.ClientConnwith humus's interceptors would let agrpc.WithUnaryInterceptorinoptsrun outside them, because grpc-go puts the single interceptor ahead of the chained ones. A*grpc.ClientConnalso has aClosethat one Service could call under the others. And a type of humus's own carries methods that fit it into the rest of humus, of whichConnectedis the first. - Passthrough, not a resolver. It is the one way grpc-go reaches only the Host. It is what
grpcservertestand telemetry already do, so production and tests dial the same way. A name spreads load as an address does, and asgrpc.NewClientwith an address does. Rejected: aLookupHostonhumus.Host, with a resolver of the Client's own andround_robin. It changes every Host, adds a resolver to maintain, and helps only a headless Service, since a ClusterIP Service's name has one address.MaxConnectionAgewould be needed with it as well, because grpc-go re-resolves only when a connection is lost. - Plain text is allowed. humus can't control what a service connects to.
Connectedcatches a missing TLS config to a server that requires one before the release rolls out. - The classification is a function,
Classify, from the start. A gRPC API picks its own codes, such asAbortedfor a conflict a retry resolves, orgoogle.rpc.RetryInfowhere another sends a pushback, so nearly every Client ends up classifying its own dependency. The default keeps a retried call to what gRPC defines as safe to try again. The Client keeps what is about the call, not the API: the breaker's rejections, the caller giving up, deadlines and an open stream, so aClassifycan't defeat the breaker or replay a stream.Classifygets the method, so a method that must never be sent twice needs no per-method table inSettings. Rejected: a fixed table, with a field added when users ask. Every Client of an API that differs would wait for it. - No instruments of humus's own in wave 4.
otelgrpc's per-attempt duration already counts every attempt and its status, and a breaker's transitions are rare enough to be log records. Whether humus adds instruments of its own is decided for every package at once, by Proposal: humus's own metrics (wave 5).
The bullet "Unavailable is retried for every method, and nothing else is without a pushback" is replaced by the bullet on Classify above.
These are added:
Connectedlatches. It gates a rollout on a working configuration, and never takes every pod out of service during an outage that a restart or a rollback can't fix. Rejected: a monitor that turns unhealthy whenever the connection isn't ready. It makes every pod unready during a dependency's outage, flaps on grpc-go's reconnects, and blocks a rollout that might be the fix. Rejected too: a monitor that calls the dependency'sgrpc.health.v1, which the end user writes in a few lines over the Client, and which an open breaker would turn unhealthy.Connectedis API because the end user can't build it. The Client hides its*grpc.ClientConn, so its connection's state is the Client's to report, as a consumer's join iskafka.Service's. It is the only client with a monitor:httpclient.NewClientreturns an*http.Client, which pools many connections and has no one state to report.
The "Changes elsewhere" bullet's list becomes:
- the docs site gets a page, "Calling gRPC services", next to the gRPC services page. It covers settings, TLS and plain text, names and addresses behind a ClusterIP or a headless Service, retries, the classification table and what the Client decides itself, writing a
Classify, the pushback, the breaker, streams, telemetry,Connectedand a monitor of the dependency'sgrpc.health.v1, spreading load withMaxConnectionAge, and testing; - the gRPC services page gets a short section on
keepalive.ServerParameters{MaxConnectionAge}, pointing to the client page; - the health page's readiness section gets
Connectedas a dependency's readiness monitor, and why it latches; - the lifecycle page's "Ambient leaks" gets the system roots and
SSL_CERT_FILEandSSL_CERT_DIR; - the testing page gets a client next to a server on one Host;
grpcservertest's doc gets a sentence pointing production code togrpcclient.
Testing
Connected was prototyped in a scratch package of the grpc module, at humus 4b786e46 with grpc-go v1.84.0. Its tests passed under -race -count=3, each inside a testing/synctest bubble on a humustest Host, with a CA generated for the test, against a grpcserver.Service:
- Nothing listening on
Addr. The connection is idle until the first check, which starts it, and returns false and no error when its 2s context ends, after 2s of fake time. The Client's stats handler sees no RPC. - A server listening. The first check returns true at once, with no RPC made.
- It latches. Once the server shuts down, its GOAWAY ends the connection, and the check still returns true.
- An untrusted server. With
RootCAsthat don't trust the server's certificate, the connection stays in transient failure, and the check returns false. - Plain text to a TLS server. With a nil
TLS, against the same server, the connection stays in transient failure, and the check returns false. - Closed. After the connection is closed, the check returns false.
Classify was not prototyped. These bullets are added to what the stories add:
- An end user's
Classify. One that retriesAbortedand hands the rest to the default makes a call answeredAbortedand thenOKsucceed, after two attempts. One that returnsretry.Stopfor one method makes that method'sUnavailablefail after one attempt, while another method's is retried. - What the Client decides stays the Client's. A
Classifythat returnsretry.Retryandcircuit.Failurefor everything is never asked about a rejection, so the open breaker's calls aren't retried. Calls whose caller cancels them leave the breaker closed. A server-streaming RPC that fails after a message is tried once, even whenClassifyreturnsretry.Retryfor its status. - A
RetryAfterbeyondBackoffMax, returned by an end user'sClassify, ends the call at once, after one attempt. - A stream's end.
Classifygets a stream's final status with its trailer, and itsBreakeris what the breaker counts. NewClientrejects a nilClassify, naming the field, next to its other errors.- A panic in
Classifyreaches the caller, and leaves the breaker consistent. Connectedin an App. AnInitthat returns a health Service withhealth.Readiness(client.Connected())is unready while nothing listens onAddr, and ready once the backend serves.
Open questions
All three are answered above. None are left.
Out of scope
These bullets become:
- Client-side load balancing, other resolvers, and xDS, including a lookup on
humus.Host. - The health of a dependency, beyond
Connected. No client serves or feeds health on its own, and an open breaker doesn't make the pod unready, as the charter's revision 7 sets out. A monitor that calls the dependency, such as itsgrpc.health.v1service, is the end user's. - Retrying a stream once it is open, and hedging. A per-method retry table of humus's own is replaced by
Classify, which gets the method. - Instruments of humus's own, such as a gauge of the breaker's state: Proposal: humus's own metrics (wave 5).
This is added:
- Classifiers for well-known APIs, and helpers to build one. A
Classifyis the end user's function.
Closing: outbound gRPC clients have landedThe revision's stories are done:
The revision left no open questions. One address gets one connection, picked first; plain text is allowed; and the end user gets a The rest is out of scope or belongs to other proposals, as "Out of scope" lists:
|
Uh oh!
There was an error while loading. Please reload this page.
Note
Part of the humus re-evaluation; see Proposal: humus on the bedrock process model, wave 4. It builds on wave 1 (the lifecycle, configuration, OpenTelemetry without globals and humustest) and on Proposal: gRPC services: its
grpcmodule, and the passthrough resolver and Host dialler thatgrpcservertest.NewClientuses. It is one of wave 4's three client proposals, with outbound HTTP clients and producing to Kafka, and depends on neither. It blocks Proposal: failure injection (wave 5), which adds an injection point at the seam this proposal names.Summary
A new package,
grpcclient, in thegrpcmodule, calls gRPC services from a humus service. The end user'sInitbuilds a client fromSettings, and generated code takes it:grpcclient.Clientis agrpc.ClientConnInterfaceof humus's own, around a*grpc.ClientConn. It is not ahumus.Service. It closes its connection withs.OnStop, after every Service has stopped.s.Host. The target ispassthrough:///andSettings.Addr, and the dialler iss.Host.Dial, as ingrpcservertest.NewClientand telemetry's OTLP connection. The Host resolves the name when it dials. grpc-go'sdnsresolver, which goes around the Host and crashes a synctest bubble, is never used.retry, and every attempt goes through bedrock'scircuit.Unavailableis retried, and so isResourceExhaustedwhen the server sendsgrpc-retry-pushback-ms. The pushback is the least delay. grpc-go's own configurable retries are turned off, and its transparent retries, of RPCs the server never received, are kept.s.Telemetryalone:otelgrpc's client stats handler gives each attempt a client span and the RPC client metrics. A call the breaker rejects gets a failed span of its own, and each change of the breaker's state is one log record.Settingsholds the address, the TLS config (nil means plain text), a timeout for each unary call, and the retry and breaker settings, typed with bedrock'sretry.BudgetConfigandcircuit.Config.Background
grpcpackage hadNewApiandRegisterServicefor servers only. The only*grpc.ClientConnin the tree was the OTLP exporters' connection,grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))(rest/rest.go#L358-L364). That uses thednsresolver and the real network, and its exporters used OTel's globals.dnsresolver (internal/resolver/dns/dns_resolver.go). Whatever dialler the client has, it:net.DefaultResolver, which reads/etc/resolv.confand/etc/hostsfrom the real filesystem and queries the real network, or, given a DNS server in the target's authority, dials that server with a plainnet.Dialer;_grpc_config.TXT record for a service config, unless the real environment setsGRPC_ENABLE_TXT_SERVICE_CONFIG=false;dns.SetResolvingTimeoutanddns.SetMinResolutionInterval, which humus never sets;testing/synctestbubble, crashes the test binary with "fatal error: send on synctest channel from outside bubble", asgrpcservertest's doc records.grpcservertest.NewClientdoes this for tests, and telemetry's OTLP connection does it in production. Proposal: gRPC services left outbound clients to this proposal: "grpcservertest.NewClientis for tests only."retryandcircuitas models with no I/O. Each leaves the protocol to humus.retry's doc says it does not "replay request bodies or parse Retry-After or gRPC pushback, which belong to the framework that wraps a transport", and ships no classifiers.circuitships no transports, policies, classifiers or observers (circuit, retry).retryandcircuitin the three client proposals. It holds them to three points: each dials throughSetup.Hostand reports throughSetup.Telemetry, each composesretryandcircuitas bedrock's docs do, and each names the seam failure injection uses. For gRPC it names "interceptors that readgrpc-retry-pushback-ms". It also says this proposal "decides how a client resolves names through theHost".Proposed API
The package is in the nested
grpcmodule, next togrpcserver. Its two new dependencies are bedrock'sretryandcircuitmodules at v0.24.0. Both import only the standard library and OpenTelemetry.otelgrpcand grpc-go are already in the module.The end user's side
readFileandclientTLSare the end user's helpers, as inhttpserver's andgrpcserver's docs:clientTLSbuilds a*tls.ConfigwhoseRootCAsholds the CA it read. The end user'sInitsources the fields it wants an operator to change. The rest keepDefaultSettings().Dialling and names
NewClientcallsgrpc.NewClient("passthrough:///"+settings.Addr, ...)with:grpc.WithContextDialer, callings.Host.Dial(ctx, "tcp", addr). With a dialler of its own, grpc-go also uses no HTTP CONNECT proxy, soHTTPS_PROXYis never read from the real environment.grpc.WithNoProxy()is passed as well, as telemetry's connection does;grpc.WithDisableRetry(), under Retries below.The Host resolves the name. In
main,humus.OSHost()'sDialis anet.Dialer's, which looks the host up asnet.Dialdoes, each time it dials. In a test, humustest's Host compares names as written. A name that exists only on the test Host works, inside a bubble or out.One connection, picked first. The passthrough resolver hands grpc-go one address, so the Client has one subchannel and grpc-go's default
pick_firstpolicy. Every RPC goes over one HTTP/2 connection to whichever backend that dial reached. When the connection breaks, grpc-go dials again through the Host, with its own connection backoff, which starts at 1s and grows to 120s. That backoff is grpc-go's, andgrpc.WithConnectParamsinoptschanges it.Spreading the load. Behind a Kubernetes ClusterIP Service, each dial reaches one pod, chosen by kube-proxy, and stays there. Two things move a client to another pod:
grpcserver'sShutdownsends every client a GOAWAY, so a rollout moves them;keepalive.ServerParameters{MaxConnectionAge: ...}ingrpcserver.NewService's options sends a GOAWAY when a connection reaches that age. The client then dials again, and reaches whichever pod kube-proxy picks. gRFC A9 addedMaxConnectionAgefor this kind of rebalancing.The docs page for this package says so, with an example. Spreading RPCs across every backend at once needs client-side load balancing, which needs a name resolved to several addresses. That is open question 1.
:authorityand the certificate. grpc-go takes the authority from the target's endpoint, so RPCs carryAddr, and TLS verifies the server's certificate forAddr's host, asgrpcservertest.NewClientdoes.TLS
Settings.TLSnil means plain text, askafka.Settings.TLSand telemetry'sOTLPSettings.TLSmean. A client's peer decides whether TLS is needed. Every humus server requires it, but a humus service also calls services humus doesn't run: one behind a mesh's sidecar, a vendor's agent, a local emulator. Open question 2 asks whether to require it instead, asgrpcserverdoes.CheckTLSaccepts nil, and rejects a config whoseMaxVersionis below TLS 1.2, which HTTP/2 requires. A config needs no certificate: a client certificate is only for mTLS.credentials.NewTLSclones the config, so the end user's is never changed. grpc-go addsh2to ALPN and enforces it.A config with
RootCAsnil verifies the server against the system's roots, whichcrypto/x509reads from the real filesystem, and fromSSL_CERT_FILEandSSL_CERT_DIRin the real environment. That is an ambient leak of every TLS client,kafka's included, and the lifecycle page's "Ambient leaks" lists it. A test never relies on system roots: it setsRootCAstohumustest.CA.Pool().Retries
Invokeis bedrock's loop, with the call through the breaker in a function of its own, asretry's doc composes them:What is retried. The retry classifier sees each attempt's error:
Successcircuit.ErrRejected)Stopgrpc-retry-pushback-msnegative or not a numberStop: the server asked not to retryUnavailableRetryResourceExhaustedwith a pushback of 0 or moreRetryResourceExhaustedwithout a pushback includedStopUnavailableis gRPC's code for "try again". It is what grpc-go returns when it can't reach the server, and whatgrpcserverends streams with at shutdown. It is the code gRPC's own retry examples list.ResourceExhaustedis retried only when the server says when. Without a pushback it is as likely to be a quota as an overload, and a quota isn't fixed by trying again a few hundred milliseconds later.grpc-retry-pushback-msis read from the attempt's trailers, and is the least delay passed back toretry, so the wait is the larger of it and the schedule's. A negative or malformed value means "don't retry", as A6 says.retrydecidesExpiredfor aRetryand takes no token.Timeoutis applied to the context before the loop, so it bounds the whole call, its waits included, and a caller's earlier deadline wins.Unavailableafter acting on a request breaks gRPC's meaning of the code. A call that must never be retried is made through a Client withMaxAttempts1.grpc-go's own retries. grpc-go retries an RPC when a service config gives its method a
retryPolicy, with its own throttling and pushback. The Client passesgrpc.WithDisableRetry(), so no service config, from the end user'sgrpc.WithDefaultServiceConfigor any other source, turns them on beside humus's. Each RPC is retried by one of them, never both, as the charter requires. humus's are the ones kept, because only they let each attempt go through the breaker, as bedrock composesretryandcircuit. grpc-go's retries run inside a singleInvoke, where a breaker would count one result for several attempts, and couldn't stop a retry withErrRejected.WithDisableRetryleaves grpc-go's transparent retries on. grpc-go retries an RPC by itself when no data reached the wire, or when the server reports it never processed it, such as a stream refused by a server that sent a GOAWAY. Those are part of one attempt, and safe whatever the method, so the Client keeps them.The circuit breaker
One per Client. A Client is one dependency, one
Addr, so it has onecircuit.Breakerand oneretry.Budget, built inNewClient, shared by every method and every goroutine. An end user who wants separate breakers for two gRPC services on one address builds two Clients.What counts. The breaker's classifier sees each attempt's error, with the call's context:
SuccessCanceledIgnored: the caller gave upUnavailable,DeadlineExceeded,ResourceExhausted,Internal,Unknown,DataLossFailureNotFoundorInvalidArgumentSuccess: the server is up and answeringDeadlineExceededcounts whether the deadline was the Client'sTimeout, the caller's own, or one the caller inherited from an inbound request. A dependency that hangs ends every call this way, so a breaker that ignored deadlines would never open on one. That is the classifiercircuit's doc gives. An inbound deadline that is too short to call the dependency in counts against it too.The policy.
DefaultSettings().BreakerPolicyis the policy above, the one incircuit's doc. It is a field set in code rather than settings, because a policy's shape, which counts matter, is a design decision, asqueue.Deliveryis. An operator tunes the numbers that are settings: the window, the probes and the two periods. An end user who wants a policy of their own sets the field, and one who wants no breaker sets aTripthat never trips.The default's ratio rule needs 20 results, which is the default
Window. With aWindowbelow 20, only its rule of 5 failures in a row trips it. The field's doc says so, and an end user who lowers the window sets a policy to match.A rejected call. An attempt the breaker rejects fails with an error that matches
circuit.ErrRejectedwitherrors.Is, and whose gRPC status isUnavailable, with the rejection's message, such as "circuit: call rejected: open for another 4.2s". Sostatus.Code(err)isUnavailable, as for an unreachable server, and a caller that needs to tell them apart useserrors.Is. The error type is unexported. The rejection is classifiedStop, so it isn't retried at once, which would defeat the breaker.Streams
NewStreamruns the same loop withMaxAttemptsand the backoff, but notTimeout, since a stream may rightly live as long as its caller wants. Each attempt opens the stream through the breaker:NewStreamfails before anything reaches the server: no connection, the breaker rejecting it, the channel in transient failure. So anUnavailablethere is retried as for a unary RPC.retry's rule that a call is committed once its result is handed to its caller.RecvMsgreturns an error, withio.EOFcounted as a success, or, for a client-streaming RPC, whenRecvMsgreturns its one response. A stream its caller abandons without reading to the end, or cancelling its context, never settles its ticket. That costs nothing while the breaker is closed, and holds a probe slot for at mostProbeTimeoutwhile it is half-open, ascircuit's doc describes.Telemetry
The Client takes everything from
s.Telemetryand never reads or sets OTel's globals. The first of humus's dial options isotelgrpc's client stats handler:Invoke. So each attempt gets one client span, named after the method, such ascart.v1.CartService/AddItem, with the attributes of OpenTelemetry's RPC conventions, among themrpc.system.name,rpc.methodandrpc.response.status_code. The attempts of one call are siblings, children of the caller's span. The propagator injects the trace context and the baggage into each attempt's metadata, so the server joins the caller's trace.otelgrpcwould, withrpc.response.status_codeset toUNAVAILABLE, and status Error with the rejection's message.otelgrpcrecordsrpc.client.call.durationfor each attempt. The Client records no metrics of its own for retries or the breaker. A retry shows as an extra attempt, and a breaker's state as its log records.s.Telemetry.LoggerProvider.Logger("github.com/z5labs/humus/grpc/grpcclient"), with the OTel Logs API. Each change of the breaker's state is one record, at WARN when it opens and INFO otherwise. Its body is "circuit breaker open", "circuit breaker half-open" or "circuit breaker closed", and it is emitted in the context of the call that made the change, so it carries that call's trace. Its attributes areserver.addressandserver.port, fromAddr,circuit.previous_state, and the counts of the state it left:circuit.successes,circuit.failures,circuit.consecutive_successes,circuit.consecutive_failuresandcircuit.timed_out.circuitdelivers transitions one at a time, in order, and outside its lock, so the observer needs no lock of its own.grpc-go's own logger, and the
GRPC_variables of itsinternal/envconfig, are read from the real environment, asgrpcserver's doc already records. So isOTEL_SEMCONV_STABILITY_OPT_IN, whichotelgrpcreads. The client adds nothing new to that list.Closing
NewClientregisterscc.Closewiths.OnStop. OnStop functions run after every Service has stopped, so no Service is still calling, and within the grace period. An RPC made after the close fails withCanceledand "grpc: the client connection is closing".Clienthas noCloseof its own, so the end user's code can't close a connection that other Services share.NewClientdials nothing, asgrpc.NewClientdoesn't. A dependency that is down when the service starts doesn't failInit. Its RPCs fail, are retried, and open the breaker, which the logs show. A service that must not start without its dependency calls it inInit.Settings
Addrhost:port, a host, a port from 1 to 65535CheckAddrTLSnil, plain textnil, orMaxVersionTLS 1.2 or later, or noneCheckTLSTimeout10sCheckTimeoutMaxAttempts3CheckMaxAttemptsBackoffBase100msCheckBackoffBaseBackoffMax5sBackoffBaseCheckBackoffMax, andNewClientRetryBudget.MaxTokens10retry.TokenLimitCheckRetryBudgetMaxTokensRetryBudget.TokenRatio0.1retry.TokenLimitCheckRetryBudgetTokenRatioBreaker.Window20circuit.MaxCountCheckBreakerWindowBreaker.Probes1circuit.MaxCountCheckBreakerProbesBreaker.OpenFor10sCheckBreakerOpenForBreaker.ProbeTimeout10sCheckBreakerProbeTimeoutBreakerPolicyNewClientAddrhas no default, likegrpcserver.Settings.Addr: humus imposes no port.CheckAddrrejects an empty host too, whichgrpcserver's accepts: a client dials a host, it doesn't listen on every name. It rejects a scheme such asdns:///, since the Client never resolves names itself.Timeoutmust be positive. A zero would mean no deadline, and an RPC with none can hang forever, which no breaker ever counts. A caller's earlier deadline wins, but a later one doesn't extendTimeout: an end user whose calls need longer sets the field.retry.BudgetConfigandcircuit.Config, so their meaning is bedrock's documentation. Each leaf field with a range has a check of its own, named after its path, so an operator's bad value fails startup with exit 78, naming the variable, like any other field.retry.Exponential(100ms, 5s), gRFC A6's example budget, andcircuit's example policy.The seam for failure injection
Proposal: failure injection (wave 5) adds an injection point to every client's calls. Its seam here is the single attempt, inside the breaker: in
attempt, afterAllowadmits it and beforec.cc.Invoke, and, for streams, beforec.cc.NewStream. An injected abort is then an attempt's error like any other. The breaker counts it, the retry classifier decides on it, and its span records it, so a test of an injected fault sees what a real one would cause. An injected delay sits insideTimeoutand the caller's deadline.The attempt's context is also where wave 5 strips scenarios from calls to third parties.
otelgrpcinjects the propagator's baggage into the attempt's metadata when the attempt starts, so a context stripped beforec.cc.Invokecarries no scenario across. The wave-5 proposal designs both, and whatever configures them, and its stories add them here.Design decisions
The package is
grpcclient, in thegrpcmodule. The charter puts outbound gRPC clients there, so an end user who doesn't use gRPC keeps grpc-go out of their module graph. It mirrorsgrpcserver: the client half, not the protocol. A package namedgrpcwould clash with grpc-go at every call site.A
Clientof its own, not a*grpc.ClientConn. The retries and the breaker sit inInvokeandNewStream, outside everything inopts, whatever interceptor options the end user passes. A*grpc.ClientConnwith humus's interceptors would let agrpc.WithUnaryInterceptorinoptsrun outside them, because grpc-go puts the single interceptor ahead of the chained ones. And a*grpc.ClientConnhas aClosethat one Service could call under the others. Open question 3 asks whether to return the*grpc.ClientConnanyway.Passthrough, not a resolver. It is the one way grpc-go reaches only the Host. It is what
grpcservertestand telemetry already do, so production and tests dial the same way. Open question 1 covers what it leaves out.TLSnil means plain text. See TLS above, and open question 2.humus's retries, not grpc-go's. Only they put each attempt through the breaker. grpc-go's transparent retries stay, because they never repeat what a server saw.
Unavailableis retried for every method, and nothing else is without a pushback. It keeps a retried call to what gRPC defines as safe to try again, without a per-method table to maintain.Streams are retried only while they open. See Streams above.
The policy is code, the numbers are settings. See The circuit breaker above.
No metrics of humus's own.
otelgrpc's per-attempt duration already counts every attempt and its status, and a breaker's transitions are rare enough to be log records. A gauge of the breaker's state is out of scope.NewClientdoesn't dial. A dependency's outage is the breaker's to handle, not a crash loop's.kafka.NewServiceopens no connection either.No test package. A test runs the end user's whole App, its client included, against a server on the same humustest Host, as Testing shows.
grpcservertest.NewClientstays the way a test calls a Service directly.Changes elsewhere, made by this proposal's stories:
MaxConnectionAge, and testing;keepalive.ServerParameters{MaxConnectionAge}, pointing to the client page;SSL_CERT_FILEandSSL_CERT_DIR;grpcservertest's doc gets a sentence pointing production code togrpcclient..claude/backlog.jsonalready verifies thegrpcmodule.Testing
A prototype of the Client, under 300 lines, ran these tests under
-race -count=5in a scratch module. It used grpc-go v1.84.0,otelgrpcv0.72.0, bedrock'sretryandcircuitv0.24.0 and the humus checkout at4289e89a, and every test passed. The prototype is not kept. Every test runs inside atesting/synctestbubble, on humustest's Host, with a CA generated for the test, and asserts withtestify/require. Fake time makes the backoff, the pushback andOpenFortake no real time.The end user's whole App: an
Initthat serves a backend withgrpcserver, builds a client of it, and runs a job that calls it.backendis agrpc_testing.TestServicewhoseEmptyCallanswers withanswer, and counts its calls. When the job returns, the App shuts down, and the Client's OnStop function closes its connection inside the bubble. A connection left open would be reported as a leak.The prototype's tests also showed the following:
ResourceExhaustedwithgrpc-retry-pushback-ms: 2000is retried, and the call takes at least 2s of fake time.Unavailablewithgrpc-retry-pushback-ms: -1is tried once.MaxAttempts1 and a backend that answersUnavailable, five calls fail and the sixth is rejected without reaching the backend. Its error matchescircuit.ErrRejected, and its status code isUnavailable. After 10s of fake time and three successful calls, the scope's log records are WARN "circuit breaker open", INFO "circuit breaker half-open" and INFO "circuit breaker closed". The trace has nine client spans: five failures, the rejection's own span and three successes.io.EOF.Unavailableafter its retries, and the App still exits 0.The stories add:
NewClientnames each bad field and reports them together.DefaultSettings()with anAddrpasses. A nil policy function, and aBackoffMaxbelowBackoffBase, are rejected. The package never reads abedrock.Environment.Timeout. A backend blocked on its context fails the call withDeadlineExceededafter 10s, which the breaker counts. A caller's earlier deadline wins.TLSreaches a plaintext server. A config with a client certificate reaches agrpcserverwhose TLS setsClientAuth.opts. Agrpc.WithTransportCredentials(insecure.NewCredentials())inoptshas no effect. A chained interceptor inoptsruns once per attempt. Agrpc.WithDefaultServiceConfigwith aretryPolicydoesn't add retries.The package's example is an
Examplefunction in thegrpcmodule, and the package is held to the 80% coverage gate.Migration from today
grpc.NewClientthemselvesgrpcclient.NewClient(s, settings, opts...)grpc.NewClient(addr, ...), with thednsresolver and the real networkpassthrough:///andSettings.Addr, dialled throughs.Hostgrpc.WithTransportCredentials(insecure.NewCredentials())Settings.TLSnilretryPolicyin a service configMaxAttempts,BackoffBase,BackoffMaxandRetryBudget; grpc-go's configurable retries are offotelgrpcon OTel's globals, if at allotelgrpc's client stats handler withs.Telemetrydefer conn.Close()inmains.OnStop, registered byNewClientgrpcservertest.NewClientOpen questions
Spreading RPCs across backends. The Client dials one address and keeps one connection, as proposed. That spreads load only per connection, so with few client pods and many server pods, most servers sit idle until
MaxConnectionAgeor a rollout moves the clients. The options are:pick_first, and documentMaxConnectionAgeon the server, as proposed;LookupHost func(ctx context.Context, host string) ([]string, error)tohumus.Host.OSHostwould usenet.DefaultResolver.LookupHost, and humustest's Host would answer from a table a test gives it, with a name not in the table resolving to itself.grpcclientwould get a resolver of its own on it, with aSettingsfield forround_robin, so a headless Service's pods each get a connection. This changes the root package'sHost, which every Host literal must then fill. This proposal's stories would make the change inhumus,humustestand the lifecycle docs. It also adds a resolver to maintain, with its re-resolution timing;net.ResolverwithPreferGothat dials its DNS server through the Host. This isn't workable: it still reads/etc/resolv.conffrom the real filesystem, queries over UDP, which the Host doesn't carry, and has no DNS server to reach on a test Host.Recommendation: (a). It needs no change to
humus.Host, and production and tests dial the same way.MaxConnectionAgeis needed with (b) as well, because grpc-go re-resolves only when a connection is lost. With (a), client-side load balancing is out of scope.TLS.
nilmeans plain text, as proposed, matchingkafka.Settings.TLSand telemetry'sOTLPSettings.TLS? Or required, asgrpcserverrequires it, so that an unset variable fails startup rather than dialling in plain text? Requiring it leaves no way to call a plaintext service with this package. Recommendation: nil means plain text. Every humus server requires TLS, so a humus-to-humus call with no TLS fails its first RPC, and the breaker's WARN record shows it.The type
NewClientreturns. A*grpcclient.Clientof its own, as proposed, or the*grpc.ClientConn, with the retries and the breaker in interceptors humus chains first? The*grpc.ClientConngives the end userGetStateandConnect, for a readiness monitor on the connection. It also gives themClose, and lets agrpc.WithUnaryInterceptorinoptsrun outside humus's. Recommendation: theClient. A readiness monitor calls the dependency, such as itsgrpc.health.v1service, through it.Out of scope
grpc.WithPerRPCCredentials, inopts.References
grpcmodule, TLS required,grpcservertest.NewClientfor tests onlyHost,OnStop,Settingsand checks,telemetry.SDKand outbound instrumentation left out of scoperetryandcircuitv0.24.0, and their discussions, A retry package owned by bedrock bedrock#606 and A circuit breaker should be provided by bedrock bedrock#595;fitand Feature request: request-scoped failure injection (Netflix FIT) on OpenTelemetry baggage bedrock#578 for wave 5grpcservertestand its notes on thednsresolver; telemetry's OTLP connectiondnsresolver,WithDisableRetry, name resolution, andkeepalive.ServerParametersgrpc-retry-pushback-ms; gRFC A9: server-side connection management:MaxConnectionAgeotelgrpcv0.72.0, and OpenTelemetry's semantic conventions for RPCrest/rest.go#L358-L364, the OTLP connection on thednsresolverAll reactions