limit_req_status directive defaults to 503, and Nginx serves its custom error page for that status. To fix this, you must configure error_page directives to return plain text or JSON responses for specific status codes like 429 or 503, ensuring API clients receive machine-readable data rather than formatted HTML.
Understanding the Default Behavior
When Nginx encounters a rate-limiting event triggered by the limit_req module, it typically rejects the request with a 503 Service Unavailable status code by default. This behavior is controlled by the limit_req_status directive. While many modern APIs prefer a 429 Too Many Requests status, Nginx’s default configuration often remains set to 503 unless explicitly changed.
The core issue arises not from the status code itself, but from how Nginx handles the response body. By default, when Nginx generates a 503 (or any error status), it looks for a custom error page defined in the configuration. If no custom error page is specified for that specific code, Nginx falls back to its built-in default error page. This built-in page is an HTML document containing styling, headers, and explanatory text, which breaks API integrations expecting a simple status line or JSON payload.
Configuring Custom Error Pages
To resolve this, you need to override the default error handling behavior using the error_page directive. This directive tells Nginx exactly what content to serve when a specific error occurs. For rate limiting issues, you should target the status code defined in your limit_req_status setting.
First, determine your desired status code. If you are using the standard 429 for rate limiting, ensure your limit_req block includes:
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
limit_req zone=one burst=5 nodelay;
limit_req_status 429;
Next, add an error_page directive in the same server or location block. You can return a plain text message directly within the configuration without needing external files:
error_page 429 = @rate_limited;
location @rate_limited { default_type text/plain; return 429 "Rate limit exceeded"; }
This approach uses a named location (@rate_limited) to handle the error internally. The default_type ensures the response header indicates plain text, and the return statement sends the exact string you want. This prevents Nginx from generating any HTML markup.
Handling 503 Errors Specifically
If you are stuck with the default 503 status code, the process is identical but targets code 503. Many legacy systems or monitoring tools might still expect 503 for temporary unavailability. You can customize the response similarly:
error_page 503 = @service_unavailable;
location @service_unavailable { default_type application/json; return 503 '{"status": "unavailable", "message": "Server overloaded"}'; }
Using JSON is particularly useful if your backend services consume these errors programmatically. Note that the = symbol in error_page 503 = @... is critical. It instructs Nginx to use the status code returned by the named location handler (which is 503) rather than changing the final response code to 200 or another value. Without the equals sign, Nginx might rewrite the status code based on the internal redirect.
Verifying the Configuration
After making changes, always test the configuration syntax before reloading. Run the following command in your terminal:
nginx -t
If the test passes, reload Nginx to apply the changes:
systemctl reload nginx
To verify the fix, use curl to trigger the rate limit. You can do this by sending multiple rapid requests or by temporarily lowering the rate limit in your config for testing purposes. Check the response headers and body:
curl -i http://your-domain.com/api/endpoint
You should see the correct HTTP status code in the first line and the plain text or JSON body in the response, without any HTML tags. If you still see HTML, check if another error_page directive elsewhere in your configuration is overriding this one. Nginx applies the most specific matching rule, so ensure your new directive is in the correct context (server vs. location).
Common Pitfalls and Debugging
Sometimes, even with correct error_page directives, browsers may cache old HTML error pages. Clear your browser cache or use incognito mode when testing. Additionally, ensure that your default_type is set correctly. If you omit it, Nginx might guess the type, potentially leading to unexpected content types being sent to the client.
Another common mistake is forgetting to include the nodelay parameter in the limit_req directive. While this doesn't affect the response format, it changes how bursts are handled. If you are troubleshooting intermittent HTML responses, ensure that your rate limiting logic is consistent and not conflicting with other modules like limit_conn.
For more complex setups involving reverse proxies, ensure that proxy headers are passed correctly so that the rate limiter identifies the correct client IP. Misconfigured proxy headers can lead to incorrect rate limiting decisions, causing legitimate users to hit the error pages unexpectedly.
Frequently Asked Questions
Why does my API client receive HTML instead of JSON?
Nginx serves its default error page as HTML when no custom error handler is defined for the specific status code. You must explicitly configure error_page to return plain text or JSON to prevent this default behavior.
Can I use a static file for the error response?
Yes, you can point error_page to a static file path, such as /custom_errors/rate_limit.html. However, returning inline text via a named location is often simpler and avoids filesystem I/O overhead during high-traffic events.
What happens if I don't specify a status code in error_page?
If you omit the status code, Nginx may return a 200 OK status for the error page content, which confuses clients expecting an error. Always use the = syntax followed by the desired status code to ensure the correct HTTP response code is sent.
Does this affect browser-based applications?
Browsers generally render HTML gracefully, so changing to plain text might make error messages less user-friendly in a web interface. Consider using different error handlers for browser traffic versus API endpoints if both are served by the same Nginx instance.
How do I debug why my error_page isn't working?
Check the Nginx error log for any permission issues or syntax errors. Also, verify that your error_page directive is not being overridden by a more general rule higher up in the configuration hierarchy.