Skip to content

Commit b509b62

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 b509b62

19 files changed

Lines changed: 1027 additions & 13 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package httpcfgattr
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"ocm.software/ocm/api/datacontext"
8+
"ocm.software/ocm/api/utils/runtime"
9+
)
10+
11+
const (
12+
ATTR_KEY = "ocm.software/ocm/api/datacontext/attrs/httptimeout"
13+
ATTR_SHORT = "httpcfg"
14+
)
15+
16+
func init() {
17+
datacontext.RegisterAttributeType(ATTR_KEY, AttributeType{}, ATTR_SHORT)
18+
}
19+
20+
type AttributeType struct{}
21+
22+
func (a AttributeType) Name() string {
23+
return ATTR_KEY
24+
}
25+
26+
func (a AttributeType) Description() string {
27+
return `
28+
*JSON*
29+
Configures HTTP client timeout settings for OCI registry and remote endpoint access.
30+
Settings are provided as a JSON document matching the ` + ConfigType + ` config type.
31+
32+
For full control use the config file:
33+
<pre>
34+
type: ` + ConfigType + `
35+
global:
36+
timeout: 0s
37+
tcpDialTimeout: 30s
38+
tcpKeepAlive: 30s
39+
tlsHandshakeTimeout: 10s
40+
idleConnTimeout: 90s
41+
</pre>
42+
`
43+
}
44+
45+
func (a AttributeType) Encode(v interface{}, marshaller runtime.Marshaler) ([]byte, error) {
46+
cfg, ok := v.(*Config)
47+
if !ok {
48+
return nil, fmt.Errorf("*Config required for %s, got %T", ATTR_SHORT, v)
49+
}
50+
return marshaller.Marshal(cfg)
51+
}
52+
53+
func (a AttributeType) Decode(data []byte, unmarshaller runtime.Unmarshaler) (interface{}, error) {
54+
var cfg Config
55+
if err := unmarshaller.Unmarshal(data, &cfg); err != nil {
56+
return nil, fmt.Errorf("failed to decode %s: %w", ATTR_SHORT, err)
57+
}
58+
return &cfg, nil
59+
}
60+
61+
////////////////////////////////////////////////////////////////////////////////
62+
63+
// Get returns the configured HTTP client config from the context.
64+
// If not set, DefaultConfig is returned.
65+
func Get(ctx datacontext.Context) *Config {
66+
a := ctx.GetAttributes().GetAttribute(ATTR_KEY)
67+
if a == nil {
68+
return DefaultConfig()
69+
}
70+
switch v := a.(type) {
71+
case *Config:
72+
return v
73+
case time.Duration:
74+
// backward compat: old callers may have stored a plain time.Duration
75+
return NewConfig(v)
76+
default:
77+
return DefaultConfig()
78+
}
79+
}
80+
81+
// Set stores the HTTP client config attribute in the context.
82+
func Set(ctx datacontext.Context, cfg *Config) error {
83+
return ctx.GetAttributes().SetAttribute(ATTR_KEY, cfg)
84+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 config with no timeout", func() {
25+
cfg := httpcfgattr.Get(ctx)
26+
Expect(cfg).NotTo(BeNil())
27+
Expect(cfg.GetGlobal().GetTimeout()).To(Equal(time.Duration(0)))
28+
})
29+
30+
It("sets and retrieves config", func() {
31+
Expect(httpcfgattr.Set(ctx, httpcfgattr.NewConfig(5*time.Minute))).To(Succeed())
32+
Expect(httpcfgattr.Get(ctx).GetGlobal().GetTimeout()).To(Equal(5 * time.Minute))
33+
})
34+
})
35+
36+
Context("encoding", func() {
37+
It("encodes *Config to JSON", func() {
38+
cfg := httpcfgattr.NewConfig(30 * time.Second)
39+
data, err := attr.Encode(cfg, enc)
40+
Expect(err).To(Succeed())
41+
Expect(string(data)).To(ContainSubstring(`"timeout":"30s"`))
42+
})
43+
44+
It("rejects non-*Config input", func() {
45+
_, err := attr.Encode("invalid", enc)
46+
Expect(err).To(HaveOccurred())
47+
Expect(err.Error()).To(ContainSubstring("*Config required"))
48+
})
49+
})
50+
51+
Context("decoding", func() {
52+
It("decodes JSON to *Config", func() {
53+
raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","global":{"timeout":"10s"}}`)
54+
val, err := attr.Decode(raw, enc)
55+
Expect(err).To(Succeed())
56+
cfg, ok := val.(*httpcfgattr.Config)
57+
Expect(ok).To(BeTrue())
58+
Expect(cfg.GetGlobal().GetTimeout()).To(Equal(10 * time.Second))
59+
})
60+
61+
It("rejects invalid JSON", func() {
62+
_, err := attr.Decode([]byte(`{invalid`), enc)
63+
Expect(err).To(HaveOccurred())
64+
Expect(err.Error()).To(ContainSubstring("failed to decode"))
65+
})
66+
})
67+
})
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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, configUsage))
20+
cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigTypeV1Alpha1, configUsage))
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+
type HTTPSettings struct {
58+
// Timeout is the overall HTTP client timeout.
59+
// If not set, http.Client uses no timeout (0).
60+
Timeout *Duration `json:"timeout,omitempty"`
61+
62+
// TCPDialTimeout is the time limit for establishing a TCP connection.
63+
TCPDialTimeout *Duration `json:"tcpDialTimeout,omitempty"`
64+
65+
// TCPKeepAlive is the interval between TCP keep-alive probes.
66+
TCPKeepAlive *Duration `json:"tcpKeepAlive,omitempty"`
67+
68+
// TLSHandshakeTimeout is the maximum time to wait for a TLS handshake.
69+
TLSHandshakeTimeout *Duration `json:"tlsHandshakeTimeout,omitempty"`
70+
71+
// ResponseHeaderTimeout is the time limit to wait for response headers.
72+
ResponseHeaderTimeout *Duration `json:"responseHeaderTimeout,omitempty"`
73+
74+
// IdleConnTimeout is the maximum time an idle connection remains open.
75+
IdleConnTimeout *Duration `json:"idleConnTimeout,omitempty"`
76+
}
77+
78+
// Config describes the configuration for HTTP client settings.
79+
type Config struct {
80+
runtime.ObjectVersionedType `json:",inline"`
81+
82+
// Global settings apply to all HTTP requests unless overridden per-host.
83+
Global *HTTPSettings `json:"global,omitempty"`
84+
}
85+
86+
// NewConfig creates a new HTTP config with the given overall timeout.
87+
func NewConfig(timeout time.Duration) *Config {
88+
return &Config{
89+
ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType),
90+
Global: &HTTPSettings{
91+
Timeout: NewDuration(timeout),
92+
},
93+
}
94+
}
95+
96+
// DefaultHTTPSettings returns an empty HTTPSettings.
97+
// When no fields are set, consumers should fall back to http.DefaultTransport defaults.
98+
func DefaultHTTPSettings() *HTTPSettings {
99+
return &HTTPSettings{}
100+
}
101+
102+
// DefaultConfig returns a Config with empty settings.
103+
func DefaultConfig() *Config {
104+
return &Config{
105+
ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType),
106+
Global: DefaultHTTPSettings(),
107+
}
108+
}
109+
110+
// MergeHTTPSettings merges src into dst. Non-nil fields in src override dst.
111+
func MergeHTTPSettings(dst, src *HTTPSettings) *HTTPSettings {
112+
if dst == nil {
113+
dst = &HTTPSettings{}
114+
}
115+
if src == nil {
116+
return dst
117+
}
118+
if src.Timeout != nil {
119+
dst.Timeout = src.Timeout
120+
}
121+
if src.TCPDialTimeout != nil {
122+
dst.TCPDialTimeout = src.TCPDialTimeout
123+
}
124+
if src.TCPKeepAlive != nil {
125+
dst.TCPKeepAlive = src.TCPKeepAlive
126+
}
127+
if src.TLSHandshakeTimeout != nil {
128+
dst.TLSHandshakeTimeout = src.TLSHandshakeTimeout
129+
}
130+
if src.ResponseHeaderTimeout != nil {
131+
dst.ResponseHeaderTimeout = src.ResponseHeaderTimeout
132+
}
133+
if src.IdleConnTimeout != nil {
134+
dst.IdleConnTimeout = src.IdleConnTimeout
135+
}
136+
return dst
137+
}
138+
139+
// Merge merges src into dst. Non-nil fields in src.Global override dst.Global.
140+
func Merge(dst, src *Config) *Config {
141+
if dst == nil {
142+
dst = &Config{ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType)}
143+
}
144+
if src == nil {
145+
return dst
146+
}
147+
dst.Global = MergeHTTPSettings(dst.Global, src.Global)
148+
return dst
149+
}
150+
151+
// GetGlobal returns the Global settings. If nil, returns empty HTTPSettings
152+
// so that consumers fall back to http.DefaultTransport defaults.
153+
func (c *Config) GetGlobal() *HTTPSettings {
154+
if c == nil || c.Global == nil {
155+
return &HTTPSettings{}
156+
}
157+
return c.Global
158+
}
159+
160+
// GetTimeout returns the overall HTTP client timeout.
161+
// Returns 0 (disabled) if not set.
162+
func (s *HTTPSettings) GetTimeout() time.Duration {
163+
if s == nil || s.Timeout == nil {
164+
return 0
165+
}
166+
return s.Timeout.Duration
167+
}
168+
169+
func (a *Config) GetType() string {
170+
return ConfigType
171+
}
172+
173+
func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error {
174+
t, ok := target.(cfgcpi.Context)
175+
if !ok {
176+
return cfgcpi.ErrNoContext(ConfigType)
177+
}
178+
existing := getFromContext(t)
179+
merged := Merge(existing, a)
180+
return errors.Wrapf(t.GetAttributes().SetAttribute(ATTR_KEY, merged), "applying config failed")
181+
}
182+
183+
func getFromContext(ctx cfgcpi.Context) *Config {
184+
attr := ctx.GetAttributes().GetAttribute(ATTR_KEY)
185+
if attr == nil {
186+
return nil
187+
}
188+
if c, ok := attr.(*Config); ok {
189+
return c
190+
}
191+
return nil
192+
}
193+
194+
const configUsage = `
195+
The config type <code>` + ConfigType + `</code> can be used to configure
196+
HTTP client settings:
197+
198+
<pre>
199+
type: ` + ConfigType + `
200+
global:
201+
timeout: 0s
202+
tcpDialTimeout: 30s
203+
tcpKeepAlive: 30s
204+
tlsHandshakeTimeout: 10s
205+
responseHeaderTimeout: 0s
206+
idleConnTimeout: 90s
207+
</pre>
208+
209+
All timeout values are Go duration strings (e.g. "30s", "5m", "1h").
210+
Use "0s" to disable a specific timeout. If not set, the <code>http.DefaultTransport</code>
211+
values from the Go standard library are used.
212+
`

0 commit comments

Comments
 (0)