Skip to content

Commit 792d8f5

Browse files
committed
feat: add configurable HTTP client timeouts via config file
<!-- markdownlint-disable MD041 --> Introduce the `http.config.ocm.software/v1alpha1` config type for controlling HTTP client timeouts (dial, TLS handshake, response header, idle connection, keep-alive, and overall timeout). Timeouts are configured exclusively through the OCM config file using Go duration strings. When not set, `http.DefaultTransport` values are preserved unchanged. - Add `httpcfgattr` package with Duration type (K8s-style marshal/unmarshal) - Add `api/utils/httpclient` transport factory (clones DefaultTransport, selectively overrides from config) - Wire HTTP settings into docker daemon and OCI registry clients - Add integration tests using toxiproxy for timeout verification - Update docs with new config type documentation Fixes: #1731 Signed-off-by: Piotr Janik <piotr.janik@sap.com>
1 parent b776b68 commit 792d8f5

19 files changed

Lines changed: 989 additions & 13 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package httpcfgattr
2+
3+
import (
4+
"github.com/mandelsoft/goutils/errors"
5+
6+
"ocm.software/ocm/api/datacontext"
7+
"ocm.software/ocm/api/utils/runtime"
8+
)
9+
10+
type (
11+
Context = datacontext.AttributesContext
12+
ContextProvider = datacontext.ContextProvider
13+
)
14+
15+
const (
16+
ATTR_KEY = "ocm.software/ocm/api/datacontext/attrs/httptimeout"
17+
ATTR_SHORT = "httpcfg"
18+
)
19+
20+
func init() {
21+
datacontext.RegisterAttributeType(ATTR_KEY, AttributeType{}, ATTR_SHORT)
22+
}
23+
24+
type AttributeType struct{}
25+
26+
func (a AttributeType) Name() string {
27+
return ATTR_KEY
28+
}
29+
30+
func (a AttributeType) Description() string {
31+
return `
32+
*JSON*
33+
Configures HTTP client timeout settings for OCI registry and remote endpoint access.
34+
Settings are provided as a JSON document matching the ` + ConfigType + ` config type.
35+
36+
For full control use the config file:
37+
<pre>
38+
type: ` + ConfigType + `
39+
timeout: 0s
40+
tcpDialTimeout: 30s
41+
tcpKeepAlive: 30s
42+
tlsHandshakeTimeout: 10s
43+
idleConnTimeout: 90s
44+
</pre>
45+
`
46+
}
47+
48+
func (a AttributeType) Encode(v interface{}, marshaller runtime.Marshaler) ([]byte, error) {
49+
attr, ok := v.(*Attribute)
50+
if !ok {
51+
return nil, errors.ErrInvalid("httpcfg attribute")
52+
}
53+
cfg := New()
54+
cfg.HTTPSettings = attr.settings
55+
56+
return marshaller.Marshal(cfg)
57+
}
58+
59+
func (a AttributeType) Decode(data []byte, unmarshaller runtime.Unmarshaler) (interface{}, error) {
60+
var value Config
61+
err := unmarshaller.Unmarshal(data, &value)
62+
if err != nil {
63+
return nil, err
64+
}
65+
66+
attr := &Attribute{}
67+
err = value.ApplyToAttribute(attr)
68+
if err != nil {
69+
return nil, err
70+
}
71+
return attr, nil
72+
}
73+
74+
////////////////////////////////////////////////////////////////////////////////
75+
76+
// Attribute holds the effective HTTP client settings for a context.
77+
type Attribute struct {
78+
settings HTTPSettings
79+
}
80+
81+
// GetHTTPSettings returns a pointer to the effective HTTP settings.
82+
func (a *Attribute) GetHTTPSettings() *HTTPSettings {
83+
if a == nil {
84+
return &HTTPSettings{}
85+
}
86+
return &a.settings
87+
}
88+
89+
////////////////////////////////////////////////////////////////////////////////
90+
91+
// Get returns the HTTP client attribute from the context.
92+
// If not set, a default empty Attribute is created and stored.
93+
func Get(ctx ContextProvider) *Attribute {
94+
return ctx.AttributesContext().GetAttributes().GetOrCreateAttribute(ATTR_KEY, func(datacontext.Context) interface{} {
95+
return &Attribute{}
96+
}).(*Attribute)
97+
}
98+
99+
// Set stores the HTTP client attribute in the context.
100+
func Set(ctx ContextProvider, attr *Attribute) error {
101+
return ctx.AttributesContext().GetAttributes().SetAttribute(ATTR_KEY, attr)
102+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package httpcfgattr_test
2+
3+
import (
4+
"time"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
"ocm.software/ocm/api/datacontext"
10+
"ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
11+
"ocm.software/ocm/api/utils/runtime"
12+
)
13+
14+
var _ = Describe("httpcfg attribute", func() {
15+
var ctx datacontext.Context
16+
attr := httpcfgattr.AttributeType{}
17+
enc := runtime.DefaultJSONEncoding
18+
19+
BeforeEach(func() {
20+
ctx = datacontext.New(nil)
21+
})
22+
23+
Context("get and set", func() {
24+
It("defaults to empty settings with no timeout", func() {
25+
a := httpcfgattr.Get(ctx)
26+
Expect(a).NotTo(BeNil())
27+
Expect(a.GetHTTPSettings().GetTimeout()).To(Equal(time.Duration(0)))
28+
})
29+
30+
It("round-trips through encode/decode", func() {
31+
a := httpcfgattr.Get(ctx)
32+
cfg := httpcfgattr.NewConfig(30 * time.Second)
33+
cfg.ApplyToAttribute(a)
34+
35+
Expect(httpcfgattr.Get(ctx).GetHTTPSettings().GetTimeout()).To(Equal(30 * time.Second))
36+
})
37+
})
38+
39+
Context("encoding", func() {
40+
It("encodes *Attribute to JSON", func() {
41+
a := httpcfgattr.Get(ctx)
42+
cfg := httpcfgattr.NewConfig(30 * time.Second)
43+
cfg.ApplyToAttribute(a)
44+
45+
data, err := attr.Encode(a, enc)
46+
Expect(err).To(Succeed())
47+
Expect(string(data)).To(ContainSubstring(`"timeout":"30s"`))
48+
})
49+
50+
It("rejects non-*Attribute input", func() {
51+
_, err := attr.Encode("invalid", enc)
52+
Expect(err).To(HaveOccurred())
53+
Expect(err.Error()).To(ContainSubstring("is invalid"))
54+
})
55+
})
56+
57+
Context("decoding", func() {
58+
It("decodes JSON to *Attribute", func() {
59+
raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"10s"}`)
60+
val, err := attr.Decode(raw, enc)
61+
Expect(err).To(Succeed())
62+
a, ok := val.(*httpcfgattr.Attribute)
63+
Expect(ok).To(BeTrue())
64+
Expect(a.GetHTTPSettings().GetTimeout()).To(Equal(10 * time.Second))
65+
})
66+
67+
It("rejects invalid JSON", func() {
68+
_, err := attr.Decode([]byte(`{invalid`), enc)
69+
Expect(err).To(HaveOccurred())
70+
})
71+
})
72+
})
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package httpcfgattr
2+
3+
import (
4+
"encoding/json"
5+
"time"
6+
7+
"github.com/mandelsoft/goutils/errors"
8+
9+
cfgcpi "ocm.software/ocm/api/config/cpi"
10+
"ocm.software/ocm/api/utils/runtime"
11+
)
12+
13+
const (
14+
ConfigType = "http" + cfgcpi.OCM_CONFIG_TYPE_SUFFIX
15+
ConfigTypeV1Alpha1 = ConfigType + runtime.VersionSeparator + "v1alpha1"
16+
)
17+
18+
func init() {
19+
cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigType, usage))
20+
cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigTypeV1Alpha1, usage))
21+
}
22+
23+
// Duration is a wrapper around time.Duration which supports correct
24+
// marshaling to YAML and JSON as a Go duration string (e.g. "30s", "5m").
25+
type Duration struct {
26+
time.Duration
27+
}
28+
29+
// UnmarshalJSON implements the json.Unmarshaller interface.
30+
func (d *Duration) UnmarshalJSON(b []byte) error {
31+
var str string
32+
if err := json.Unmarshal(b, &str); err != nil {
33+
return err
34+
}
35+
pd, err := time.ParseDuration(str)
36+
if err != nil {
37+
return err
38+
}
39+
d.Duration = pd
40+
return nil
41+
}
42+
43+
// MarshalJSON implements the json.Marshaler interface.
44+
func (d Duration) MarshalJSON() ([]byte, error) {
45+
return json.Marshal(d.Duration.String())
46+
}
47+
48+
// NewDuration creates a pointer to a Duration.
49+
func NewDuration(d time.Duration) *Duration {
50+
return &Duration{Duration: d}
51+
}
52+
53+
// HTTPSettings contains the timeout settings for HTTP clients.
54+
// All timeout values use Duration (Go duration strings in config).
55+
// If not set (nil), the http.DefaultTransport value from the Go
56+
// standard library is used.
57+
//
58+
// Note: Timeout controls the overall http.Client deadline and is
59+
// independent of the transport-level timeouts below. Setting Timeout
60+
// alone does NOT override TCPDialTimeout, TLSHandshakeTimeout, etc.
61+
type HTTPSettings struct {
62+
// Timeout is the overall http.Client timeout — the maximum duration
63+
// for the entire request including connection, TLS, headers, and body.
64+
// It does NOT serve as a fallback for transport-level timeouts.
65+
// If not set, http.Client uses no timeout (0).
66+
Timeout *Duration `json:"timeout,omitempty"`
67+
68+
// TCPDialTimeout is the time limit for establishing a TCP connection.
69+
TCPDialTimeout *Duration `json:"tcpDialTimeout,omitempty"`
70+
71+
// TCPKeepAlive is the interval between TCP keep-alive probes.
72+
TCPKeepAlive *Duration `json:"tcpKeepAlive,omitempty"`
73+
74+
// TLSHandshakeTimeout is the maximum time to wait for a TLS handshake.
75+
TLSHandshakeTimeout *Duration `json:"tlsHandshakeTimeout,omitempty"`
76+
77+
// ResponseHeaderTimeout is the time limit to wait for response headers.
78+
ResponseHeaderTimeout *Duration `json:"responseHeaderTimeout,omitempty"`
79+
80+
// IdleConnTimeout is the maximum time an idle connection remains open.
81+
IdleConnTimeout *Duration `json:"idleConnTimeout,omitempty"`
82+
}
83+
84+
// GetTimeout returns the overall HTTP client timeout.
85+
// Returns 0 (disabled) if not set.
86+
func (s *HTTPSettings) GetTimeout() time.Duration {
87+
if s == nil || s.Timeout == nil {
88+
return 0
89+
}
90+
return s.Timeout.Duration
91+
}
92+
93+
// Config describes the configuration for HTTP client settings.
94+
type Config struct {
95+
runtime.ObjectVersionedType `json:",inline"`
96+
HTTPSettings `json:",inline"`
97+
}
98+
99+
// New creates a new empty HTTP Config.
100+
func New() *Config {
101+
return &Config{
102+
ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType),
103+
}
104+
}
105+
106+
func (a *Config) GetType() string {
107+
return ConfigType
108+
}
109+
110+
// NewConfig creates a new HTTP config with the given overall timeout.
111+
func NewConfig(timeout time.Duration) *Config {
112+
return &Config{
113+
ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType),
114+
HTTPSettings: HTTPSettings{
115+
Timeout: NewDuration(timeout),
116+
},
117+
}
118+
}
119+
120+
func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error {
121+
if t, ok := target.(Context); ok {
122+
if t.AttributesContext().IsAttributesContext() { // apply only to root context
123+
return errors.Wrapf(a.ApplyToAttribute(Get(t)), "applying config failed")
124+
}
125+
}
126+
return cfgcpi.ErrNoContext(ConfigType)
127+
}
128+
129+
// ApplyToAttribute merges this config's settings into an existing attribute.
130+
func (a *Config) ApplyToAttribute(attr *Attribute) error {
131+
s := &attr.settings
132+
if a.Timeout != nil {
133+
s.Timeout = a.Timeout
134+
}
135+
if a.TCPDialTimeout != nil {
136+
s.TCPDialTimeout = a.TCPDialTimeout
137+
}
138+
if a.TCPKeepAlive != nil {
139+
s.TCPKeepAlive = a.TCPKeepAlive
140+
}
141+
if a.TLSHandshakeTimeout != nil {
142+
s.TLSHandshakeTimeout = a.TLSHandshakeTimeout
143+
}
144+
if a.ResponseHeaderTimeout != nil {
145+
s.ResponseHeaderTimeout = a.ResponseHeaderTimeout
146+
}
147+
if a.IdleConnTimeout != nil {
148+
s.IdleConnTimeout = a.IdleConnTimeout
149+
}
150+
return nil
151+
}
152+
153+
const usage = `
154+
The config type <code>` + ConfigType + `</code> can be used to configure
155+
HTTP client settings:
156+
157+
<pre>
158+
type: ` + ConfigType + `
159+
timeout: 0s
160+
tcpDialTimeout: 30s
161+
tcpKeepAlive: 30s
162+
tlsHandshakeTimeout: 10s
163+
responseHeaderTimeout: 0s
164+
idleConnTimeout: 90s
165+
</pre>
166+
167+
All timeout values are Go duration strings (e.g. "30s", "5m", "1h").
168+
Use "0s" to disable a specific timeout. If not set, the <code>http.DefaultTransport</code>
169+
values from the Go standard library are used.
170+
171+
Note: <code>timeout</code> controls the overall <code>http.Client</code> request deadline and is
172+
independent of the transport-level settings. Setting only <code>timeout</code> does not
173+
affect <code>tcpDialTimeout</code>, <code>tlsHandshakeTimeout</code>, or other transport timeouts.
174+
`

0 commit comments

Comments
 (0)