diff --git a/docs/index.md b/docs/index.md index e697833..3c50a15 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,7 @@ provider "hydra" { ### Optional - `authentication` (Block List, Max: 1) Optional block to specify an authentication method which is used to access Hydra Admin API. (see [below for nested schema](#nestedblock--authentication)) +- `retry` (Block List, Max: 1) Optional block to configure retry behavior for API requests. (see [below for nested schema](#nestedblock--retry)) ### Nested Schema for `authentication` @@ -88,4 +89,16 @@ Required: Optional: -- `insecure_skip_verify` (Boolean) Controls whether a client verifies the server's certificate chain and host name. \ No newline at end of file +- `insecure_skip_verify` (Boolean) Controls whether a client verifies the server's certificate chain and host name. + + + + +### Nested Schema for `retry` + +Optional: + +- `enabled` (Boolean) Enable or disable retry behavior. +- `max_elapsed_time` (String) Maximum time to spend retrying requests. +- `max_interval` (String) Maximum interval between retries. +- `randomization_factor` (Number) Randomization factor to add jitter to retry intervals. \ No newline at end of file diff --git a/go.mod b/go.mod index 9d92e94..e3ed5c5 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 github.com/cloudflare/circl v1.3.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.5.3 // indirect diff --git a/go.sum b/go.sum index 3b1c7b3..5e6c520 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/internal/provider/data_source_jwks.go b/internal/provider/data_source_jwks.go index c6f01a2..fe6f4c0 100644 --- a/internal/provider/data_source_jwks.go +++ b/internal/provider/data_source_jwks.go @@ -2,6 +2,7 @@ package provider import ( "context" + "net/http" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -31,8 +32,17 @@ A JSON Web Key is identified by its set and key id. ORY Hydra uses this function func readJWKSDataSource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { data.SetId(data.Get("name").(string)) - hydraClient := meta.(*hydra.APIClient) - jsonWebKeySet, _, err := hydraClient.JwkApi.GetJsonWebKeySet(ctx, data.Id()).Execute() + hydraClient := meta.(*HydraConfig).hydraClient + + var jsonWebKeySet *hydra.JsonWebKeySet + + err := retryThrottledHydraAction(func() (*http.Response, error) { + var err error + var resp *http.Response + jsonWebKeySet, resp, err = hydraClient.JwkApi.GetJsonWebKeySet(ctx, data.Id()).Execute() + return resp, err + }, meta.(*HydraConfig).backOff) + if err != nil { return diag.FromErr(err) } diff --git a/internal/provider/helper.go b/internal/provider/helper.go index 09eadac..9aaa4b0 100644 --- a/internal/provider/helper.go +++ b/internal/provider/helper.go @@ -1,8 +1,11 @@ package provider import ( + "fmt" + "net/http" "time" + "github.com/cenkalti/backoff/v4" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -32,3 +35,41 @@ func diffSuppressMatchingDurationStrings(k, old, new string, d *schema.ResourceD return oldDuration == newDuration } + +// retryThrottledHydraAction executes the fn function and if backOff is set, retries the function if the request is throttled. +func retryThrottledHydraAction(fn func() (*http.Response, error), backOff *backoff.ExponentialBackOff) error { + if backOff == nil { + _, err := fn() + return err + } + + retryAction := func() error { + resp, err := fn() + + if err != nil { + if resp != nil && resp.StatusCode == http.StatusTooManyRequests { + fmt.Println("Throttled, retrying...") + return err + } + + return backoff.Permanent(err) + } + + return nil + } + + return backoff.Retry(retryAction, backOff) +} + +func validateDuration(val interface{}, key string) (ws []string, errors []error) { + v, ok := val.(string) + if !ok { + errors = append(errors, fmt.Errorf("expected type of %s to be string", key)) + return + } + + if _, err := time.ParseDuration(v); err != nil { + errors = append(errors, fmt.Errorf("%q must be a valid duration string: %s", key, err)) + } + return +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1b208ac..2ba3a09 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,7 +7,9 @@ import ( "net/http" "net/url" "strings" + "time" + "github.com/cenkalti/backoff/v4" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -20,6 +22,11 @@ func init() { schema.DescriptionKind = schema.StringMarkdown } +type HydraConfig struct { + hydraClient *hydra.APIClient + backOff *backoff.ExponentialBackOff +} + func New() *schema.Provider { return &schema.Provider{ Schema: map[string]*schema.Schema{ @@ -28,6 +35,42 @@ func New() *schema.Provider { Required: true, DefaultFunc: schema.EnvDefaultFunc("HYDRA_ADMIN_URL", nil), }, + "retry": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Optional block to configure retry behavior for API requests.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Enable or disable retry behavior.", + }, + "max_elapsed_time": { + Type: schema.TypeString, + Optional: true, + Default: "30s", + Description: "Maximum time to spend retrying requests.", + ValidateFunc: validateDuration, + }, + "max_interval": { + Type: schema.TypeString, + Optional: true, + Default: "3s", + Description: "Maximum interval between retries.", + ValidateFunc: validateDuration, + }, + "randomization_factor": { + Type: schema.TypeFloat, + Optional: true, + Default: 0.5, + Description: "Randomization factor to add jitter to retry intervals.", + }, + }, + }, + }, "authentication": { Type: schema.TypeList, Optional: true, @@ -188,7 +231,25 @@ func providerConfigure(ctx context.Context, data *schema.ResourceData) (interfac }, } - return hydra.NewAPIClient(cfg), nil + var backOff *backoff.ExponentialBackOff + if retry, ok := data.GetOk("retry.0"); ok && data.Get("retry.0.enabled").(bool) { + backOff = backoff.NewExponentialBackOff() + + retryConfig := retry.(map[string]interface{}) + + maxElapsedTime, _ := time.ParseDuration(retryConfig["max_elapsed_time"].(string)) + maxInterval, _ := time.ParseDuration(retryConfig["max_interval"].(string)) + randomizationFactor := retryConfig["randomization_factor"].(float64) + + backOff.MaxElapsedTime = maxElapsedTime + backOff.MaxInterval = maxInterval + backOff.RandomizationFactor = randomizationFactor + } + + return &HydraConfig{ + hydraClient: hydra.NewAPIClient(cfg), + backOff: backOff, + }, nil } func configureHTTPClient(data *schema.ResourceData) (*http.Client, error) { diff --git a/internal/provider/resource_jwks.go b/internal/provider/resource_jwks.go index 8b98356..9aee068 100644 --- a/internal/provider/resource_jwks.go +++ b/internal/provider/resource_jwks.go @@ -2,6 +2,7 @@ package provider import ( "context" + "net/http" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -76,13 +77,16 @@ func createJWKSResource(ctx context.Context, data *schema.ResourceData, meta int } func generateJWKSResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient setName := data.Get("name").(string) generators := data.Get("generator").([]interface{}) generator := generators[0].(map[string]interface{}) - _, _, err := hydraClient.JwkApi.CreateJsonWebKeySet(ctx, setName).CreateJsonWebKeySet(*dataToJWKGeneratorRequest(generator)).Execute() + err := retryThrottledHydraAction(func() (*http.Response, error) { + _, resp, err := hydraClient.JwkApi.CreateJsonWebKeySet(ctx, setName).CreateJsonWebKeySet(*dataToJWKGeneratorRequest(generator)).Execute() + return resp, err + }, meta.(*HydraConfig).backOff) if err != nil { return diag.FromErr(err) } @@ -93,8 +97,15 @@ func generateJWKSResource(ctx context.Context, data *schema.ResourceData, meta i } func readJWKSResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) - jsonWebKeySet, _, err := hydraClient.JwkApi.GetJsonWebKeySet(ctx, data.Id()).Execute() + hydraClient := meta.(*HydraConfig).hydraClient + var jsonWebKeySet *hydra.JsonWebKeySet + + err := retryThrottledHydraAction(func() (*http.Response, error) { + var err error + var resp *http.Response + jsonWebKeySet, resp, err = hydraClient.JwkApi.GetJsonWebKeySet(ctx, data.Id()).Execute() + return resp, err + }, meta.(*HydraConfig).backOff) if err != nil { return diag.FromErr(err) } @@ -105,11 +116,14 @@ func readJWKSResource(ctx context.Context, data *schema.ResourceData, meta inter } func updateJWKSResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient setName := data.Get("name").(string) - _, _, err := hydraClient.JwkApi.SetJsonWebKeySet(ctx, setName).JsonWebKeySet(*dataToJWKS(data, "key")).Execute() + err := retryThrottledHydraAction(func() (*http.Response, error) { + _, resp, err := hydraClient.JwkApi.SetJsonWebKeySet(ctx, setName).JsonWebKeySet(*dataToJWKS(data, "key")).Execute() + return resp, err + }, meta.(*HydraConfig).backOff) if err != nil { return diag.FromErr(err) } @@ -120,11 +134,13 @@ func updateJWKSResource(ctx context.Context, data *schema.ResourceData, meta int } func deleteJWKSResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient setName := data.Get("name").(string) - _, err := hydraClient.JwkApi.DeleteJsonWebKeySet(ctx, setName).Execute() + err := retryThrottledHydraAction(func() (*http.Response, error) { + return hydraClient.JwkApi.DeleteJsonWebKeySet(ctx, setName).Execute() + }, meta.(*HydraConfig).backOff) if err != nil { return diag.FromErr(err) } diff --git a/internal/provider/resource_oauth2_client.go b/internal/provider/resource_oauth2_client.go index 69ce239..0973bd2 100644 --- a/internal/provider/resource_oauth2_client.go +++ b/internal/provider/resource_oauth2_client.go @@ -3,7 +3,7 @@ package provider import ( "context" "errors" - "fmt" + "net/http" "regexp" "strings" @@ -322,11 +322,18 @@ The default, if omitted, is for the UserInfo Response to return the Claims as a } func createOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient + + var oAuth2Client *hydra.OAuth2Client client := dataToClient(data) - oAuth2Client, _, err := hydraClient.OAuth2Api.CreateOAuth2Client(ctx).OAuth2Client(*client).Execute() + err := retryThrottledHydraAction(func() (*http.Response, error) { + var err error + var resp *http.Response + oAuth2Client, resp, err = hydraClient.OAuth2Api.CreateOAuth2Client(ctx).OAuth2Client(*client).Execute() + return resp, err + }, meta.(*HydraConfig).backOff) if err != nil { return diag.FromErr(err) } @@ -335,18 +342,27 @@ func createOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, } func readOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient + + var oAuth2Client *hydra.OAuth2Client + + err := retryThrottledHydraAction(func() (*http.Response, error) { + var resp *http.Response + var err error - oAuth2Client, _, err := hydraClient.OAuth2Api.GetOAuth2Client(ctx, data.Id()).Execute() + oAuth2Client, resp, err = hydraClient.OAuth2Api.GetOAuth2Client(ctx, data.Id()).Execute() + + return resp, err + }, meta.(*HydraConfig).backOff) if err != nil { var genericOpenAPIError *hydra.GenericOpenAPIError - if errors.As(err, genericOpenAPIError) { - if err, ok := genericOpenAPIError.Model().(hydra.ErrorOAuth2); ok && err.StatusCode != nil && *err.StatusCode == 401 { + if errors.As(err, &genericOpenAPIError) { + if apiError, ok := genericOpenAPIError.Model().(hydra.ErrorOAuth2); ok && apiError.StatusCode != nil && *apiError.StatusCode == 401 { data.SetId("") return nil } } - fmt.Println("Error2 SVH: ", err) + return diag.FromErr(err) } @@ -354,11 +370,17 @@ func readOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, me } func updateOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient oAuthClient := dataToClient(data) - oAuthClient, _, err := hydraClient.OAuth2Api.SetOAuth2Client(ctx, data.Id()).OAuth2Client(*oAuthClient).Execute() + err := retryThrottledHydraAction(func() (*http.Response, error) { + var err error + var resp *http.Response + oAuthClient, resp, err = hydraClient.OAuth2Api.SetOAuth2Client(ctx, data.Id()).OAuth2Client(*oAuthClient).Execute() + + return resp, err + }, meta.(*HydraConfig).backOff) if err != nil { return diag.FromErr(err) } @@ -367,9 +389,11 @@ func updateOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, } func deleteOAuth2ClientResource(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { - hydraClient := meta.(*hydra.APIClient) + hydraClient := meta.(*HydraConfig).hydraClient - _, err := hydraClient.OAuth2Api.DeleteOAuth2Client(ctx, data.Id()).Execute() + err := retryThrottledHydraAction(func() (*http.Response, error) { + return hydraClient.OAuth2Api.DeleteOAuth2Client(ctx, data.Id()).Execute() + }, meta.(*HydraConfig).backOff) return diag.FromErr(err) }