Repository navigation
Replies: 2 comments
Revision 1: the open questions answeredThe open questions:
Each section below replaces or adds to the part of the same name in the post. Background, Shutdown, Timeouts, What wave 5 builds on this, Migration from today and References are unchanged. SummaryThe third bullet becomes:
Proposed API
type Settings struct {
// BaseURL, TLS, Timeout, AttemptTimeout, 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 answers
// mean.
//
// Classify gets the attempt's response, with its status and headers
// and its body unread, or the error the attempt failed with, never
// both. resp.Request is the request the attempt sent. It isn't asked
// about what the client decides itself: a rejection by the breaker, an
// error once the call's context has ended, and a body that fails once
// service code reads it.
//
// Classify may read resp.Body, to classify what the dependency sent,
// such as a 200 that carries an error. It then closes the body it read,
// and leaves in resp.Body what service code is to read, such as the
// bytes it read.
//
// It is called on the goroutine that made the call, 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(resp *http.Response, err error) 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
// the request, 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 Retry-After, or 0. A RetryAfter
// longer than BackoffMax isn't waited for: the call ends, and returns
// the response as it came.
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
}
The clientThese are added after "One client per dependency": Two clients may share an origin. For example, two APIs behind one gateway, which the end user wants shed separately, get two clients. Each has its own Pooling. A client per dependency pools as one shared
The cost that matters is a client built for each call. It would pool nothing, so every call would dial and handshake. Every call would also start with a fresh breaker and budget, which then never open or run down, and The layersStep 1 becomes:
Step 3 becomes:
Which requests are retriedThis is added: These rules hold whatever How an attempt's result countsThis section is replaced: Every attempt's result is classified twice: for What the client decides.
And whatever
What
Changing it. The end user's d := httpclient.DefaultSettings()
classify := d.Classify
settings.Classify = func(resp *http.Response, err error) httpclient.Classification {
if resp != nil && resp.StatusCode == http.StatusConflict {
// The ledger answers 409 while another write to the account is in
// flight. That write is over by the next attempt.
return httpclient.Classification{Retry: retry.Retry, Breaker: circuit.Success}
}
return classify(resp, err)
}The function is the end user's own, so the same one can be set on every client whose dependencies share a convention. humus exports no classifier but the default in
|
| Field | Default | Valid | Check |
|---|---|---|---|
Classify |
the table under How an attempt's result counts | set | NewClient; set in code, not sourced |
The bullet on TLS becomes:
TLSis nil for plain text, askafka.Settings.TLSandtelemetry.OTLPSettings.TLSare. humus can't control what a service connects to, and a mesh's sidecar, a node-local agent or a cloud metadata endpoint is often plain HTTP. A URL names its scheme too, soNewClientrequires the two to agree: anhttpsBaseURL with a nilTLS, or anhttpone with a config, fails startup rather than quietly doing one of the two. Every humus server serves TLS, so a call to one is alwayshttps.
This bullet is added:
Classifyis set in code, likeBreakerPolicy. What a dependency's answers 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.
Telemetry
This is added to Metrics:
Whether humus should record instruments of its own, such as a gauge of each breaker's state and of each budget's tokens, is decided for every client at once by Proposal: humus's own metrics (wave 5).
Panics
This section is replaced:
The client recovers nothing. Nothing of the end user's runs inside a call but Classify and BreakerPolicy. A panic in either passes to the goroutine that made the call. circuit frees the attempt's slot and leaves the breaker consistent, retry holds nothing between attempts, and the client closes the attempt's response before the panic goes on, so its connection isn't held. A panic in the client itself is humus's bug.
Design decisions
The bullets "One client per dependency" and "The classification is fixed" become:
- One client per dependency. bedrock builds one breaker per dependency at startup and keeps no registry. A client bound to one origin is that, and its
Settingsare the dependency's: its own roots, timeouts, retry budget andClassify.fitexpects the same of an injection point. It pools as a shared transport would, becausenet/httpnever shares a connection between origins. Rejected: one client for every origin, with a breaker and a budget made for each origin on its first call. It grows without bound, and puts every dependency on one set of settings. - The classification is a function,
Classify, from the start. APIs don't agree on what their answers mean, so nearly every client ends up classifying its own dependency. The default follows RFC 9110, and the end user's function wraps it. The client keeps what is about the call, not the API: the breaker's rejections, the caller giving up, deadlines and request safety, so aClassifycan't defeat the breaker or double a charge. Rejected: a fixed table, with a field added when users ask. Every client of an API that differs would wait for it.
These are added:
- Plain text is allowed. humus can't control what a service connects to.
TLSand the URL's scheme must agree, so plain text is always written out twice, never a default the operator fell into. - No instruments of humus's own.
otelhttprecords every attempt, rejections included, and a breaker's transitions are log records. Whether humus adds instruments of its own is decided for every package at once, by Proposal: humus's own metrics (wave 5).
Testing
These bullets are added to what the stories add:
- An end user's
Classify. AClassifythat retries a 409 and hands the rest to the default makes a GET answered 409 and then 200 succeed, after two attempts. One that reads a 200's body, finds an error in it and returnsretry.Retrymakes a second attempt, and service code reads the second response's body whole. - What the client decides stays the client's. A
Classifythat returnsretry.Retryandcircuit.Failurefor everything still sends a POST without a key once. It 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
RetryAfterbeyondBackoffMax, returned by an end user'sClassify, ends the call at once, after one request. NewClientrejects a nilClassify, naming the field, next to its other errors.- A panic in
Classifyreaches the caller, and the attempt's response is closed.
Changes to other files
The docs page's list becomes: one client per dependency, built in Init and shared, and two clients of one origin; sourcing the settings; which requests are retried; the classification table and what the client decides itself; writing a Classify, with a 409 and with a body; Retry-After; the breaker and its log records; the body rules; HTTP/2 behind a ClusterIP Service; and testing against a fake dependency on a humustest Host.
The other clients
These answers reach past this proposal. Each other proposal records them when it is next revised:
- Plain text. The reason holds for every client. Outbound gRPC clients' open question 2 is the same question.
kafka.Settings.TLSand RabbitMQ'sConnection.TLSalready mean plain text when nil. Classify. The reason holds for gRPC: a gRPC API picks its own codes too, such asAbortedfor a conflict that a retry resolves. So outbound gRPC clients' revision givesgrpcclient.SettingsaClassifyfield of the same name, shaped for what a gRPC attempt ends with, its pushback included. It doesn't hold for producing to Kafka or for RabbitMQ'sPublisher. They call a broker, whose protocol, not an API, fixes what its errors mean, so their classification stays fixed.- Metrics. No wave-4 client records instruments of its own. Proposal: humus's own metrics (wave 5) decides it for all of them at once.
Open questions
All four are answered above. None are left.
Out of scope
These are added:
- Instruments of humus's own, such as gauges of the breaker's state and the budget's tokens: Proposal: humus's own metrics (wave 5).
- Classifiers for well-known APIs, and helpers to build one. A
Classifyis the end user's function.
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 Proposal: the humus App lifecycle, Proposal: configuration for humus apps, Proposal: OpenTelemetry without globals and Proposal: humustest, and on bedrock v0.24.0's
retryandcircuit. It depends on no other wave-4 proposal. It is discussed side by side with Proposal: outbound gRPC clients and Proposal: producing to Kafka, and keeps to the three points the charter's revision 7 sets for clients. It blocks Proposal: failure injection (wave 5), which adds an injection point to its calls.Summary
A new package,
httpclient, gives the end user'sInitan*http.Clientfor one HTTP dependency, such as another service's API:httpclient.NewClient(s, settings)returns a plain*http.Client. Service code, generated clients and SDKs take it as they take any other. It dials only throughs.Host.Dial, so a test runs the end user's whole App, its outbound calls included, on humustest's network.Settings.BaseURL. It sends nothing anywhere else.Settings.TLSis nil for plain text, as for a Kafka consumer and the OTLP exporters, and anhttpsBaseURL requires it.retryandcircuit. A request that is safe to repeat is retried, up to 3 attempts, with exponential backoff, a retry budget, and the server'sRetry-After. Every attempt goes through one circuit breaker for the dependency.otelhttpands.Telemetry. Each change of the breaker's state is a log record.humus.Service. It opens no connection inNewClient, and closes its idle connections in a function it registers withs.OnStop.Background
Outbound calls were left to this proposal. OpenTelemetry without globals put "instrumentation of outbound clients, and FIT" out of scope, and the HTTP server foundation put "the outbound HTTP client" out of scope. The charter's revision 7 moved both here.
Before the reset, humus had no outbound client. The v0.15.0 orders walkthrough built one in its
Init(app/app.go#L28-L30):The spans used OTel's global providers and propagator.
http.DefaultTransportdials the real network and readsHTTP_PROXYfrom the real environment, which the lifecycle page already lists as an ambient leak. The one client served three dependencies, with no retries, no breaker and no timeout.bedrock ships the model and leaves the wiring to humus. bedrock v0.24.0 releases
retryandcircuitas nested modules. Neither does any I/O. Their discussions name what humus builds: "thehttp.RoundTripperwrapper, gRPC interceptors and OTel metrics" for the breaker (z5labs/bedrock#595), and "anhttp.RoundTripperthat replays bodies withGetBodyand readsRetry-After" for retries (z5labs/bedrock#606). A retry package owned by bedrock bedrock#606 includes a humus sketch of the composition, which this proposal starts from.net/httpalready retries one case, and it stays.http.Transportsends a request again on a new connection when a reused keep-alive connection fails before the server has answered, if the request is idempotent and its body can be rewound (Transport's doc). That happens inside one attempt, and can't be turned off. It recovers a stale pooled connection. It doesn't retry a failed call.What wave 1 provides.
Host.Dial, which humustest makes in-memory.Setup.Telemetry, which carries the providers.Setup.OnStop, which releases whatInitbuilt.humustest.NewCA, so a test generates the certificates it serves and trusts. And from wave 3,httpserver, which serves a test's fake dependency on the same Host.Proposed API
The package is in the core module. Its new dependencies are bedrock's
retryandcircuitmodules, which depend on nothing but the standard library and testify.otelhttpis already in the core module, forhttpserver.The client
One client per dependency. A dependency is an origin: the scheme, host and port of
BaseURL, with the scheme's default port when the URL has none. The end user'sInitbuilds one client for each dependency, with its ownSettings, and shares it between every Service that calls it. The breaker and the retry budget belong to the client, so a client is what bedrock calls "once per dependency, at startup". A request to another origin fails withhttpclient: request to https://ledger:8443 on a client for https://payments:8443, and nothing is sent. A redirect to the same origin is followed, ashttp.Clientdoes. A redirect to another origin isn't: the client returns the 3xx response to service code.What it returns. A plain
*http.Client. Service code depends onnet/http's type, not onhttpclient, and a unit test of that code passes any client. ItsTransportis the stack described below, itsTimeoutisSettings.Timeout, and itsCheckRedirectstops at a redirect to another origin.The transport underneath is an
http.Transportthat humus builds:DialContextiss.Host.Dial. So the client reaches the dependency the way every humus App reaches the network, and a test's in-memory Host serves it.Proxyis nil. The client never readsHTTP_PROXY,HTTPS_PROXYorNO_PROXY.TLSClientConfigis a clone ofSettings.TLS, for anhttpsBaseURL.Protocolsoffers HTTP/2 and HTTP/1.1, so every server that offersh2gets HTTP/2, a humus server set toHTTP2Onlyamong them. AnhttpBaseURL is HTTP/1.1, because humus serves no h2c.http.DefaultTransportdoes for every host together.net/http's default of 2 idle connections per host would make a busy client open and close connections on every burst. These aren't settings: they are added when users ask for them.Shutdown. The client isn't a Service, and has nothing to drain. A call in flight ends with its own context: a handler's call ends when its request's context does. The function
NewClientregisters withs.OnStopcallsCloseIdleConnections, and theOnStopfunctions run after every Service has stopped, so nothing is still calling the dependency by then.The layers
Each call goes through four layers, all unexported, outermost first:
Retrier.Attemptswith the call's context. Before each attempt after the first, it rewinds the body withGetBody. After each, it builds the attempt's result: the transport's error, or an error for a status code the classifiers count. It passes that toAttempt.Done.otelhttp.Transport. One client span per attempt. It injects the propagator's trace context and baggage into the attempt's headers, and records the attempt's metrics.Breaker.Allow, a deferredRelease, the attempt, thenTicket.Done, which the response body settles. A rejected attempt returns the*circuit.RejectedErrorand sends nothing.AttemptTimeoutthat cancels the attempt's context, with a cause that wrapscontext.DeadlineExceeded, unless the response's headers arrive first.Then the
http.Transport.The order is bedrock's: "a circuit breaker goes inside the retry loop, so that each attempt is a call the breaker admits or rejects".
otelhttpsits between them so that every attempt, rejected ones included, has a span. That's where HTTP differs from gRPC: anhttp.RoundTrippercan wrap the breaker, so a rejected attempt's span isotelhttp's own, named and attributed as every other attempt's is, and humus starts no span of its own. The breaker sees the call's context, not the attempt's, so the classifier can tell the caller giving up from an attempt timing out.Which requests are retried
A call is retried only when sending it twice is safe and possible:
Idempotency-KeyorX-Idempotency-Keyheader, whichnet/httptreats the same way.GetBodyis set.http.NewRequestsetsGetBodyfor a*bytes.Buffer, a*bytes.Readerand a*strings.Reader.Any other request makes one attempt. It still goes through the breaker, and
Donestill counts its result in the budget: the loop breaks afterDone, as bedrock's doc shows.net/http's own list leaves out PUT and DELETE. RFC 9110 defines both as idempotent, and a service that sends them relies on that already.How an attempt's result counts
Every result goes to two classifiers: retry's, which decides whether another attempt follows, and the breaker's, which decides whether the result counts against the dependency.
Retry-AfterRetry-AfterAttemptTimeoutTimeout, or a deadline the caller setRetry-Afterpaces them.retry's doc says.Retry-AfterWhen the classifier retries a response that has a
Retry-Afterheader, its delay is the least time to wait, and the wait is the longer of it and the backoff. Both forms of RFC 9110 are read: a number of seconds, and an HTTP-date, which is measured from now and clamped at 0. A malformed header is ignored.A
Retry-Afterlonger thanBackoffMaxisn't waited for: the classifier returns Stop, and the response goes to service code as it came. A server that asks for a minute is not one a request in flight should wait on, and that rule needs no setting of its own. A wait that would end after the call's deadline isn't started either:retryends the call as Expired, and it returns the last response.The response body
Two things happen to a response's body.
When another attempt follows, the client reads the body of the response it replaces into memory, up to 64 KiB, and closes it, before the wait. Closing it ends the attempt's span, settles its breaker ticket, and frees its connection during the backoff. If the wait is cut short, the buffered response is what the call returns. A body larger than 64 KiB isn't retried: the loop breaks, and the call returns the response with its body intact. Error bodies are small, and z5labs/bedrock#606 named this choice: "unless humus buffers a small body and closes the connection."
The breaker's ticket is settled by the body, as
circuit's doc shows for anhttp.RoundTripper. The body settles it when service code reads it to the end, or closes it, with the status's result. A read that fails settles it with the error. So a dependency that sends its headers and then fails mid-body counts as failing. A body that is never closed leaves its ticket unsettled. That costs nothing while the breaker is closed, and holds a half-open probe slot forProbeTimeoutat most.net/httpasks for every body to be closed anyway.The sketch in z5labs/bedrock#606 settles the ticket once the headers arrive. That is simpler, but a failure after the headers is never counted.
Timeouts
Timeoutbounds the call. It is thehttp.Client'sTimeout, whichnet/httpputs on the request's context as a deadline, unless the caller's context has an earlier one, which then wins. Soretrysees the deadline, and doesn't start a wait that would end after it. It covers reading the body, so a client that streams large bodies raises it.AttemptTimeoutbounds one attempt until its headers, asretry's doc puts a deadline on each attempt. An attempt to a dependency that hangs fails after 5s and is retried, so with the defaults, a hung first attempt still leaves time for a second beforeTimeout. It stops counting once the headers arrive, so a slow body isn't cut off. Its cause wrapscontext.DeadlineExceeded, which the classifiers read as an attempt timing out, not the caller giving up.net/httpreturns the context's cause.Settings
BaseURLhttporhttpsURL with a host; no user info, query or fragmentCheckBaseURLTLSnil: plain textnil, or a config that verifies the server and allows TLS 1.2 or later; set forhttpsand nil forhttpCheckTLS; the scheme inNewClientTimeout10sCheckTimeoutAttemptTimeout5sCheckAttemptTimeoutMaxAttempts3CheckMaxAttemptsBackoffBase100msCheckBackoffBaseBackoffMax5sBackoffBaseCheckBackoffMax; the order inNewClientRetryBudget.MaxTokens10retry.TokenLimitCheckRetryBudgetMaxTokensRetryBudget.TokenRatio0.1retry.TokenLimitCheckRetryBudgetTokenRatioBreaker.Window20circuit.MaxCountCheckBreakerWindowBreaker.Probes1circuit.MaxCountCheckBreakerProbesBreaker.OpenFor10sCheckBreakerOpenForBreaker.ProbeTimeout10sCheckBreakerProbeTimeoutBreakerPolicyTripandProbesetNewClient; set in code, not sourcedCheckBaseURLrejects user info. A password in the URL would sit in a setting, and in every error that names the client's origin. Credentials go in a header that service code sets.TLSis nil for plain text, askafka.Settings.TLSandtelemetry.OTLPSettings.TLSare. A URL names its scheme too, soNewClientrequires the two to agree: anhttpsBaseURL with a nilTLS, or anhttpone with a config, fails startup rather than quietly doing one of the two. Open question 2 asks whether plain text is allowed at all.CheckTLSrejectsInsecureSkipVerify. A test trusts itshumustest.NewCAthroughRootCAs, so nothing in humus needs to skip verification, and a setting that does fails startup.RootCAstrusts the system's roots, whichcrypto/x509loads from the real host: fromSSL_CERT_FILEandSSL_CERT_DIRin the real environment, and from the OS's certificate files. That's an ambient leak, and the lifecycle page lists it. A service that calls only its own services setsRootCAsto its CA and reads nothing ambient.Timeoutis positive. A client with no bound on a call holds a goroutine and a connection for as long as a dependency hangs. It covers reading the body, because for HTTP the body is part of the call: a client that streams large bodies raises it.BreakerPolicyis set in code, like a Kafka consumer'sDelivery. It is the end user's decision about how their dependency fails, rather than an operator's, and anyPolicycan be written. Four threshold settings would fix one shape of policy, and the operator rarely changes them.DefaultSettingsgives bedrock's example policy, andNewClientrejects a nilTriporProbe, whichcircuit.Newwould panic on.In the end user's
Init, sourced as #631 and the configuration page describe, with whatever variable names the service uses:readFileandtrustingare the end user's helpers:trustingbuilds a*tls.ConfigwhoseRootCAshold the PEM it read. The dependency ishttps, soPAYMENTS_CA_FILEhas no.Defaulteither.Telemetry
The client takes everything from
s.Telemetryand never touches OTel's globals:otelhttp's transport reads the global propagator andMeterProvideras defaults before it applies its options, and without aTracerProviderit falls back to the global one. All three are passed, so none of the globals is used.http.request.resend_count. A redirect counts as a resend too, as the conventions say.otelhttpdoesn't set that attribute, so the breaker layer sets it on the attempt's span, whichotelhttphas put in the attempt's context by then. A span is named after the method, such asGET, as the conventions name a client span with no URL template:otelhttp's own default,HTTP GET, predates them. The span carriesotelhttp's attributes, among themurl.full, without user info,server.address,server.portandhttp.response.status_code. Its status is Error for a 4xx or 5xx and for a transport error.otelhttp's, becauseotelhttpwraps the breaker. It is named and attributed as every attempt's span is, and ends at once with status Error anderror.typeset to*circuit.RejectedError. The call returns an error thaterrors.Ismatches withcircuit.ErrRejected, throughhttp.Client's*url.Error.otelhttp'shttp.client.request.durationandhttp.client.request.body.size, recorded for each attempt inotelhttp's scope, witherror.typefor a failed one. humus records no instrument of its own. Becauseotelhttpwraps the breaker, a rejected attempt is inhttp.client.request.durationtoo, with itserror.type, which the gRPC client's rejections aren't.s.Telemetry.LoggerProvider.Logger("github.com/z5labs/humus/httpclient"), with the OTel Logs API. It writes one record for each of the breaker's transitions, fromcircuit's observer, in the context of the call that made the change:circuit breaker openat WARN, andcircuit breaker half-openandcircuit breaker closedat INFO. Each has the attributesserver.addressandserver.portof the dependency,circuit.previous_state, and the counts of the state it left:circuit.successes,circuit.failures,circuit.consecutive_successes,circuit.consecutive_failuresandcircuit.timed_out. OpenTelemetry has no convention for circuit breakers, so thecircuit.names are humus's, the same in all three clients.Panics
The client recovers nothing. Nothing of the end user's runs inside a call but
BreakerPolicy, and a panic there passes throughcircuit, which leaves the breaker consistent, to the goroutine that made the call. A panic in the client itself is humus's bug.What wave 5 builds on this
Proposal: failure injection (wave 5) adds an injection point to every client's calls, and strips scenarios from calls to third parties. The seams in this package are:
http.Transport. An injected abort then reaches every layer as a transport error. It is counted by the breaker, retried by the retry layer, and recorded on the attempt's span, exactly as a real failure is. An injected delay counts againstAttemptTimeout, as a slow dependency's does. A client has one dependency, known whenNewClientruns, so a point's name can be fixed when the client is built, asfit's doc expects.otelhttpinjects the propagator's baggage into an attempt's headers.Nothing in this API is designed for failure injection, and the wave-5 proposal's stories make the change here.
Design decisions
httpclient. It mirrorshttpserver, in the same module.httpwould clash withnet/http, andclientsays nothing.NewClientreturns*http.Client, not a type of its own. The gRPC client returns a type of its own, and the Kafka producer isNewProducer. HTTP differs because every Go HTTP library takes an*http.Client, generated clients and cloud SDKs among them, and a type of humus's own would need an adapter for each. The constructor keeps the name the other clients use. Anhttp.RoundTripperalone would leave the end user to setTimeoutandCheckRedirectcorrectly.Settingsare the dependency's: its own roots, timeouts and retry budget.fitexpects the same of an injection point. The alternative, one client with a breaker for each origin it meets, is open question 1.MaxAttempts: 1turns retries off.BreakerPolicywhoseTripreturns false never opens, for a dependency that must not be shed.HTTP2Only. HTTP/2 puts every call on one connection, so behind a Kubernetes ClusterIP Service, one client's calls reach one pod until that connection closes. A humus server'sShutdownsends a GOAWAY, so a rollout moves them. The docs page says so. Load balancing across a Service's pods is out of scope.Proxyis nil, so the client never reads the proxy variables of the real environment. A proxy setting is added when users ask for one.net/http's default client.http.DefaultClient,http.DefaultTransport,http.Get,http.Head,http.Postandhttp.PostFormdial the real network and read the proxy variables. humus's library code never uses them, and forbidigo enforces that as it does the process boundary.Testing
A prototype of this client passed the tests below, under
go test -race -count=3, with bedrock'sretryandcircuitv0.24.0,otelhttpv0.72.0 and Go 1.27. Each test runs the end user's whole App inside atesting/synctestbubble, on a humustestHost, with a fake dependency served byhttpserveron the same Host over TLS. The App is a job whose run calls the dependency. Certificates come fromhumustest.NewCA, so no key, certificate or password is fixed, and backoff andOpenForwaits take no real time. The prototype was in a scratchpad and is not kept, and the sketches below are trimmed from it.The shared helpers: the fake dependency listens on
:8443and serves a certificate forpayments, and the client'sBaseURLishttps://payments:8443:A 503 with
Retry-Afteris retried after it, with a span for each attempt:The breaker opens, rejects without dialling, and closes again after
OpenFor:The prototype's tests also showed the following:
Idempotency-Keyis sent three times.AttemptTimeout's 5s. The second attempt succeeds, and the handler's context was cancelled, because the attempt's stream was reset.Retry-AfterbeyondBackoffMaxisn't waited for. A 429 withRetry-After: 60is returned at once, after one request.Timeoutends the retries. WithTimeout: 6sand a 503 withRetry-After: 4on every request, the call makes two attempts and returns the second 503 at 4s, rather than starting a wait that would end after the deadline.http.request.resend_count1. A redirect to another origin returns the 302 to service code.https://ledger:8443/fails with the message above, and nothing is dialled.httpserverdependency hasnetwork.protocol.version2.0, asotelhttpwrites it.TracerProviderset to a recorder of its own, a call records nothing in it.The stories add the tests the prototype didn't have:
retry.NewBudgetandcircuit.Newaccept.NewClientnames each bad field and reports two together. It rejectsBackoffMaxbelowBackoffBase, aTLSthat doesn't match the scheme, and aBreakerPolicywith a nil function.DefaultSettings()with anhttpBaseURLpassesNewClient. The package never reads abedrock.Environmentand doesn't importbedrock/config.circuit.previous_stateclosed, and the counts that tripped it, such ascircuit.consecutive_failures5. The prototype checked these on the open record.OnStop. After the App exits, the dependency sees its connection from the client closed.error.type*circuit.RejectedError, and so does itshttp.client.request.durationdata point.The package's example is an
Examplefunction or apackage mainwith anapp_test.go, as the charter requires, and the package is held to the 80% coverage gate.Changes to other files
This proposal's stories make these, as it owns outbound HTTP:
docs/content/http-client.md: one client per dependency, sourcing the settings, which requests are retried, the classification table,Retry-After, the breaker and its log records, the body rules, HTTP/2 behind a ClusterIP Service, and testing against a fake dependency on a humustest Host.crypto/x509's system roots, read fromSSL_CERT_FILE,SSL_CERT_DIRand the OS's files when a client'sTLShas noRootCAs.http.DefaultClient,http.DefaultTransport,http.Get,http.Head,http.Postandhttp.PostForm.go.modgainsgithub.com/z5labs/bedrock/retryandgithub.com/z5labs/bedrock/circuitv0.24.0.httpserver,httpservertest,humustestandtelemetrydon't change.Migration from today
&http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}, built inInithttpclient.NewClient(s, settings)Settings, breaker and budgetbedrockconfigSettings.BaseURL, sourced from whatever variable the end user namesotelhttpon OTel's global providers and propagatorotelhttpwiths.Telemetry's, a span per attempt, named after the methodhttp.DefaultTransport: the real network,ProxyFromEnvironments.Host.Dial, and no proxyTimeoutof 10s, andAttemptTimeoutof 5sThere was nothing else to migrate: humus had no outbound client.
Open questions
BaseURL's origin and refuses others, and the end user builds one for each dependency. The alternative is one client that calls any origin, with a breaker and a budget made for each origin on its first call. That suits a client of URLs it doesn't know at startup, such as webhooks, but grows without bound, and puts every dependency on one set of settings. Recommendation: one client per dependency.TLSmeans plain text, with anhttpBaseURL, askafka.Settings.TLSandtelemetry.OTLPSettings.TLSdo, and as the gRPC client proposes. A mesh sidecar, a node-local agent or a cloud metadata endpoint is often plain HTTP. Every humus server serves TLS, so a call to one is alwayshttps. Allow plain text, or requireTLSand anhttpsBaseURL, as the servers require TLS? Recommendation: allow it, and decide it the same way for all three clients.retryandcircuit. Recommendation: fixed, and a classifier field is added when users ask, as the Kafka consumer's extra settings are.http.client.request.durationwith theirerror.type, and transitions are WARN and INFO records.circuitandretryboth expect a framework to export a breaker's state and a budget's tokens, as gauges read withBreaker.StateandBudget.Tokens, which would let an alert fire on an open breaker without a log query. Add the two gauges now, or wait for the three client proposals to agree on their names? Recommendation: none now. The other two clients would need the same gauges, which makes it a decision for all three.Out of scope
retryandcircuitleave these out too.Jaris nil, and the end user may set one.References
telemetry.SDK, no globals, and outbound instrumentation left out of scopeSettings,DefaultSettingsandCheck<Field>NewCAHTTP2Onlyretryandcircuit, and their discussions, z5labs/bedrock#606 and z5labs/bedrock#595;fitand z5labs/bedrock#578 for the seams of wave 5otelhttpv0.72.0, and OpenTelemetry's HTTP client spans and HTTP client metricsRetry-After(§10.2.3); RFC 6585: 429net/http'sTransport, on the requests it replays itselfapp/app.goAll reactions