Setting headers in error response always appends
@danielgtaylor @victoraugustolls Thanks for the prototype solution in #386 and #387 for setting headers in error responses.
While it worked well initially, I've hit a case where I don't see solution. I'm happy to open a PR, but would appreciate your thoughts on the right approach to solve this specific issue. I know there are thoughts around a much more robust approach. At this point, I'm only trying to solve this narrow problem.
I'm implementing a route-specific secondary rate limiter (after a general IP-based limiter as middleware) that exponentially increases the required time between password resets based on the POSTed email address.
The issue is that, unlike headers in output structs, headers in errors are always appended. This means that I can't override the headers set by the general limiter with the headers set by the more restrictive limiter. The response with duplicate headers looks like this:
HTTP/1.1 429 Too Many Requests
Connection: close
Content-Type: application/problem+json
Link: </api/core/v1/schemas/ErrorModel.json>; rel="describedBy"
Retry-After: Mon, 10 Jun 2024 13:37:46 UTC
Retry-After: Mon, 10 Jun 2024 13:38:47 UTC
X-Ratelimit-Limit: 100
X-Ratelimit-Limit: 1
X-Ratelimit-Remaining: 98
X-Ratelimit-Remaining: 0
X-Ratelimit-Reset: 2024-06-10T13:37:47Z
X-Ratelimit-Reset: 2024-06-11T13:37:46Z
X-Ratelimit-Retry: 2024-06-10T13:37:46Z
X-Ratelimit-Retry: 2024-06-10T13:38:47Z
Ideally, in this case, I could do something like the array approach documented for output structs -- override if a single value and append when an array. But I understand that in the general case it makes more sense to alway append error headers to preserve information that might be useful when troubleshooting.
It seems that:
- There is no way to control appending vs overriding error headers.
- There is no way to access headers in the handler to remove duplicates since the request isn't available.
- A transformer can't be used to remove duplicate headers since there is no way to the read the response headers (only the request headers) from the Huma context.
Other than a framework-level enhancement, the only things I can think of doing are:
- Move the secondary limiter into a middleware and parse the body before Huma does to get at the properties I need to configure the secondary limiter attempt. In addition to parsing the body twice, it would not benefit from Huma's validation and it would separate part of the logic from the handler into a middleware.
- Wrap the Huma context in the middleware to override AppendHeader() with SetHeader() for specific headers.
Thoughts?
@jeremybower I'm still wrapping my head around this problem. Maybe you can make a small example like https://go.dev/play/p/xrlGxyVZBge to reproduce it?
That said, I wonder if you can use operation metadata to just tell the middleware when a handler will set its own rate limiting info into the response, something like https://go.dev/play/p/d_dj-_UMKM1
@danielgtaylor Here's an example of the primary and secondary rate limiter use case: https://go.dev/play/p/zOulOSxaFWL
Please let me know if I'm misunderstanding the suggestion about using operation metadata, but it seems this won't work because the primary rate limiter must always be enabled so that invalid requests that don't reach the secondary rate limiter still count against the rate limiting quota (as shown in the example with an invalid email address).
Another thing I'll point out about this example, which is purely subjective, is that there are three different ways to set the same header:
- Middleware:
ctx.SetHeader("X-Ratelimit-Remaining", "0") - Error:
http.Header{"X-Ratelimit-Remaining": []string{"0"}} - Output:
PasswordResetOutput{HeaderXRateLimitRemaining: "0"}
I can understand the middleware operating at a lower level with the Huma context, but it seems like the handler should be consistent about how headers are set. If I continue to pull on that thread, then it leads me to errors being similar to output structs where headers can be set in the same way.
Thanks again for your thoughts on this. I'm really enjoying using Huma.
Hello, I think the output was correct. Because the ErrorWithHeaders using header.Add operation this line.
And the only time your code goes to this block
if _, ok := secondaryRateLimiter[input.Body.EmailAddress]; ok {
// Return a 429 with the more restrictive rate limit headers, which
// will NOT override those set in the middleware.
return nil, huma.ErrorWithHeaders(huma.Error429TooManyRequests("foo"), http.Header{
"X-Ratelimit-Limit": []string{"1"},
"X-Ratelimit-Remaining": []string{"0"},
})
}
Are only when doRequest doRequest(mux, "[email protected]").
So, the code execution order are
doRequest(mux, "[email protected]")
ctx.SetHeader("X-Ratelimit-Remaining", strconv.Itoa(primaryRateLimiterRemaining))
return nil, huma.ErrorWithHeaders(huma.Error429TooManyRequests("foo"), http.Header{
"X-Ratelimit-Limit": []string{"1"},
"X-Ratelimit-Remaining": []string{"0"},
})
Which explain why the header for X-Ratelimit-Remaining are duplicate.
Sorry for incomplete analysis. After the
return nil, huma.ErrorWithHeaders(huma.Error429TooManyRequests("foo"), http.Header{
"X-Ratelimit-Limit": []string{"1"},
"X-Ratelimit-Remaining": []string{"0"},
})
The code goes to huma.go:936. And since the previous code return as error, and the context already had header set. The code append to existing header. So that explaining why you got multiple rate limit in the header. Unfortunately, I dont see a way to get the main context from the middleware. As when it got invode, it pass the ctx.Context() to the function, But the header live on ctx.w.Header()
I dont see a way to modify the raw/original request/response header. Or any way to populate the context being pass to the handler through middleware. Therefore, the there's no work around for this version. And the handler run through huma register, is guarantee to write the response. So, calling the the next first then set the header afterward is not possible either
api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
ctx.SetHeader("X-Ratelimit-Remaining", strconv.Itoa(PrimaryRateLimiterBucketSize))
next(ctx)
ctx.SetHeader("X-Ratelimit-Limit", strconv.Itoa(123))
if ctx.Header("X-Ratelimit-Remaining") == "" {
ctx.SetHeader("X-Ratelimit-Remaining", strconv.Itoa(primaryRateLimiterRemaining))
}
})