feat: phase 8 serviceadapter crd rollout (32/33 tasks)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package problem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Problem represents an RFC 7807 / RFC 9457 problem document.
|
||||
// We use 9457 as the canonical reference (HTTP Semantics updates).
|
||||
type Problem struct {
|
||||
Type string `json:"type"` // stable URI per rejection reason
|
||||
Title string `json:"title"` // human-readable summary
|
||||
Status int `json:"status"` // HTTP status code
|
||||
Detail string `json:"detail"` // human-useful detail, names offending input
|
||||
Instance string `json:"instance,omitempty"` // URI of the affected resource
|
||||
RetryAfter *int `json:"retry_after,omitempty"` // seconds until retry is safe
|
||||
Extra map[string]interface{} `json:"extra,omitempty"` // additional fields
|
||||
}
|
||||
|
||||
// NewProblem creates a new problem document with the given parameters.
|
||||
func NewProblem(statusCode int, typeURI, title, detail string) *Problem {
|
||||
return &Problem{
|
||||
Type: typeURI,
|
||||
Title: title,
|
||||
Status: statusCode,
|
||||
Detail: detail,
|
||||
Extra: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryAfter sets the Retry-After field (in seconds).
|
||||
func (p *Problem) WithRetryAfter(seconds int) *Problem {
|
||||
p.RetryAfter = &seconds
|
||||
return p
|
||||
}
|
||||
|
||||
// WithInstance sets the Instance field.
|
||||
func (p *Problem) WithInstance(instance string) *Problem {
|
||||
p.Instance = instance
|
||||
return p
|
||||
}
|
||||
|
||||
// WithExtra adds extra fields to the problem document.
|
||||
func (p *Problem) WithExtra(key string, value interface{}) *Problem {
|
||||
p.Extra[key] = value
|
||||
return p
|
||||
}
|
||||
|
||||
// Write sends the problem document to the HTTP response writer.
|
||||
func (p *Problem) Write(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
|
||||
// Set Retry-After header if present
|
||||
if p.RetryAfter != nil {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(*p.RetryAfter))
|
||||
}
|
||||
|
||||
w.WriteHeader(p.Status)
|
||||
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Common problem types
|
||||
const (
|
||||
TypeBadRequest = "about:blank#bad-request"
|
||||
TypeUnauthorized = "about:blank#unauthorized"
|
||||
TypeForbidden = "about:blank#forbidden"
|
||||
TypeNotFound = "about:blank#not-found"
|
||||
TypeMethodNotAllowed = "about:blank#method-not-allowed"
|
||||
TypeConflict = "about:blank#conflict"
|
||||
TypeGone = "about:blank#gone"
|
||||
TypePayloadTooLarge = "about:blank#payload-too-large"
|
||||
TypeUnprocessable = "about:blank#unprocessable-entity"
|
||||
TypeTooManyRequests = "about:blank#too-many-requests"
|
||||
TypeInternalError = "about:blank#internal-server-error"
|
||||
TypeNotImplemented = "about:blank#not-implemented"
|
||||
TypeUnavailable = "about:blank#service-unavailable"
|
||||
)
|
||||
|
||||
// Common constructors
|
||||
func BadRequest(detail string) *Problem {
|
||||
return NewProblem(http.StatusBadRequest, TypeBadRequest, "Bad Request", detail)
|
||||
}
|
||||
|
||||
func Unauthorized(detail string) *Problem {
|
||||
return NewProblem(http.StatusUnauthorized, TypeUnauthorized, "Unauthorized", detail)
|
||||
}
|
||||
|
||||
func Forbidden(detail string) *Problem {
|
||||
return NewProblem(http.StatusForbidden, TypeForbidden, "Forbidden", detail)
|
||||
}
|
||||
|
||||
func NotFound(detail string) *Problem {
|
||||
return NewProblem(http.StatusNotFound, TypeNotFound, "Not Found", detail)
|
||||
}
|
||||
|
||||
func PayloadTooLarge(detail string) *Problem {
|
||||
return NewProblem(http.StatusRequestEntityTooLarge, TypePayloadTooLarge, "Payload Too Large", detail)
|
||||
}
|
||||
|
||||
func UnprocessableEntity(detail string) *Problem {
|
||||
return NewProblem(http.StatusUnprocessableEntity, TypeUnprocessable, "Unprocessable Entity", detail)
|
||||
}
|
||||
|
||||
func TooManyRequests(detail string, retryAfter int) *Problem {
|
||||
return NewProblem(http.StatusTooManyRequests, TypeTooManyRequests, "Too Many Requests", detail).
|
||||
WithRetryAfter(retryAfter)
|
||||
}
|
||||
|
||||
func InternalServerError(detail string) *Problem {
|
||||
return NewProblem(http.StatusInternalServerError, TypeInternalError, "Internal Server Error", detail)
|
||||
}
|
||||
|
||||
func ServiceUnavailable(detail string, retryAfter int) *Problem {
|
||||
return NewProblem(http.StatusServiceUnavailable, TypeUnavailable, "Service Unavailable", detail).
|
||||
WithRetryAfter(retryAfter)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package problem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProblemDocument(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
problem *Problem
|
||||
statusCode int
|
||||
hasType bool
|
||||
hasTitle bool
|
||||
hasStatus bool
|
||||
hasDetail bool
|
||||
}{
|
||||
{
|
||||
name: "BadRequest",
|
||||
problem: BadRequest("missing field: model"),
|
||||
statusCode: http.StatusBadRequest,
|
||||
hasType: true,
|
||||
hasTitle: true,
|
||||
hasStatus: true,
|
||||
hasDetail: true,
|
||||
},
|
||||
{
|
||||
name: "PayloadTooLarge",
|
||||
problem: PayloadTooLarge("request body 1001 bytes exceeds max 1000"),
|
||||
statusCode: http.StatusRequestEntityTooLarge,
|
||||
hasType: true,
|
||||
hasTitle: true,
|
||||
hasStatus: true,
|
||||
hasDetail: true,
|
||||
},
|
||||
{
|
||||
name: "TooManyRequests",
|
||||
problem: TooManyRequests("rate limit exceeded", 60),
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
hasType: true,
|
||||
hasTitle: true,
|
||||
hasStatus: true,
|
||||
hasDetail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
err := tc.problem.Write(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if w.Code != tc.statusCode {
|
||||
t.Errorf("expected status %d, got %d", tc.statusCode, w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" {
|
||||
t.Errorf("expected Content-Type: application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Parse response body
|
||||
var p Problem
|
||||
err = json.Unmarshal(w.Body.Bytes(), &p)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if tc.hasType && p.Type == "" {
|
||||
t.Errorf("expected 'type' field, got empty")
|
||||
}
|
||||
if tc.hasTitle && p.Title == "" {
|
||||
t.Errorf("expected 'title' field, got empty")
|
||||
}
|
||||
if tc.hasStatus && p.Status == 0 {
|
||||
t.Errorf("expected 'status' field, got 0")
|
||||
}
|
||||
if tc.hasDetail && p.Detail == "" {
|
||||
t.Errorf("expected 'detail' field, got empty")
|
||||
}
|
||||
|
||||
// Verify status matches HTTP response code
|
||||
if p.Status != w.Code {
|
||||
t.Errorf("status field %d does not match HTTP status %d", p.Status, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemRetryAfter(t *testing.T) {
|
||||
p := TooManyRequests("rate limit", 120)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := p.Write(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
// Check Retry-After header is set
|
||||
if ra := w.Header().Get("Retry-After"); ra == "" {
|
||||
t.Errorf("expected Retry-After header, got empty")
|
||||
}
|
||||
|
||||
var body Problem
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body.RetryAfter == nil || *body.RetryAfter != 120 {
|
||||
t.Errorf("expected RetryAfter=120, got %v", body.RetryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemWithExtra(t *testing.T) {
|
||||
p := BadRequest("invalid request")
|
||||
p.WithExtra("field", "model")
|
||||
p.WithExtra("reason", "unknown_model")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
err := p.Write(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
var body Problem
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
if body.Extra["field"] != "model" {
|
||||
t.Errorf("expected extra field 'model', got %v", body.Extra["field"])
|
||||
}
|
||||
if body.Extra["reason"] != "unknown_model" {
|
||||
t.Errorf("expected extra reason 'unknown_model', got %v", body.Extra["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSecretsInProblem(t *testing.T) {
|
||||
// Verify that secrets, tokens, bodies are never leaked
|
||||
p := Unauthorized("invalid bearer token").
|
||||
WithExtra("attempted_route", "/v1/chat/completions")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
p.Write(w)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Should not contain any auth-related secrets
|
||||
if len(body) > 200 {
|
||||
t.Errorf("problem document too large for detail: %d bytes (check for leaked content)", len(body))
|
||||
}
|
||||
|
||||
// Parse and verify no sensitive fields are present
|
||||
var doc Problem
|
||||
json.Unmarshal(w.Body.Bytes(), &doc)
|
||||
|
||||
// Detail should describe the problem, not echo the token
|
||||
if len(doc.Detail) > 100 {
|
||||
t.Errorf("detail too long: %s", doc.Detail)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user