Skip to content

Commit d56a460

Browse files
Merge pull request #96 from trimble-oss/apiCallerChanges
Add support for url encoded endpoints and retrying for specific error…
2 parents b8f699c + 0030870 commit d56a460

2 files changed

Lines changed: 83 additions & 14 deletions

File tree

api/api.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type EndpointType string
2626
const (
2727
// EndpointTypeREST represents a REST API endpoint
2828
EndpointTypeREST EndpointType = "rest"
29+
// EndpointTypeFormURLEncoded represents a REST API endpoint using application/x-www-form-urlencoded
30+
EndpointTypeFormURLEncoded EndpointType = "form-urlencoded"
2931
// EndpointTypeGRPC represents a gRPC API endpoint
3032
EndpointTypeGRPC EndpointType = "grpc"
3133
// EndpointTypeSOAP represents a SOAP API endpoint
@@ -38,7 +40,7 @@ type Endpoint struct {
3840
FriendlyName string
3941
// URL is the endpoint URL/address
4042
URL string
41-
// Type is the type of endpoint (REST, gRPC, SOAP)
43+
// Type is the type of endpoint (REST, form-urlencoded REST, gRPC, SOAP)
4244
Type EndpointType
4345
// Timeout is the timeout duration for API calls
4446
// If set to 0 or unset, defaults to 30 seconds
@@ -54,6 +56,11 @@ type Endpoint struct {
5456
// If set to 0 or unset, no retries are attempted
5557
// Retries use exponential backoff: 1s, 2s, 4s, 8s, etc.
5658
MaxRetries int
59+
// RetryErrorRange is the hundredths place of the error code range for which retries should be attempted
60+
// If set to 0 or unset, retries are attempted for all error codes based on MaxRetries
61+
// For example, if set to 5, retries will only be attempted for 5xx error codes (e.g., 500-599)
62+
// This allows for more granular control over which errors should trigger retries
63+
RetryErrorRange int
5764
// Config is the optional API caller configuration (TLS, certificates, etc.)
5865
// If set on the Endpoint, it will be used as the default for all calls
5966
// Can be overridden by passing a non-nil config to Call()
@@ -68,6 +75,8 @@ type Endpoint struct {
6875
// - "body" (any): Request body
6976
// REST-specific parameters:
7077
// - "headers" (map[string]string): HTTP headers
78+
// Form-urlencoded REST parameters:
79+
// - "body" should be url.Values, map[string]string, map[string][]string, or map[string]any
7180
// SOAP-specific parameters:
7281
// - "soapAction" (string): SOAP action header
7382
// - "headers" (map[string]string): Additional HTTP headers
@@ -154,6 +163,10 @@ func (e *Endpoint) Call(params map[string]any) (map[string]any, error) {
154163
if maxRetries < 0 {
155164
maxRetries = 0
156165
}
166+
retryErrRange := e.RetryErrorRange
167+
if retryErrRange < 0 {
168+
retryErrRange = 0
169+
}
157170

158171
for attempt := 0; attempt <= maxRetries; attempt++ {
159172
// Recreate context for each retry attempt
@@ -185,6 +198,13 @@ func (e *Endpoint) Call(params map[string]any) (map[string]any, error) {
185198
// Timeout occurred, will retry
186199
continue
187200
}
201+
// Check for HTTP error codes if response is available and RetryErrorRange is set
202+
if retryErrRange > 0 && response != nil {
203+
if response.StatusCode >= retryErrRange*100 && response.StatusCode < (retryErrRange+1)*100 {
204+
// Error code is within the specified retry range, will retry
205+
continue
206+
}
207+
}
188208
}
189209

190210
// Success or non-timeout error, break out of retry loop
@@ -387,6 +407,8 @@ func newAPICallerInternal(endpoint Endpoint, config *APICallerConfig, cacheKey s
387407
switch endpoint.Type {
388408
case EndpointTypeREST:
389409
caller.client, err = NewRESTClient(endpoint, config)
410+
case EndpointTypeFormURLEncoded:
411+
caller.client, err = NewRESTClient(endpoint, config)
390412
case EndpointTypeGRPC:
391413
caller.client, err = NewGRPCClient(endpoint, config)
392414
case EndpointTypeSOAP:

api/rest_client.go

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"net/url"
12+
"strings"
1113
)
1214

1315
// RESTClient implements the Client interface for REST APIs
@@ -66,20 +68,28 @@ func (rc *RESTClient) Call(options *CallOptions) (*Response, error) {
6668
// Prepare request body
6769
var bodyReader io.Reader
6870
if options.Body != nil {
69-
switch v := options.Body.(type) {
70-
case []byte:
71-
bodyReader = bytes.NewReader(v)
72-
case string:
73-
bodyReader = bytes.NewReader([]byte(v))
74-
case io.Reader:
75-
bodyReader = v
76-
default:
77-
// Try to marshal as JSON
78-
jsonBody, err := json.Marshal(options.Body)
71+
if rc.endpoint.Type == EndpointTypeFormURLEncoded {
72+
formReader, err := encodeFormBody(options.Body)
7973
if err != nil {
80-
return nil, fmt.Errorf("failed to marshal request body: %w", err)
74+
return nil, err
75+
}
76+
bodyReader = formReader
77+
} else {
78+
switch v := options.Body.(type) {
79+
case []byte:
80+
bodyReader = bytes.NewReader(v)
81+
case string:
82+
bodyReader = bytes.NewReader([]byte(v))
83+
case io.Reader:
84+
bodyReader = v
85+
default:
86+
// Try to marshal as JSON
87+
jsonBody, err := json.Marshal(options.Body)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to marshal request body: %w", err)
90+
}
91+
bodyReader = bytes.NewReader(jsonBody)
8192
}
82-
bodyReader = bytes.NewReader(jsonBody)
8393
}
8494
}
8595

@@ -98,7 +108,11 @@ func (rc *RESTClient) Call(options *CallOptions) (*Response, error) {
98108

99109
// Set default Content-Type if not specified and body is present
100110
if bodyReader != nil && req.Header.Get("Content-Type") == "" {
101-
req.Header.Set("Content-Type", "application/json")
111+
if rc.endpoint.Type == EndpointTypeFormURLEncoded {
112+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
113+
} else {
114+
req.Header.Set("Content-Type", "application/json")
115+
}
102116
}
103117

104118
// Make the request
@@ -134,6 +148,39 @@ func (rc *RESTClient) Call(options *CallOptions) (*Response, error) {
134148
return response, response.Error
135149
}
136150

151+
func encodeFormBody(body any) (io.Reader, error) {
152+
switch v := body.(type) {
153+
case url.Values:
154+
return strings.NewReader(v.Encode()), nil
155+
case map[string]string:
156+
values := url.Values{}
157+
for key, value := range v {
158+
values.Set(key, value)
159+
}
160+
return strings.NewReader(values.Encode()), nil
161+
case map[string][]string:
162+
values := url.Values{}
163+
for key, value := range v {
164+
values[key] = value
165+
}
166+
return strings.NewReader(values.Encode()), nil
167+
case map[string]any:
168+
values := url.Values{}
169+
for key, value := range v {
170+
values.Set(key, fmt.Sprint(value))
171+
}
172+
return strings.NewReader(values.Encode()), nil
173+
case []byte:
174+
return bytes.NewReader(v), nil
175+
case string:
176+
return strings.NewReader(v), nil
177+
case io.Reader:
178+
return v, nil
179+
default:
180+
return nil, fmt.Errorf("unsupported form body type: %T", body)
181+
}
182+
}
183+
137184
// Close closes the REST client
138185
func (rc *RESTClient) Close() error {
139186
// HTTP client doesn't need explicit closing

0 commit comments

Comments
 (0)