Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
"errors/user/bad_request/invalid_analytics_table",
"errors/user/bad_request/permissions_query_syntax_error",
"errors/user/bad_request/request_body_too_large",
"errors/user/bad_request/request_body_unreadable",
"errors/user/bad_request/request_timeout"
]
},
Expand Down
73 changes: 73 additions & 0 deletions apps/docs/errors/user/bad_request/request_body_unreadable.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: "request_body_unreadable"
description: "Request body could not be read due to malformed request or connection issues"
---

<Danger>`err:user:bad_request:request_body_unreadable`</Danger>

```json Example
{
"meta": {
"requestId": "req_4dgzrNP3Je5mU1tD"
},
"error": {
"detail": "The request body could not be read.",
"status": 400,
"title": "Bad Request",
"type": "https://unkey.com/docs/errors/user/bad_request/request_body_unreadable",
"errors": []
}
}
```

## What Happened?

The server couldn't read your request body. This typically happens when there's an issue with how the request was formed or transmitted.

Common causes include:
- Malformed HTTP request structure
- Network connection closed prematurely during transmission
- Invalid or mismatched `Content-Length` header
- Data corruption during transmission
- Client-side network interruption

## How to Fix It

### 1. Check Your Request Structure

Make sure your HTTP request is properly formatted and all required headers are present:

```bash
curl -X POST https://api.unkey.com/v2/keys.create \
-H "Content-Type: application/json" \
-H "Authorization: Bearer unkey_XXXX" \
-d '{
"apiId": "api_123",
"name": "My Key"
}'
```

### 2. Check Network Stability

If you're experiencing intermittent failures:
- Implement retry logic with exponential backoff
- Check your network connection stability
- Consider timeout values that may be too aggressive

### 3. Avoid Partial Uploads

Ensure you're sending the complete request body in one go, not streaming partial data that might get interrupted.

## Still Having Issues?

<Note>
If you continue to see this error after checking the above:

1. Check your HTTP client library documentation for known issues
2. Try a different HTTP client to isolate the problem
3. [Contact our support team](mailto:support@unkey.dev) with:
- Your request ID (from the error response)
- The HTTP client/library you're using
- Any network logs or error messages

</Note>
2 changes: 1 addition & 1 deletion go/apps/api/openapi/gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions go/apps/api/openapi/openapi-generated.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,7 @@ components:
type: string
minLength: 3
maxLength: 255
description: Identifier of the configured migration provider/strategy to use (e.g., "your_company").
description: Identifier of the configured migration provider/strategy to use (e.g., "your_company"). You will receive this from Unkey's support staff.
example: your_company
apiId:
type: string
Expand Down Expand Up @@ -2707,7 +2707,7 @@ components:
type: string
minLength: 3
description: The current hash of the key on your side
example: qwerty123
example: your_already_hashed_key
name:
type: string
minLength: 1
Expand Down
2 changes: 2 additions & 0 deletions go/pkg/codes/constants_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions go/pkg/codes/user_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ type userBadRequest struct {
PermissionsQuerySyntaxError Code
// RequestBodyTooLarge indicates the request body exceeds the maximum allowed size.
RequestBodyTooLarge Code
// RequestBodyUnreadable indicates the request body could not be read due to malformed request or connection issues.
RequestBodyUnreadable Code
// RequestTimeout indicates the request took too long to process.
RequestTimeout Code
// ClientClosedRequest indicates the client closed the connection before the request completed.
Expand Down Expand Up @@ -54,6 +56,7 @@ var User = UserErrors{
BadRequest: userBadRequest{
PermissionsQuerySyntaxError: Code{SystemUser, CategoryUserBadRequest, "permissions_query_syntax_error"},
RequestBodyTooLarge: Code{SystemUser, CategoryUserBadRequest, "request_body_too_large"},
RequestBodyUnreadable: Code{SystemUser, CategoryUserBadRequest, "request_body_unreadable"},
RequestTimeout: Code{SystemUser, CategoryUserBadRequest, "request_timeout"},
ClientClosedRequest: Code{SystemUser, CategoryUserBadRequest, "client_closed_request"},
InvalidAnalyticsQuery: Code{SystemUser, CategoryUserBadRequest, "invalid_analytics_query"},
Expand Down
1 change: 1 addition & 0 deletions go/pkg/zen/middleware_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func WithErrorHandling(logger logging.Logger) Middleware {
codes.UnkeyAuthErrorsAuthenticationMalformed,
codes.UserErrorsBadRequestPermissionsQuerySyntaxError,
codes.UnkeyGatewayErrorsValidationRequestInvalid,
codes.UserErrorsBadRequestRequestBodyUnreadable,
codes.UnkeyGatewayErrorsValidationResponseInvalid:
return s.JSON(http.StatusBadRequest, openapi.BadRequestErrorResponse{
Meta: openapi.Meta{
Expand Down
1 change: 1 addition & 0 deletions go/pkg/zen/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func (s *Session) Init(w http.ResponseWriter, r *http.Request, maxBodySize int64
}

return fault.Wrap(err,
fault.Code(codes.User.BadRequest.RequestBodyUnreadable.URN()),
fault.Internal("unable to read request body"),
fault.Public("The request body could not be read."),
)
Expand Down
149 changes: 149 additions & 0 deletions go/pkg/zen/session_body_read_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package zen

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/require"
"github.com/unkeyed/unkey/go/pkg/otel/logging"
)

// failingReadCloser is a custom io.ReadCloser that always returns an error on Read
type failingReadCloser struct {
readErr error
}

func (e *failingReadCloser) Read(p []byte) (n int, err error) {
return 0, e.readErr
}

func (e *failingReadCloser) Close() error {
return nil
}

func TestSession_UnreadableBodyReturns400NotError500(t *testing.T) {
// Test that when the body cannot be read, we return 400 Bad Request, not 500 Internal Server Error
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()

// Replace the body with a failingReadCloser that will fail on read
req.Body = &failingReadCloser{readErr: errors.New("simulated read error")}

sess := &Session{}
err := sess.Init(w, req, 0)

// Should get an error
require.Error(t, err)

// Error should contain our internal message
require.Contains(t, err.Error(), "unable to read request body")
}

func TestSession_UnreadableBodyHTTPStatus(t *testing.T) {
// Test that unreadable request bodies return 400 status through zen server
logger := logging.NewNoop()

srv, err := New(Config{
Logger: logger,
MaxRequestBodySize: 0, // No size limit
})
require.NoError(t, err)

// Flag to track if handler was invoked (should remain false)
handlerInvoked := false

// Register a simple route that would process the body
testRoute := NewRoute(http.MethodPost, "/test", func(ctx context.Context, s *Session) error {
// This should never be reached due to the body read error
handlerInvoked = true
return s.JSON(http.StatusOK, map[string]string{"status": "ok"})
})

srv.RegisterRoute(
[]Middleware{
WithErrorHandling(logger),
},
testRoute,
)

// Create request with a body that will fail to read
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Body = &failingReadCloser{readErr: errors.New("connection reset by peer")}
w := httptest.NewRecorder()

// Call through the zen server
srv.Mux().ServeHTTP(w, req)

// Check that the response is 400 Bad Request, NOT 500 Internal Server Error
require.Equal(t, http.StatusBadRequest, w.Code, "Should return 400 Bad Request status, not 500")

// Parse and validate JSON response structure
require.Contains(t, w.Header().Get("Content-Type"), "application/json")

var response map[string]any
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err, "Response should be valid JSON")

// Validate that response contains error field
require.Contains(t, response, "error", "Response should contain 'error' field")

errorObj, ok := response["error"].(map[string]any)
require.True(t, ok, "Error field should be an object")

// Validate required JSON fields in error object
require.Contains(t, errorObj, "title", "Error should contain 'title' field")
require.Contains(t, errorObj, "detail", "Error should contain 'detail' field")
require.Contains(t, errorObj, "status", "Error should contain 'status' field")
require.Contains(t, errorObj, "type", "Error should contain 'type' field")

// Validate field values
require.Equal(t, "Bad Request", errorObj["title"])
require.Equal(t, float64(400), errorObj["status"]) // JSON unmarshals numbers as float64
require.Equal(t, "The request body could not be read.", errorObj["detail"])
require.Contains(t, errorObj["type"], "request_body_unreadable")

// Ensure the handler was never invoked
require.False(t, handlerInvoked, "Handler should not have been invoked due to body read error")
}

func TestSession_UnreadableBodyVsMaxBytesError(t *testing.T) {
// Ensure that MaxBytesError (413) and generic read errors (400) are distinct
tests := []struct {
name string
maxBodySize int64
bodyReader io.ReadCloser
errorSubstr string
}{
{
name: "MaxBytesError has correct message",
maxBodySize: 10,
bodyReader: io.NopCloser(strings.NewReader(strings.Repeat("x", 100))),
errorSubstr: "request body exceeds size limit",
},
{
name: "Generic read error has correct message",
maxBodySize: 0,
bodyReader: &failingReadCloser{readErr: errors.New("connection interrupted")},
errorSubstr: "unable to read request body",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", tt.bodyReader)
w := httptest.NewRecorder()

sess := &Session{}
err := sess.Init(w, req, tt.maxBodySize)

require.Error(t, err)
require.Contains(t, err.Error(), tt.errorSubstr)
})
}
}
Loading