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
2 changes: 1 addition & 1 deletion internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ func handleError(c *echo.Context, err error) error {
}

fallback := &core.GatewayError{
Type: "internal_error",
Type: core.ErrorTypeInternal,
Message: "an unexpected error occurred",
StatusCode: http.StatusInternalServerError,
Err: err,
Expand Down
8 changes: 8 additions & 0 deletions internal/auditlog/enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ func EnrichEntryWithError(c *echo.Context, errorType, errorMessage string, error
publishLiveAuditUpdate(c, entry)
}

// HasRecordedError reports whether the request's audit entry already carries an
// error. Late failure paths consult it so a generic follow-up error cannot
// replace the original cause on the audit row.
func HasRecordedError(c *echo.Context) bool {
entry := entryFromContext(c)
return entry != nil && entry.ErrorType != ""
}

// EnrichEntryWithGatewayError records a gateway error on the log entry:
// type, message and code as EnrichEntryWithError does, plus the upstream
// provider the error originated from (empty for gateway-raised errors).
Expand Down
27 changes: 27 additions & 0 deletions internal/auditlog/enrich_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,30 @@ func TestEnrichEntryWithGatewayError(t *testing.T) {
})
}
}

func TestHasRecordedError(t *testing.T) {
tests := []struct {
name string
entry *LogEntry
want bool
}{
{name: "no entry on the context", entry: nil, want: false},
{name: "entry without an error", entry: &LogEntry{Data: &LogData{}}, want: false},
{name: "entry carrying an error", entry: &LogEntry{ErrorType: "not_found_error", Data: &LogData{}}, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
e := echo.New()
c := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil), httptest.NewRecorder())
if tc.entry != nil {
c.Set(string(LogEntryKey), tc.entry)
}
if got := HasRecordedError(c); got != tc.want {
t.Fatalf("HasRecordedError() = %v, want %v", got, tc.want)
}
})
}
if HasRecordedError(nil) {
t.Fatal("HasRecordedError(nil) = true, want false")
}
}
4 changes: 2 additions & 2 deletions internal/authkeys/store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ func scanSQLAuthKey(scanner authKeyScanner) (AuthKey, error) {
}
key.ExpiresAt = sqlutil.TimeFromUnixPtr(expiresAt)
key.DeactivatedAt = sqlutil.TimeFromUnixPtr(deactivatedAt)
key.CreatedAt = time.Unix(createdAt, 0).UTC()
key.UpdatedAt = time.Unix(updatedAt, 0).UTC()
key.CreatedAt = sqlutil.TimeFromUnix(createdAt)
key.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
return key, nil
}
4 changes: 2 additions & 2 deletions internal/budget/store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ func scanSQLBudget(scanner sqlx.Row) (Budget, error) {
return Budget{}, fmt.Errorf("scan budget: %w", err)
}
budget.LastResetAt = sqlutil.TimeFromUnixPtr(lastResetAt)
budget.CreatedAt = time.Unix(createdAt, 0).UTC()
budget.UpdatedAt = time.Unix(updatedAt, 0).UTC()
budget.CreatedAt = sqlutil.TimeFromUnix(createdAt)
budget.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
return budget, nil
}
3 changes: 3 additions & 0 deletions internal/core/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const (
// ErrorTypePermission indicates an authenticated caller lacks the
// permission for the operation (403)
ErrorTypePermission ErrorType = "permission_error"
// ErrorTypeInternal indicates a failure inside the gateway itself (500),
// as opposed to one an upstream provider reported
ErrorTypeInternal ErrorType = "internal_error"
)

// GatewayError is the base error type for all gateway errors
Expand Down
4 changes: 2 additions & 2 deletions internal/guardrails/store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ func scanSQLDefinition(scanner definitionScanner) (Definition, error) {
}
definition.UserPath = sqlutil.DerefTrimmed(userPath)
definition.Config = configJSON
definition.CreatedAt = time.Unix(createdAtUnix, 0).UTC()
definition.UpdatedAt = time.Unix(updatedAtUnix, 0).UTC()
definition.CreatedAt = sqlutil.TimeFromUnix(createdAtUnix)
definition.UpdatedAt = sqlutil.TimeFromUnix(updatedAtUnix)
return definition, nil
}
6 changes: 3 additions & 3 deletions internal/mcpgateway/store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import (
"errors"
"fmt"
"strings"
"time"

"github.com/enterpilot/gomodel/internal/storage/sqlutil"
"github.com/enterpilot/gomodel/internal/storage/sqlx"
)

Expand Down Expand Up @@ -210,7 +210,7 @@ func scanSQLMCPServer(scanner sqlx.Row) (ManagedServer, error) {
if server.DisplayName == "" {
server.DisplayName = server.Name
}
server.CreatedAt = time.Unix(createdAt, 0).UTC()
server.UpdatedAt = time.Unix(updatedAt, 0).UTC()
server.CreatedAt = sqlutil.TimeFromUnix(createdAt)
server.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
return server, nil
}
6 changes: 3 additions & 3 deletions internal/pricingoverrides/store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/goccy/go-json"

"github.com/enterpilot/gomodel/internal/storage/sqlutil"
"github.com/enterpilot/gomodel/internal/storage/sqlx"
)

Expand Down Expand Up @@ -124,7 +124,7 @@ func scanSQLOverride(rows sqlx.Rows) (Override, error) {
if err := json.Unmarshal(pricing, &override.Pricing); err != nil {
return Override{}, fmt.Errorf("decode pricing: %w", err)
}
override.CreatedAt = time.Unix(createdAt, 0).UTC()
override.UpdatedAt = time.Unix(updatedAt, 0).UTC()
override.CreatedAt = sqlutil.TimeFromUnix(createdAt)
override.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
return override, nil
}
6 changes: 3 additions & 3 deletions internal/providers/credentials_store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import (
"context"
"errors"
"fmt"
"time"

"github.com/enterpilot/gomodel/internal/storage/sqlutil"
"github.com/enterpilot/gomodel/internal/storage/sqlx"
)

Expand Down Expand Up @@ -205,7 +205,7 @@ func scanSQLCredential(scanner sqlx.Row) (ManagedProviderCredential, error) {
if cred.Models, err = decodeCredentialList(models); err != nil {
return ManagedProviderCredential{}, err
}
cred.CreatedAt = time.Unix(createdAt, 0).UTC()
cred.UpdatedAt = time.Unix(updatedAt, 0).UTC()
cred.CreatedAt = sqlutil.TimeFromUnix(createdAt)
cred.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
return cred, nil
}
5 changes: 3 additions & 2 deletions internal/ratelimit/store_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"github.com/enterpilot/gomodel/internal/storage/sqlutil"
"github.com/enterpilot/gomodel/internal/storage/sqlx"
)

Expand Down Expand Up @@ -317,8 +318,8 @@ func scanSQLRule(scanner sqlx.Row) (Rule, error) {
}
rule.MaxRequests = maxRequests
rule.MaxTokens = maxTokens
rule.CreatedAt = time.Unix(createdAt, 0).UTC()
rule.UpdatedAt = time.Unix(updatedAt, 0).UTC()
rule.CreatedAt = sqlutil.TimeFromUnix(createdAt)
rule.UpdatedAt = sqlutil.TimeFromUnix(updatedAt)
return rule, nil
}

Expand Down
87 changes: 86 additions & 1 deletion internal/server/error_support.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"

"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"

"github.com/enterpilot/gomodel/internal/anthropicapi"
"github.com/enterpilot/gomodel/internal/auditlog"
Expand Down Expand Up @@ -37,6 +38,85 @@ func writeGatewayError(c *echo.Context, gatewayErr *core.GatewayError) error {
return c.JSON(gatewayErr.HTTPStatusCode(), gatewayErr.ToJSON())
}

// gatewayErrorHandler renders every error that escapes a handler — a recovered
// panic, a response body the JSON serializer could not encode, or an
// echo.HTTPError raised by middleware — in the caller's wire dialect, and logs
// it. Echo's default handler answers with a bare
// {"message": "Internal Server Error"} and logs nothing at all, so such a
// failure reaches the operator as a 500 naming neither the endpoint nor the
// cause, and reaches the client in an envelope no OpenAI SDK can parse.
func gatewayErrorHandler(c *echo.Context, err error) {
if err == nil {
return
}
gatewayErr := escapedGatewayError(err)
// Log before checking whether the response can still be changed: a panic
// after the first streamed chunk must still reach the operator.
logHandledError(c, gatewayErr)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Finalize as handleError does, so an error that never passed through it —
// a panic, a rate-limit error returned rather than rendered — still lands
// on the audit row and keeps headers such as Retry-After. The audit half is
// skipped once an error is recorded: handleError returns its own response
// write failures here, and re-enriching would replace the real cause with
// this generic one.
if !auditlog.HasRecordedError(c) {
enrichAuditEntryWithProviderAttempts(c)
auditlog.EnrichEntryWithGatewayError(c, gatewayErr)
}
applyErrorResponseHeaders(c, err)

// Once the status line and body are on the wire nothing can be changed;
// the response stands as the handler left it.
if response, unwrapErr := echo.UnwrapResponse(c.Response()); unwrapErr == nil && response.Committed {
return
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
if writeErr := writeGatewayError(c, gatewayErr); writeErr != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
slog.Error("failed to send error response", "error", writeErr)
}
}

// escapedGatewayError classifies an error that reached the central handler. A
// gateway error is already shaped; an echo.HTTPError keeps its status; anything
// else is a failure inside the gateway and stays opaque to the client.
func escapedGatewayError(err error) *core.GatewayError {
if gatewayErr, ok := errors.AsType[*core.GatewayError](err); ok {
return gatewayErr
}

// Echo carries the intended status on the error itself — BodyLimit's 413,
// the router's 405, any middleware's echo.NewHTTPError. An error without
// one is a failure inside the gateway.
status := echo.StatusCode(err)
if status <= 0 {
status = http.StatusInternalServerError
}
if status < http.StatusInternalServerError {
return core.NewInvalidRequestErrorWithStatus(status, echoErrorMessage(err, status), err)
}
// A 5xx message describes what broke inside the gateway and can quote an
// internal error verbatim (echo's own Decompress middleware answers with
// err.Error()), so the client gets the same opaque text every other
// gateway 500 uses. The cause travels in Err, which only the logs read.
return &core.GatewayError{
Type: core.ErrorTypeInternal,
Message: "an unexpected error occurred",
StatusCode: status,
Err: err,
}
}

// echoErrorMessage is the client-facing text of an echo error: its own message
// when it carries one, else the status text.
func echoErrorMessage(err error, status int) string {
if httpErr, ok := errors.AsType[*echo.HTTPError](err); ok && httpErr.Message != "" {
return httpErr.Message
}
if text := http.StatusText(status); text != "" {
return text
}
return "request failed"
}

// handleRouteNotFound renders unknown-route 404s in the caller's wire dialect
// so SDK clients raise clean typed errors instead of parsing echo's default
// {"message": "Not Found"} body. Anthropic SDK clients are recognized by the
Expand Down Expand Up @@ -103,7 +183,12 @@ func logHandledError(c *echo.Context, gatewayErr *core.GatewayError) {
if gatewayErr.Code != nil {
attrs = append(attrs, "code", *gatewayErr.Code)
}
if gatewayErr.Err != nil {
// A recovered panic arrives as echo's PanicStackError, whose message is
// the panic value followed by the whole stack; split them so the message
// stays readable and the stack lands in its own attribute.
if panicErr, ok := errors.AsType[*middleware.PanicStackError](gatewayErr.Err); ok {
attrs = append(attrs, "panic", panicErr.Err, "stack", string(panicErr.Stack))
} else if gatewayErr.Err != nil {
attrs = append(attrs, "error", gatewayErr.Err)
}
if c != nil && c.Request() != nil {
Expand Down
Loading