Skip to content

Commit 2ed48d4

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. When not set, `http.DefaultTransport` values are preserved unchanged. - 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 2ed48d4

19 files changed

Lines changed: 998 additions & 13 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package httpcfgattr
2+
3+
import (
4+
"time"
5+
6+
"github.com/mandelsoft/goutils/errors"
7+
8+
"ocm.software/ocm/api/datacontext"
9+
"ocm.software/ocm/api/utils/runtime"
10+
)
11+
12+
type (
13+
Context = datacontext.AttributesContext
14+
ContextProvider = datacontext.ContextProvider
15+
)
16+
17+
const (
18+
ATTR_KEY = "ocm.software/ocm/api/datacontext/attrs/httpcfg"
19+
ATTR_SHORT = "httpcfg"
20+
)
21+
22+
func init() {
23+
datacontext.RegisterAttributeType(ATTR_KEY, AttributeType{}, ATTR_SHORT)
24+
}
25+
26+
type AttributeType struct{}
27+
28+
func (a AttributeType) Name() string {
29+
return ATTR_KEY
30+
}
31+
32+
func (a AttributeType) Description() string {
33+
return `
34+
*JSON*
35+
Configures HTTP client timeout settings for OCI registry and remote endpoint access.
36+
Settings are provided as a JSON document matching the ` + ConfigType + ` config type.
37+
38+
For full control use the config file:
39+
<pre>
40+
type: ` + ConfigType + `
41+
timeout: 0s
42+
tcpDialTimeout: 30s
43+
tcpKeepAlive: 30s
44+
tlsHandshakeTimeout: 10s
45+
idleConnTimeout: 90s
46+
</pre>
47+
`
48+
}
49+
50+
func (a AttributeType) Encode(v interface{}, marshaller runtime.Marshaler) ([]byte, error) {
51+
attr, ok := v.(*Attribute)
52+
if !ok {
53+
return nil, errors.ErrInvalid("httpcfg attribute")
54+
}
55+
cfg := New()
56+
cfg.Timeout = durationPtrToString(attr.timeout)
57+
cfg.TCPDialTimeout = durationPtrToString(attr.tcpDialTimeout)
58+
cfg.TCPKeepAlive = durationPtrToString(attr.tcpKeepAlive)
59+
cfg.TLSHandshakeTimeout = durationPtrToString(attr.tlsHandshakeTimeout)
60+
cfg.ResponseHeaderTimeout = durationPtrToString(attr.responseHeaderTimeout)
61+
cfg.IdleConnTimeout = durationPtrToString(attr.idleConnTimeout)
62+
63+
return marshaller.Marshal(cfg)
64+
}
65+
66+
func (a AttributeType) Decode(data []byte, unmarshaller runtime.Unmarshaler) (interface{}, error) {
67+
var value Config
68+
err := unmarshaller.Unmarshal(data, &value)
69+
if err != nil {
70+
return nil, err
71+
}
72+
73+
attr := &Attribute{}
74+
err = value.ApplyToAttribute(attr)
75+
if err != nil {
76+
return nil, err
77+
}
78+
return attr, nil
79+
}
80+
81+
////////////////////////////////////////////////////////////////////////////////
82+
83+
// Attribute holds the effective HTTP client settings for a context.
84+
// nil means "not configured" (use transport default), non-nil means
85+
// explicitly set (including 0 which disables the timeout).
86+
type Attribute struct {
87+
timeout *time.Duration
88+
tcpDialTimeout *time.Duration
89+
tcpKeepAlive *time.Duration
90+
tlsHandshakeTimeout *time.Duration
91+
responseHeaderTimeout *time.Duration
92+
idleConnTimeout *time.Duration
93+
}
94+
95+
// durationPtrToString converts a *time.Duration to string, returning "" for nil.
96+
func durationPtrToString(d *time.Duration) string {
97+
if d == nil {
98+
return ""
99+
}
100+
return d.String()
101+
}
102+
103+
func (a *Attribute) GetTimeout() *time.Duration { return a.timeout }
104+
func (a *Attribute) GetTCPDialTimeout() *time.Duration { return a.tcpDialTimeout }
105+
func (a *Attribute) GetTCPKeepAlive() *time.Duration { return a.tcpKeepAlive }
106+
func (a *Attribute) GetTLSHandshakeTimeout() *time.Duration { return a.tlsHandshakeTimeout }
107+
func (a *Attribute) GetResponseHeaderTimeout() *time.Duration { return a.responseHeaderTimeout }
108+
func (a *Attribute) GetIdleConnTimeout() *time.Duration { return a.idleConnTimeout }
109+
110+
////////////////////////////////////////////////////////////////////////////////
111+
112+
// Get returns the HTTP client attribute from the context.
113+
// If not set, a default empty Attribute is created and stored.
114+
func Get(ctx ContextProvider) *Attribute {
115+
return ctx.AttributesContext().GetAttributes().GetOrCreateAttribute(ATTR_KEY, func(datacontext.Context) interface{} {
116+
return &Attribute{}
117+
}).(*Attribute)
118+
}
119+
120+
// Set stores the HTTP client attribute in the context.
121+
func Set(ctx ContextProvider, attr *Attribute) error {
122+
return ctx.AttributesContext().GetAttributes().SetAttribute(ATTR_KEY, attr)
123+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 nil with no timeout", func() {
25+
a := httpcfgattr.Get(ctx)
26+
Expect(a).NotTo(BeNil())
27+
Expect(a.GetTimeout()).To(BeNil())
28+
})
29+
30+
It("round-trips through encode/decode", func() {
31+
a := httpcfgattr.Get(ctx)
32+
cfg := httpcfgattr.New()
33+
cfg.Timeout = "30s"
34+
Expect(cfg.ApplyToAttribute(a)).To(Succeed())
35+
36+
d := httpcfgattr.Get(ctx).GetTimeout()
37+
Expect(d).NotTo(BeNil())
38+
Expect(*d).To(Equal(30 * time.Second))
39+
})
40+
})
41+
42+
Context("encoding", func() {
43+
It("encodes *Attribute to JSON", func() {
44+
a := httpcfgattr.Get(ctx)
45+
cfg := httpcfgattr.New()
46+
cfg.Timeout = "30s"
47+
Expect(cfg.ApplyToAttribute(a)).To(Succeed())
48+
49+
data, err := attr.Encode(a, enc)
50+
Expect(err).To(Succeed())
51+
Expect(string(data)).To(ContainSubstring(`"timeout":"30s"`))
52+
})
53+
54+
It("encodes explicit zero duration as 0s, not omitted", func() {
55+
a := httpcfgattr.Get(ctx)
56+
cfg := httpcfgattr.New()
57+
cfg.Timeout = "0s"
58+
Expect(cfg.ApplyToAttribute(a)).To(Succeed())
59+
60+
data, err := attr.Encode(a, enc)
61+
Expect(err).To(Succeed())
62+
Expect(string(data)).To(ContainSubstring(`"timeout":"0s"`))
63+
})
64+
65+
It("rejects non-*Attribute input", func() {
66+
_, err := attr.Encode("invalid", enc)
67+
Expect(err).To(HaveOccurred())
68+
Expect(err.Error()).To(ContainSubstring("is invalid"))
69+
})
70+
})
71+
72+
Context("decoding", func() {
73+
It("decodes JSON to *Attribute", func() {
74+
raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"10s"}`)
75+
val, err := attr.Decode(raw, enc)
76+
Expect(err).To(Succeed())
77+
a, ok := val.(*httpcfgattr.Attribute)
78+
Expect(ok).To(BeTrue())
79+
Expect(a.GetTimeout()).NotTo(BeNil())
80+
Expect(*a.GetTimeout()).To(Equal(10 * time.Second))
81+
})
82+
83+
It("rejects invalid JSON", func() {
84+
_, err := attr.Decode([]byte(`{invalid`), enc)
85+
Expect(err).To(HaveOccurred())
86+
})
87+
})
88+
})
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package httpcfgattr
2+
3+
import (
4+
"fmt"
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+
// Config describes the configuration for HTTP client settings.
24+
type Config struct {
25+
runtime.ObjectVersionedType `json:",inline"`
26+
27+
// Timeout is the overall http.Client timeout.
28+
Timeout string `json:"timeout,omitempty"`
29+
30+
// TCPDialTimeout is the time limit for establishing a TCP connection.
31+
TCPDialTimeout string `json:"tcpDialTimeout,omitempty"`
32+
33+
// TCPKeepAlive is the interval between TCP keep-alive probes.
34+
TCPKeepAlive string `json:"tcpKeepAlive,omitempty"`
35+
36+
// TLSHandshakeTimeout is the maximum time to wait for a TLS handshake.
37+
TLSHandshakeTimeout string `json:"tlsHandshakeTimeout,omitempty"`
38+
39+
// ResponseHeaderTimeout is the time limit to wait for response headers.
40+
ResponseHeaderTimeout string `json:"responseHeaderTimeout,omitempty"`
41+
42+
// IdleConnTimeout is the maximum time an idle connection remains open.
43+
IdleConnTimeout string `json:"idleConnTimeout,omitempty"`
44+
}
45+
46+
// New creates a new empty HTTP Config.
47+
func New() *Config {
48+
return &Config{
49+
ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType),
50+
}
51+
}
52+
53+
func (a *Config) GetType() string {
54+
return ConfigType
55+
}
56+
57+
func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error {
58+
if t, ok := target.(Context); ok {
59+
if t.AttributesContext().IsAttributesContext() { // apply only to root context
60+
return errors.Wrapf(a.ApplyToAttribute(Get(t)), "applying config failed")
61+
}
62+
}
63+
return cfgcpi.ErrNoContext(ConfigType)
64+
}
65+
66+
// parseDuration parses a duration string, returning nil if empty.
67+
func parseDuration(s string) (*time.Duration, error) {
68+
if s == "" {
69+
return nil, nil
70+
}
71+
d, err := time.ParseDuration(s)
72+
if err != nil {
73+
return nil, fmt.Errorf("invalid duration %q: %w", s, err)
74+
}
75+
return &d, nil
76+
}
77+
78+
// ApplyToAttribute merges this config's settings into an existing attribute.
79+
func (a *Config) ApplyToAttribute(attr *Attribute) error {
80+
var errs error
81+
82+
if d, err := parseDuration(a.Timeout); err != nil {
83+
errs = errors.Join(errs, err)
84+
} else if d != nil {
85+
attr.timeout = d
86+
}
87+
if d, err := parseDuration(a.TCPDialTimeout); err != nil {
88+
errs = errors.Join(errs, err)
89+
} else if d != nil {
90+
attr.tcpDialTimeout = d
91+
}
92+
if d, err := parseDuration(a.TCPKeepAlive); err != nil {
93+
errs = errors.Join(errs, err)
94+
} else if d != nil {
95+
attr.tcpKeepAlive = d
96+
}
97+
if d, err := parseDuration(a.TLSHandshakeTimeout); err != nil {
98+
errs = errors.Join(errs, err)
99+
} else if d != nil {
100+
attr.tlsHandshakeTimeout = d
101+
}
102+
if d, err := parseDuration(a.ResponseHeaderTimeout); err != nil {
103+
errs = errors.Join(errs, err)
104+
} else if d != nil {
105+
attr.responseHeaderTimeout = d
106+
}
107+
if d, err := parseDuration(a.IdleConnTimeout); err != nil {
108+
errs = errors.Join(errs, err)
109+
} else if d != nil {
110+
attr.idleConnTimeout = d
111+
}
112+
113+
return errs
114+
}
115+
116+
const usage = `
117+
The config type <code>` + ConfigType + `</code> can be used to configure
118+
HTTP client settings:
119+
120+
<pre>
121+
type: ` + ConfigType + `
122+
timeout: 0s
123+
tcpDialTimeout: 30s
124+
tcpKeepAlive: 30s
125+
tlsHandshakeTimeout: 10s
126+
responseHeaderTimeout: 0s
127+
idleConnTimeout: 90s
128+
</pre>
129+
130+
All timeout values are Go duration strings (e.g. "30s", "5m", "1h").
131+
Use "0s" to disable a specific timeout. If not set, the <code>http.DefaultTransport</code>
132+
values from the Go standard library are used.
133+
134+
Note: <code>timeout</code> controls the overall <code>http.Client</code> request deadline and is
135+
independent of the transport-level settings. Setting only <code>timeout</code> does not
136+
affect <code>tcpDialTimeout</code>, <code>tlsHandshakeTimeout</code>, or other transport timeouts.
137+
`

0 commit comments

Comments
 (0)