Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP client round trippers logging, metrics and request id #18

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

andrewesteves
Copy link

No description provided.

func TestConfigWithLoader(t *testing.T) {
yamlData := []byte(`
retries:
max_retry_attempts: 30
Copy link
Member

@vasayxtx vasayxtx Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuration parameters should follow camelCase naming conventions to maintain consistency with other packages in go-appkit. Let’s rename them accordingly.

Additionally, the current implementation doesn’t allow enabling the retry mechanism without also enabling rate limiting. I suggest introducing an enabled: true|false flag for both features to provide more flexibility. Alternative option: use rate-limiting round tripper only if the rate limit > 0 and retryable round tripper - only if max retry attempts > 0.


// MustHTTPClient wraps delegate transports with logging, metrics, rate limiting, retryable, user agent, request id
// and panics if any error occurs.
func MustHTTPClient(cfg *Config, userAgent, reqType string, delegate http.RoundTripper) *http.Client {
Copy link
Member

@vasayxtx vasayxtx Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s provide both methods:

  • NewHTTPClient: This method returns an error, allowing callers to handle it gracefully.
  • MustNewHTTPClient: This method panics if an error occurs, providing a convenient option for cases where error handling is not necessary.

This approach ensures flexibility for different use cases.

Additionally, I propose introducing two more methods:

  • NewHTTPClientWithOpts
  • MustNewHTTPClientWithOpts

In these methods, the delegate can be within the HTTPClientOpts struct for better extensibility and clarity in configuration.

// and panics if any error occurs.
func MustHTTPClient(cfg *Config, userAgent, reqType string, delegate http.RoundTripper) *http.Client {
if delegate == nil {
delegate = http.DefaultTransport
Copy link
Member

@vasayxtx vasayxtx Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use http.DefaultTransport.(*http.Transport).Clone() to avoid any potential side effects.


const (
// DefaultLimit is the default limit for rate limiting.
DefaultLimit = 10
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name DefaultLimit seems too generic for a package-level constant.

Additionally, I’m not convinced that default values are necessary in this case. Please refer to the second part of my next comment for further details.

DefaultWaitTimeout = 10 * time.Second

// configuration properties
cfgKeyRetriesMax = "retries.max_retry_attempts"
Copy link
Member

@vasayxtx vasayxtx Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about renaming retries.max_retry_attempts to retries.maxAttempts? The word "retry" in the middle seems redundant and can be omitted for better readability.

Additionally, consider supporting multiple retry backoff types, such as exponential and constant. You can refer to the BackoffPolicy field in the RetryableRoundTripperOpts struct for implementation details.

if err != nil {
return err
}
c.Limit = limit
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do more validation and return an error for example if the limit is negative.

}

// Config represents options for HTTP client configuration.
type Config struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider the following suggestions:

  1. We usually store internal config structs by value, not by pointer. I.e.:
type Config struct {
	Retries       RetriesConfig   `mapstructure:"retries"`
	RateLimits RateLimitConfig `mapstructure:"rate_limits"`
	Timeout     time.Duration    `mapstructure:"timeout"`
}
  1. All existing Config structs have 2 constructors: NewConfig and NewConfigWithKeyPrefix. Let's do the same for the httpclient.Config too.

  2. Let's add a compile-time check that httpclient.Config implements config.Config and config.KeyPrefixProvider interfaces.

var _ config.Config = (*Config)(nil)
var _ config.KeyPrefixProvider = (*Config)(nil)

delegate = NewMetricsRoundTripper(delegate, reqType)

delegate, err := NewRateLimitingRoundTripperWithOpts(
delegate, cfg.RateLimits.Limit, cfg.RateLimits.TransportOpts(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a good idea to accept LoggerProvider func(ctx context.Context) log.FieldLogger as an optional parameter in the constructors (NewHTTPClient and MustNewHTTPClient). This provider can then be passed as an option to NewRateLimitingRoundTripperWithOpts.

By default, the LoggerProvider should be set to middleware.GetLoggerFromContext.

Additionally, this LoggerProvider should also be utilized for the LoggingRoundTripper.

delegate = http.DefaultTransport
}

delegate = NewLoggingRoundTripper(delegate, reqType)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not sure it’s a good idea to log all requests unconditionally without providing a way to configure it.

Could we consider adding a configuration like the following?

log:
  slowRequestThreshold: 1s # Log requests taking 1s or longer (warn level).
  mode: none | failed_requests | all_requests # Log modes:
                                 # - none: Disable logging entirely.
                                 # - failed_requests: Log only failed requests (4xx/5xx) at error level.
                                 # - all_requests: Log all requests (2xx at info level, 4xx/5xx at error level).
                                 # Default: failed_requests or none (to be decided).

@andrewesteves, what’s your opinion on this approach?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vasayxtx i liked the approach and implemented it, just the namings i suggest none, failed and all since it's a logger round tripper for http clients the "requests" suffix could be omitted. is it okay for you?


if resp != nil {
args = []interface{}{r.Method, r.URL.String(), rt.ReqType, resp.StatusCode, elapsed.Seconds(), err}
message = common + "status code %d, time taken %.3f, err %+v"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s differentiate logs for successfully completed and failed requests. Logging them separately will make it much easier to search for errors in the logs.

For additional suggestions, please refer to my previous comment.

ExternalHTTPRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: namespace,
Name: "external_http_request_duration",
Copy link
Member

@vasayxtx vasayxtx Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name external_http_request_duration could be ambiguous, as "external" might imply either incoming requests from the internet or outgoing requests. I suggest renaming the Prometheus metric to http_client_request_duration_seconds for better clarity.

Additionally, this aligns with the naming convention already used in go-authkit.

if err == nil && resp != nil {
status = strconv.Itoa(resp.StatusCode)
if resp.StatusCode >= http.StatusBadRequest && logger != nil {
logger.Warnf("external request %s %s completed with HTTP status %d", r.Method, r.URL.String(), status)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest avoiding log production in the MetricsRoundTripper that is responsible for collecting metrics. Instead, we should use a separate LoggingRoundTripper specifically for logging purposes. This separation of concerns will keep the implementation cleaner and more modular.


// RoundTrip adds X-Request-ID header to the request.
func (rt *RequestIDRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
requestID := middleware.GetRequestIDFromContext(r.Context())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s add support for a RequestIDProvider func(ctx context.Context) string to enable customization of how request IDs are generated or retrieved. This should be included as part of the HTTPClientOpts struct, with a default implementation of middleware.GetRequestIDFromContext.

Use case: This is particularly useful for scenarios like outgoing requests from background workers, where the context is not tied to an incoming HTTP request.

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