Skip to content

Commit ae33628

Browse files
Add timeout and retry to framework version of provider (#151)
* Adding optional request_timeout and retry attributes (#149) Co-authored-by: Vincent Chenal <vincent.chenal@protonmail.com> * Fixing ExpectError in tests (#149) * Updates following code review (#149) --------- Co-authored-by: Vincent Chenal <vincent.chenal@protonmail.com>
1 parent c947898 commit ae33628

7 files changed

Lines changed: 553 additions & 180 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: ENHANCEMENTS
2+
body: 'data-source/http: Added `retry` with nested `attempts`, `max_delay_ms` and `min_delay_ms`'
3+
time: 2023-02-09T16:57:28.046924Z
4+
custom:
5+
Issue: "151"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: ENHANCEMENTS
2+
body: 'data-source/http: Added `request_timeout_ms`'
3+
time: 2023-02-09T16:57:47.790638Z
4+
custom:
5+
Issue: "151"

docs/data-sources/http.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ description: |-
1212
mechanism to authenticate the remote server except for general verification of
1313
the server certificate's chain of trust. Data retrieved from servers not under
1414
your control should be treated as untrustworthy.
15+
By default, there are no retries. Configuring the retry block will result in
16+
retries if an error is returned by the client (e.g., connection errors) or if
17+
a 5xx-range (except 501) status code is received. For further details see
18+
go-retryablehttp https://pkg.go.dev/github.com/hashicorp/go-retryablehttp.
1519
---
1620

1721
# http (Data Source)
@@ -29,6 +33,11 @@ mechanism to authenticate the remote server except for general verification of
2933
the server certificate's chain of trust. Data retrieved from servers not under
3034
your control should be treated as untrustworthy.
3135

36+
By default, there are no retries. Configuring the retry block will result in
37+
retries if an error is returned by the client (e.g., connection errors) or if
38+
a 5xx-range (except 501) status code is received. For further details see
39+
[go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp).
40+
3241
## Example Usage
3342

3443
```terraform
@@ -148,11 +157,22 @@ resource "null_resource" "example" {
148157
- `method` (String) The HTTP Method for the request. Allowed methods are a subset of methods defined in [RFC7231](https://datatracker.ietf.org/doc/html/rfc7231#section-4.3) namely, `GET`, `HEAD`, and `POST`. `POST` support is only intended for read-only URLs, such as submitting a search.
149158
- `request_body` (String) The request body as a string.
150159
- `request_headers` (Map of String) A map of request header field names and values.
160+
- `request_timeout_ms` (Number) The request timeout in milliseconds.
161+
- `retry` (Block, Optional) Retry request configuration. By default there are no retries. Configuring this block will result in retries if an error is returned by the client (e.g., connection errors) or if a 5xx-range (except 501) status code is received. For further details see [go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp). (see [below for nested schema](#nestedblock--retry))
151162

152163
### Read-Only
153164

154165
- `body` (String, Deprecated) The response body returned as a string. **NOTE**: This is deprecated, use `response_body` instead.
155166
- `id` (String) The URL used for the request.
156167
- `response_body` (String) The response body returned as a string.
157168
- `response_headers` (Map of String) A map of response header field names and values. Duplicate headers are concatenated according to [RFC2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2).
158-
- `status_code` (Number) The HTTP response status code.
169+
- `status_code` (Number) The HTTP response status code.
170+
171+
<a id="nestedblock--retry"></a>
172+
### Nested Schema for `retry`
173+
174+
Optional:
175+
176+
- `attempts` (Number) The number of times the request is to be retried. For example, if 2 is specified, the request will be tried a maximum of 3 times.
177+
- `max_delay_ms` (Number) The maximum delay between retry requests in milliseconds.
178+
- `min_delay_ms` (Number) The minimum delay between retry requests in milliseconds.

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ module github.com/terraform-providers/terraform-provider-http
33
go 1.19
44

55
require (
6+
github.com/google/uuid v1.3.0
7+
github.com/hashicorp/go-retryablehttp v0.7.1
68
github.com/hashicorp/terraform-plugin-docs v0.14.1
79
github.com/hashicorp/terraform-plugin-framework v1.2.0
810
github.com/hashicorp/terraform-plugin-framework-validators v0.10.0
911
github.com/hashicorp/terraform-plugin-go v0.15.0
12+
github.com/hashicorp/terraform-plugin-log v0.8.0
1013
github.com/hashicorp/terraform-plugin-testing v1.2.0
1114
golang.org/x/net v0.9.0
1215
)
@@ -22,7 +25,6 @@ require (
2225
github.com/fatih/color v1.13.0 // indirect
2326
github.com/golang/protobuf v1.5.2 // indirect
2427
github.com/google/go-cmp v0.5.9 // indirect
25-
github.com/google/uuid v1.3.0 // indirect
2628
github.com/hashicorp/errwrap v1.1.0 // indirect
2729
github.com/hashicorp/go-checkpoint v0.5.0 // indirect
2830
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -37,7 +39,6 @@ require (
3739
github.com/hashicorp/logutils v1.0.0 // indirect
3840
github.com/hashicorp/terraform-exec v0.18.1 // indirect
3941
github.com/hashicorp/terraform-json v0.16.0 // indirect
40-
github.com/hashicorp/terraform-plugin-log v0.8.0 // indirect
4142
github.com/hashicorp/terraform-plugin-sdk/v2 v2.26.1 // indirect
4243
github.com/hashicorp/terraform-registry-address v0.2.0 // indirect
4344
github.com/hashicorp/terraform-svchost v0.0.1 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,21 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
6565
github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU=
6666
github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg=
6767
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
68+
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
6869
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
6970
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
7071
github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI=
7172
github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs=
73+
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
7274
github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
7375
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
7476
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
7577
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
7678
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
7779
github.com/hashicorp/go-plugin v1.4.9 h1:ESiK220/qE0aGxWdzKIvRH69iLiuN/PjoLTm69RoWtU=
7880
github.com/hashicorp/go-plugin v1.4.9/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s=
81+
github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
82+
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
7983
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
8084
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
8185
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=

internal/provider/data_source_http.go

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,27 @@ import (
44
"context"
55
"crypto/tls"
66
"crypto/x509"
7+
"errors"
78
"fmt"
89
"io"
910
"mime"
1011
"net/http"
1112
"net/url"
1213
"regexp"
1314
"strings"
15+
"time"
1416

17+
"github.com/hashicorp/go-retryablehttp"
18+
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
1519
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
1620
"github.com/hashicorp/terraform-plugin-framework/datasource"
1721
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
1822
"github.com/hashicorp/terraform-plugin-framework/path"
1923
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
2024
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
2125
"github.com/hashicorp/terraform-plugin-framework/types"
26+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
27+
"github.com/hashicorp/terraform-plugin-log/tflog"
2228
"golang.org/x/net/http/httpproxy"
2329
)
2430

@@ -52,7 +58,13 @@ regardless of the returned content type header.
5258
~> **Important** Although ` + "`https`" + ` URLs can be used, there is currently no
5359
mechanism to authenticate the remote server except for general verification of
5460
the server certificate's chain of trust. Data retrieved from servers not under
55-
your control should be treated as untrustworthy.`,
61+
your control should be treated as untrustworthy.
62+
63+
By default, there are no retries. Configuring the retry block will result in
64+
retries if an error is returned by the client (e.g., connection errors) or if
65+
a 5xx-range (except 501) status code is received. For further details see
66+
[go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp).
67+
`,
5668

5769
Attributes: map[string]schema.Attribute{
5870
"id": schema.StringAttribute{
@@ -90,6 +102,14 @@ your control should be treated as untrustworthy.`,
90102
Optional: true,
91103
},
92104

105+
"request_timeout_ms": schema.Int64Attribute{
106+
Description: "The request timeout in milliseconds.",
107+
Optional: true,
108+
Validators: []validator.Int64{
109+
int64validator.AtLeast(1),
110+
},
111+
},
112+
93113
"response_body": schema.StringAttribute{
94114
Description: "The response body returned as a string.",
95115
Computed: true,
@@ -128,6 +148,38 @@ your control should be treated as untrustworthy.`,
128148
Computed: true,
129149
},
130150
},
151+
152+
Blocks: map[string]schema.Block{
153+
"retry": schema.SingleNestedBlock{
154+
Description: "Retry request configuration. By default there are no retries. Configuring this block will result in " +
155+
"retries if an error is returned by the client (e.g., connection errors) or if a 5xx-range (except 501) status code is received. " +
156+
"For further details see [go-retryablehttp](https://pkg.go.dev/github.com/hashicorp/go-retryablehttp).",
157+
Attributes: map[string]schema.Attribute{
158+
"attempts": schema.Int64Attribute{
159+
Description: "The number of times the request is to be retried. For example, if 2 is specified, the request will be tried a maximum of 3 times.",
160+
Optional: true,
161+
Validators: []validator.Int64{
162+
int64validator.AtLeast(0),
163+
},
164+
},
165+
"min_delay_ms": schema.Int64Attribute{
166+
Description: "The minimum delay between retry requests in milliseconds.",
167+
Optional: true,
168+
Validators: []validator.Int64{
169+
int64validator.AtLeast(0),
170+
},
171+
},
172+
"max_delay_ms": schema.Int64Attribute{
173+
Description: "The maximum delay between retry requests in milliseconds.",
174+
Optional: true,
175+
Validators: []validator.Int64{
176+
int64validator.AtLeast(0),
177+
int64validator.AtLeastSumOf(path.MatchRelative().AtParent().AtName("min_delay_ms")),
178+
},
179+
},
180+
},
181+
},
182+
},
131183
}
132184
}
133185

@@ -195,11 +247,38 @@ func (d *httpDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
195247
clonedTr.TLSClientConfig.RootCAs = caCertPool
196248
}
197249

198-
client := &http.Client{
199-
Transport: clonedTr,
250+
var retry retryModel
251+
252+
if !model.Retry.IsNull() && !model.Retry.IsUnknown() {
253+
diags = model.Retry.As(ctx, &retry, basetypes.ObjectAsOptions{})
254+
resp.Diagnostics.Append(diags...)
255+
if resp.Diagnostics.HasError() {
256+
return
257+
}
258+
}
259+
260+
retryClient := retryablehttp.NewClient()
261+
retryClient.HTTPClient.Transport = clonedTr
262+
263+
var timeout time.Duration
264+
265+
if model.RequestTimeout.ValueInt64() > 0 {
266+
timeout = time.Duration(model.RequestTimeout.ValueInt64()) * time.Millisecond
267+
retryClient.HTTPClient.Timeout = timeout
200268
}
201269

202-
request, err := http.NewRequestWithContext(ctx, method, requestURL, requestBody)
270+
retryClient.Logger = levelledLogger{ctx}
271+
retryClient.RetryMax = int(retry.Attempts.ValueInt64())
272+
273+
if !retry.MinDelay.IsNull() && !retry.MinDelay.IsUnknown() && retry.MinDelay.ValueInt64() >= 0 {
274+
retryClient.RetryWaitMin = time.Duration(retry.MinDelay.ValueInt64()) * time.Millisecond
275+
}
276+
277+
if !retry.MaxDelay.IsNull() && !retry.MaxDelay.IsUnknown() && retry.MaxDelay.ValueInt64() >= 0 {
278+
retryClient.RetryWaitMax = time.Duration(retry.MaxDelay.ValueInt64()) * time.Millisecond
279+
}
280+
281+
request, err := retryablehttp.NewRequestWithContext(ctx, method, requestURL, requestBody)
203282
if err != nil {
204283
resp.Diagnostics.AddError(
205284
"Error creating request",
@@ -219,8 +298,25 @@ func (d *httpDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
219298
request.Header.Set(name, header)
220299
}
221300

222-
response, err := client.Do(request)
301+
response, err := retryClient.Do(request)
223302
if err != nil {
303+
target := &url.Error{}
304+
if errors.As(err, &target) {
305+
if target.Timeout() {
306+
detail := fmt.Sprintf("timeout error: %s", err)
307+
308+
if timeout > 0 {
309+
detail = fmt.Sprintf("request exceeded the specified timeout: %s, err: %s", timeout.String(), err)
310+
}
311+
312+
resp.Diagnostics.AddError(
313+
"Error making request",
314+
detail,
315+
)
316+
return
317+
}
318+
}
319+
224320
resp.Diagnostics.AddError(
225321
"Error making request",
226322
fmt.Sprintf("Error making request: %s", err),
@@ -251,8 +347,7 @@ func (d *httpDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
251347

252348
responseHeaders := make(map[string]string)
253349
for k, v := range response.Header {
254-
// Concatenate according to RFC2616
255-
// cf. https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2
350+
// Concatenate according to RFC9110 https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2
256351
responseHeaders[k] = strings.Join(v, ", ")
257352
}
258353

@@ -304,10 +399,51 @@ type modelV0 struct {
304399
Method types.String `tfsdk:"method"`
305400
RequestHeaders types.Map `tfsdk:"request_headers"`
306401
RequestBody types.String `tfsdk:"request_body"`
402+
RequestTimeout types.Int64 `tfsdk:"request_timeout_ms"`
403+
Retry types.Object `tfsdk:"retry"`
307404
ResponseHeaders types.Map `tfsdk:"response_headers"`
308405
CaCertificate types.String `tfsdk:"ca_cert_pem"`
309406
Insecure types.Bool `tfsdk:"insecure"`
310407
ResponseBody types.String `tfsdk:"response_body"`
311408
Body types.String `tfsdk:"body"`
312409
StatusCode types.Int64 `tfsdk:"status_code"`
313410
}
411+
412+
type retryModel struct {
413+
Attempts types.Int64 `tfsdk:"attempts"`
414+
MinDelay types.Int64 `tfsdk:"min_delay_ms"`
415+
MaxDelay types.Int64 `tfsdk:"max_delay_ms"`
416+
}
417+
418+
var _ retryablehttp.LeveledLogger = levelledLogger{}
419+
420+
// levelledLogger is used to log messages from retryablehttp.Client to tflog.
421+
type levelledLogger struct {
422+
ctx context.Context
423+
}
424+
425+
func (l levelledLogger) Error(msg string, keysAndValues ...interface{}) {
426+
tflog.Error(l.ctx, msg, l.additionalFields(keysAndValues))
427+
}
428+
429+
func (l levelledLogger) Info(msg string, keysAndValues ...interface{}) {
430+
tflog.Info(l.ctx, msg, l.additionalFields(keysAndValues))
431+
}
432+
433+
func (l levelledLogger) Debug(msg string, keysAndValues ...interface{}) {
434+
tflog.Debug(l.ctx, msg, l.additionalFields(keysAndValues))
435+
}
436+
437+
func (l levelledLogger) Warn(msg string, keysAndValues ...interface{}) {
438+
tflog.Warn(l.ctx, msg, l.additionalFields(keysAndValues))
439+
}
440+
441+
func (l levelledLogger) additionalFields(keysAndValues []interface{}) map[string]interface{} {
442+
additionalFields := make(map[string]interface{}, len(keysAndValues))
443+
444+
for i := 0; i+1 < len(keysAndValues); i += 2 {
445+
additionalFields[fmt.Sprint(keysAndValues[i])] = keysAndValues[i+1]
446+
}
447+
448+
return additionalFields
449+
}

0 commit comments

Comments
 (0)