Skip to content

Commit dc2db1e

Browse files
committed
Add redirect handling to HTTP client
1 parent 3b32b26 commit dc2db1e

File tree

3 files changed

+195
-33
lines changed

3 files changed

+195
-33
lines changed

httpclient/httpclient_request.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/deploymenttheory/go-api-http-client/logger"
11+
"github.com/deploymenttheory/go-api-http-client/redirecthandler"
1112
"github.com/deploymenttheory/go-api-http-client/status"
1213
"github.com/google/uuid"
1314
"go.uber.org/zap"
@@ -17,8 +18,10 @@ import (
1718
// This function serves as a dispatcher, deciding whether to execute the request with or without retry logic based on the
1819
// idempotency of the HTTP method. Idempotent methods (GET, PUT, DELETE) are executed with retries to handle transient errors
1920
// and rate limits, while non-idempotent methods (POST, PATCH) are executed without retries to avoid potential side effects
20-
// of duplicating non-idempotent operations. function uses an instance of a logger implementing the logger.Logger interface, used to log informational messages, warnings, and
21-
// errors encountered during the execution of the request.
21+
// of duplicating non-idempotent operations. The function uses an instance of a logger implementing the logger.Logger interface,
22+
// used to log informational messages, warnings, and errors encountered during the execution of the request.
23+
// It also applies redirect handling to the client if configured, allowing the client to follow redirects up to a maximum
24+
// number of times.
2225

2326
// Parameters:
2427
// - method: A string representing the HTTP method to be used for the request. This method determines the execution path
@@ -59,9 +62,12 @@ import (
5962
// including maximum retry attempts and total retry duration.
6063

6164
func (c *Client) DoRequest(method, endpoint string, body, out interface{}) (*http.Response, error) {
62-
6365
log := c.Logger
6466

67+
// Apply redirect handling to the client with MaxRedirects set to 10
68+
redirectHandler := redirecthandler.NewRedirectHandler(log, 10)
69+
redirectHandler.WithRedirectHandling(c.httpClient)
70+
6571
if IsIdempotentHTTPMethod(method) {
6672
return c.executeRequestWithRetries(method, endpoint, body, out)
6773
} else if IsNonIdempotentHTTPMethod(method) {

redirecthandler/redirecthandler.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package redirecthandler
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"sync"
7+
8+
"github.com/deploymenttheory/go-api-http-client/logger"
9+
"github.com/deploymenttheory/go-api-http-client/status"
10+
"go.uber.org/zap"
11+
)
12+
13+
// RedirectHandler handles HTTP redirects within an http.Client.
14+
// It provides features such as redirect loop detection, security enhancements,
15+
// and integration with client settings for fine-grained control over redirect behavior.
16+
type RedirectHandler struct {
17+
Logger logger.Logger
18+
MaxRedirects int
19+
VisitedURLs map[string]int
20+
VisitedURLsMutex sync.Mutex
21+
SensitiveHeaders []string
22+
}
23+
24+
// NewRedirectHandler creates a new instance of RedirectHandler with the provided logger
25+
// and maximum number of redirects. It initializes internal structures and is ready to use.
26+
func NewRedirectHandler(logger logger.Logger, maxRedirects int) *RedirectHandler {
27+
return &RedirectHandler{
28+
Logger: logger,
29+
MaxRedirects: maxRedirects,
30+
VisitedURLs: make(map[string]int),
31+
SensitiveHeaders: []string{"Authorization", "Cookie"}, // Add other sensitive headers if needed
32+
}
33+
}
34+
35+
// WithRedirectHandling applies the redirect handling policy to an http.Client.
36+
// It sets the CheckRedirect function on the client to use the handler's logic.
37+
func (r *RedirectHandler) WithRedirectHandling(client *http.Client) {
38+
client.CheckRedirect = r.checkRedirect
39+
}
40+
41+
// checkRedirect is the core function that implements the redirect handling logic.
42+
// It is set as the CheckRedirect function on an http.Client and is called whenever
43+
// the client encounters a 3XX response. It enforces the max redirects limit,
44+
// detects redirect loops, applies security measures for cross-domain redirects,
45+
// resolves relative redirects, and optimizes performance.
46+
func (r *RedirectHandler) checkRedirect(req *http.Request, via []*http.Request) error {
47+
// Redirect Loop Detection
48+
r.VisitedURLsMutex.Lock()
49+
defer r.VisitedURLsMutex.Unlock()
50+
if _, exists := r.VisitedURLs[req.URL.String()]; exists {
51+
r.Logger.Warn("Detected redirect loop", zap.String("url", req.URL.String()))
52+
return http.ErrUseLastResponse
53+
}
54+
r.VisitedURLs[req.URL.String()]++
55+
56+
if len(via) >= r.MaxRedirects {
57+
r.Logger.Warn("Stopped after maximum redirects", zap.Int("maxRedirects", r.MaxRedirects))
58+
return http.ErrUseLastResponse
59+
}
60+
61+
lastResponse := via[len(via)-1].Response
62+
if status.IsRedirectStatusCode(lastResponse.StatusCode) {
63+
location, err := lastResponse.Location()
64+
if err != nil {
65+
r.Logger.Error("Failed to get location from redirect response", zap.Error(err))
66+
return err
67+
}
68+
69+
// Resolve relative redirects against the current request URL
70+
newReqURL, err := r.resolveRedirectURL(req.URL, location)
71+
if err != nil {
72+
r.Logger.Error("Failed to resolve redirect URL", zap.Error(err))
73+
return err
74+
}
75+
76+
// Security Measures
77+
if newReqURL.Host != req.URL.Host {
78+
r.secureRequest(req)
79+
}
80+
81+
// Handling 303 See Other
82+
if lastResponse.StatusCode == http.StatusSeeOther {
83+
req.Method = http.MethodGet
84+
req.Body = nil
85+
req.GetBody = nil
86+
req.ContentLength = 0
87+
req.Header.Del("Content-Type")
88+
r.Logger.Info("Changed request method to GET for 303 See Other response")
89+
}
90+
91+
req.URL = newReqURL
92+
r.Logger.Info("Redirecting request", zap.String("newURL", newReqURL.String()))
93+
return nil
94+
}
95+
96+
return http.ErrUseLastResponse
97+
}
98+
99+
// resolveRedirectURL resolves the redirect location URL against the current request URL
100+
// to handle relative redirects accurately.
101+
func (r *RedirectHandler) resolveRedirectURL(reqURL *url.URL, redirectURL *url.URL) (*url.URL, error) {
102+
if redirectURL.IsAbs() {
103+
return redirectURL, nil // Absolute URL, no need to resolve
104+
}
105+
106+
// Relative URL, resolve against the current request URL
107+
absoluteURL := *reqURL
108+
absoluteURL.Path = redirectURL.Path
109+
absoluteURL.RawQuery = redirectURL.RawQuery
110+
absoluteURL.Fragment = redirectURL.Fragment
111+
return &absoluteURL, nil
112+
}
113+
114+
// secureRequest removes sensitive headers from the request if the new destination is a different domain.
115+
func (r *RedirectHandler) secureRequest(req *http.Request) {
116+
for _, header := range r.SensitiveHeaders {
117+
req.Header.Del(header)
118+
r.Logger.Info("Removed sensitive header due to domain change", zap.String("header", header))
119+
}
120+
}

status/status.go

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
)
99

10+
// TranslateStatusCode provides a human-readable message for HTTP status codes.
1011
// TranslateStatusCode provides a human-readable message for HTTP status codes.
1112
func TranslateStatusCode(resp *http.Response) string {
1213

@@ -15,36 +16,48 @@ func TranslateStatusCode(resp *http.Response) string {
1516
}
1617

1718
messages := map[int]string{
18-
http.StatusOK: "Request successful.",
19-
http.StatusCreated: "Request to create or update resource successful.",
20-
http.StatusAccepted: "The request was accepted for processing, but the processing has not completed.",
21-
http.StatusNoContent: "Request successful. No content to send for this request.",
22-
http.StatusBadRequest: "Bad request. Verify the syntax of the request.",
23-
http.StatusUnauthorized: "Authentication failed. Verify the credentials being used for the request.",
24-
http.StatusPaymentRequired: "Payment required. Access to the requested resource requires payment.",
25-
http.StatusForbidden: "Invalid permissions. Verify the account has the proper permissions for the resource.",
26-
http.StatusNotFound: "Resource not found. Verify the URL path is correct.",
27-
http.StatusMethodNotAllowed: "Method not allowed. The method specified is not allowed for the resource.",
28-
http.StatusNotAcceptable: "Not acceptable. The server cannot produce a response matching the list of acceptable values.",
29-
http.StatusProxyAuthRequired: "Proxy authentication required. You must authenticate with a proxy server before this request can be served.",
30-
http.StatusRequestTimeout: "Request timeout. The server timed out waiting for the request.",
31-
http.StatusConflict: "Conflict. The request could not be processed because of conflict in the request.",
32-
http.StatusGone: "Gone. The resource requested is no longer available and will not be available again.",
33-
http.StatusLengthRequired: "Length required. The request did not specify the length of its content, which is required by the requested resource.",
34-
http.StatusPreconditionFailed: "Precondition failed. The server does not meet one of the preconditions specified in the request.",
35-
http.StatusRequestEntityTooLarge: "Payload too large. The request is larger than the server is willing or able to process.",
36-
http.StatusRequestURITooLong: "Request-URI too long. The URI provided was too long for the server to process.",
37-
http.StatusUnsupportedMediaType: "Unsupported media type. The request entity has a media type which the server or resource does not support.",
38-
http.StatusRequestedRangeNotSatisfiable: "Requested range not satisfiable. The client has asked for a portion of the file, but the server cannot supply that portion.",
39-
http.StatusExpectationFailed: "Expectation failed. The server cannot meet the requirements of the Expect request-header field.",
40-
http.StatusUnprocessableEntity: "Unprocessable entity. The server understands the content type and syntax of the request but was unable to process the contained instructions.",
41-
http.StatusLocked: "Locked. The resource that is being accessed is locked.",
42-
http.StatusFailedDependency: "Failed dependency. The request failed because it depended on another request and that request failed.",
43-
http.StatusUpgradeRequired: "Upgrade required. The client should switch to a different protocol.",
44-
http.StatusPreconditionRequired: "Precondition required. The server requires that the request be conditional.",
45-
http.StatusTooManyRequests: "Too many requests. The user has sent too many requests in a given amount of time.",
46-
http.StatusRequestHeaderFieldsTooLarge: "Request header fields too large. The server is unwilling to process the request because its header fields are too large.",
47-
http.StatusUnavailableForLegalReasons: "Unavailable for legal reasons. The server is denying access to the resource as a consequence of a legal demand.",
19+
// Successful responses (200-299)
20+
http.StatusOK: "Request successful.",
21+
http.StatusCreated: "Request to create or update resource successful.",
22+
http.StatusAccepted: "The request was accepted for processing, but the processing has not completed.",
23+
http.StatusNoContent: "Request successful. No content to send for this request.",
24+
25+
// Redirect status codes (300-399)
26+
http.StatusMovedPermanently: "Moved Permanently. The requested resource has been assigned a new permanent URI. Future references should use the returned URI.",
27+
http.StatusFound: "Found. The requested resource resides temporarily under a different URI. The client should use the Request-URI for future requests.",
28+
http.StatusSeeOther: "See Other. The response to the request can be found under a different URI. A GET method should be used to retrieve the resource.",
29+
http.StatusTemporaryRedirect: "Temporary Redirect. The requested resource resides temporarily under a different URI. The request method should not change.",
30+
http.StatusPermanentRedirect: "Permanent Redirect. The requested resource has been permanently moved to a new URI. The request method should not change.",
31+
32+
// Client error responses (400-499)
33+
http.StatusBadRequest: "Bad request. Verify the syntax of the request.",
34+
http.StatusUnauthorized: "Authentication failed. Verify the credentials being used for the request.",
35+
http.StatusPaymentRequired: "Payment required. Access to the requested resource requires payment.",
36+
http.StatusForbidden: "Invalid permissions. Verify the account has the proper permissions for the resource.",
37+
http.StatusNotFound: "Resource not found. Verify the URL path is correct.",
38+
http.StatusMethodNotAllowed: "Method not allowed. The method specified is not allowed for the resource.",
39+
http.StatusNotAcceptable: "Not acceptable. The server cannot produce a response matching the list of acceptable values.",
40+
http.StatusProxyAuthRequired: "Proxy authentication required. You must authenticate with a proxy server before this request can be served.",
41+
http.StatusRequestTimeout: "Request timeout. The server timed out waiting for the request.",
42+
http.StatusConflict: "Conflict. The request could not be processed because of conflict in the request.",
43+
http.StatusGone: "Gone. The resource requested is no longer available and will not be available again.",
44+
http.StatusLengthRequired: "Length required. The request did not specify the length of its content, which is required by the requested resource.",
45+
http.StatusPreconditionFailed: "Precondition failed. The server does not meet one of the preconditions specified in the request.",
46+
http.StatusRequestEntityTooLarge: "Payload too large. The request is larger than the server is willing or able to process.",
47+
http.StatusRequestURITooLong: "Request-URI too long. The URI provided was too long for the server to process.",
48+
http.StatusUnsupportedMediaType: "Unsupported media type. The request entity has a media type which the server or resource does not support.",
49+
http.StatusRequestedRangeNotSatisfiable: "Requested range not satisfiable. The client has asked for a portion of the file, but the server cannot supply that portion.",
50+
http.StatusExpectationFailed: "Expectation failed. The server cannot meet the requirements of the Expect request-header field.",
51+
http.StatusUnprocessableEntity: "Unprocessable entity. The server understands the content type and syntax of the request but was unable to process the contained instructions.",
52+
http.StatusLocked: "Locked. The resource that is being accessed is locked.",
53+
http.StatusFailedDependency: "Failed dependency. The request failed because it depended on another request and that request failed.",
54+
http.StatusUpgradeRequired: "Upgrade required. The client should switch to a different protocol.",
55+
http.StatusPreconditionRequired: "Precondition required. The server requires that the request be conditional.",
56+
http.StatusTooManyRequests: "Too many requests. The user has sent too many requests in a given amount of time.",
57+
http.StatusRequestHeaderFieldsTooLarge: "Request header fields too large. The server is unwilling to process the request because its header fields are too large.",
58+
http.StatusUnavailableForLegalReasons: "Unavailable for legal reasons. The server is denying access to the resource as a consequence of a legal demand.",
59+
60+
// Server error responses (500-599)
4861
http.StatusInternalServerError: "Internal server error. The server encountered an unexpected condition that prevented it from fulfilling the request.",
4962
http.StatusNotImplemented: "Not implemented. The server does not support the functionality required to fulfill the request.",
5063
http.StatusBadGateway: "Bad gateway. The server received an invalid response from the upstream server while trying to fulfill the request.",
@@ -61,6 +74,29 @@ func TranslateStatusCode(resp *http.Response) string {
6174
return fmt.Sprintf("Unknown status code: %d", resp.StatusCode)
6275
}
6376

77+
// IsRedirectStatusCode checks if the provided HTTP status code is one of the redirect codes.
78+
// Redirect status codes instruct the client to make a new request to a different URI, as defined in the response's Location header.
79+
//
80+
// - 301 Moved Permanently: The requested resource has been assigned a new permanent URI and any future references to this resource should use one of the returned URIs.
81+
// - 302 Found: The requested resource resides temporarily under a different URI. Since the redirection might be altered on occasion, the client should continue to use the Request-URI for future requests.
82+
// - 303 See Other: The response to the request can be found under a different URI and should be retrieved using a GET method on that resource. This method exists primarily to allow the output of a POST-activated script to redirect the user agent to a selected resource.
83+
// - 307 Temporary Redirect: The requested resource resides temporarily under a different URI. The client should not change the request method if it performs an automatic redirection to that URI.
84+
// - 308 Permanent Redirect: The request and all future requests should be repeated using another URI. 308 parallel the behavior of 301 but do not allow the HTTP method to change. So, for example, submitting a form to a permanently redirected resource may continue smoothly.
85+
//
86+
// The function returns true if the statusCode is one of the above redirect statuses, indicating that the client should follow the redirection as specified in the Location header of the response.
87+
func IsRedirectStatusCode(statusCode int) bool {
88+
switch statusCode {
89+
case http.StatusMovedPermanently, // 301
90+
http.StatusFound, // 302
91+
http.StatusSeeOther, // 303
92+
http.StatusTemporaryRedirect, // 307
93+
http.StatusPermanentRedirect: // 308
94+
return true
95+
default:
96+
return false
97+
}
98+
}
99+
64100
// IsNonRetryableStatusCode checks if the provided response indicates a non-retryable error.
65101
func IsNonRetryableStatusCode(resp *http.Response) bool {
66102
// Expanded list of non-retryable HTTP status codes

0 commit comments

Comments
 (0)