Skip to content

Commit

Permalink
feat: error handling and proper status codes (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
nekomeowww authored Dec 17, 2024
1 parent b5a0b3f commit bfbf5e8
Show file tree
Hide file tree
Showing 13 changed files with 1,004 additions and 42 deletions.
3 changes: 3 additions & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ words:
- cyclop
- depguard
- Describedby
- Detailf
- dupl
- durationcheck
- elevenlabs
- errcheck
- errchkjson
- errname
- execinquery
- exhaustive
Expand Down Expand Up @@ -59,6 +61,7 @@ words:
- predeclared
- reassign
- revive
- samber
- staticcheck
- strconv
- tagalign
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ require (
github.com/samber/lo v1.47.0
github.com/samber/mo v1.13.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
k8s.io/client-go v0.32.0
)

require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/gobuffalo/envy v1.7.0 // indirect
github.com/gobuffalo/packd v0.3.0 // indirect
github.com/gobuffalo/packr v1.30.1 // indirect
Expand All @@ -20,6 +23,7 @@ require (
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.3.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
Expand All @@ -31,4 +35,5 @@ require (
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
12 changes: 10 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gobuffalo/envy v1.7.0 h1:GlXgaiBkmrYMHco6t4j7SacKO4XUjvh5pwXh0f4uxXU=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
Expand All @@ -28,9 +29,12 @@ github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqx
github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.2 h1:9aAt4hstpH54qIcqkuUXRLTf+v7yOTfMPWzDtuqLmtA=
github.com/labstack/echo/v4 v4.13.2/go.mod h1:uc9gDtHB8UWt3FfbYx0HyxcCuvR4YuPYOxF/1QjoV/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
Expand All @@ -46,8 +50,9 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/nekomeowww/fo v1.4.0 h1:ULX5KsnDzWHoDwHgtjd2wibpdpyh+5/5DITmvhJZyWY=
github.com/nekomeowww/fo v1.4.0/go.mod h1:ctwQ+BZ0UYUb2s+yM7h9SFHjqGCXeUIXFLK2ujAneWw=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
Expand Down Expand Up @@ -117,9 +122,12 @@ golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20190624180213-70d37148ca0c/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8=
k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8=
14 changes: 14 additions & 0 deletions pkg/apierrors/apierrors.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ func (e *Error) WithSourceHeader(header string) *Error {
return e
}

func (e *Error) WithReason(reason string) *Error {
return e.WithMeta("reason", reason)
}

func (e *Error) WithMeta(key string, val any) *Error {
if e.Meta.IsAbsent() {
e.Meta = mo.Some(map[string]any{})
}

e.Meta.MustGet()[key] = val

return e
}

type ErrResponse struct {
jsonapi.Response
}
Expand Down
56 changes: 34 additions & 22 deletions pkg/apierrors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,6 @@ func NewErrBadRequest() *Error {
WithDetail("The request was invalid or cannot be served")
}

func NewErrInternal() *Error {
return NewError(http.StatusInternalServerError, "INTERNAL_SERVER_ERROR").
WithTitle("Internal Server Error").
WithDetail("An internal server error occurred")
}

func NewErrPermissionDenied() *Error {
return NewError(http.StatusForbidden, "PERMISSION_DENIED").
WithTitle("Permission Denied").
WithDetail("You do not have permission to access the requested resources")
}

func NewErrUnavailable() *Error {
return NewError(http.StatusServiceUnavailable, "UNAVAILABLE").
WithTitle("Service Unavailable").
WithDetail("The requested service is unavailable")
}

func NewErrInvalidArgument() *Error {
return NewError(http.StatusBadRequest, "INVALID_ARGUMENT").
WithTitle("Invalid Argument").
Expand All @@ -46,6 +28,18 @@ func NewErrUnauthorized() *Error {
WithDetail("The requested resources require authentication")
}

func NewErrPermissionDenied() *Error {
return NewError(http.StatusForbidden, "PERMISSION_DENIED").
WithTitle("Permission Denied").
WithDetail("You do not have permission to access the requested resources")
}

func NewErrForbidden() *Error {
return NewError(http.StatusForbidden, "FORBIDDEN").
WithTitle("Forbidden").
WithDetail("You do not have permission to access the requested resources")
}

func NewErrNotFound() *Error {
return NewError(http.StatusNotFound, "NOT_FOUND").
WithTitle("Not Found").
Expand All @@ -64,8 +58,26 @@ func NewErrQuotaExceeded() *Error {
WithDetail("The request quota has been exceeded")
}

func NewErrForbidden() *Error {
return NewError(http.StatusForbidden, "FORBIDDEN").
WithTitle("Forbidden").
WithDetail("You do not have permission to access the requested resources")
func NewErrInternal() *Error {
return NewError(http.StatusInternalServerError, "INTERNAL_SERVER_ERROR").
WithTitle("Internal Server Error").
WithDetail("An internal server error occurred")
}

func NewErrBadGateway() *Error {
return NewError(http.StatusBadGateway, "BAD_GATEWAY").
WithTitle("Bad gateway").
WithDetail("The server received an invalid response from an upstream server")
}

func NewErrUnavailable() *Error {
return NewError(http.StatusServiceUnavailable, "UNAVAILABLE").
WithTitle("Service Unavailable").
WithDetail("The requested service is unavailable")
}

func NewUpstreamError(statusCode int) *Error {
return NewError(statusCode, "UPSTREAM_ERROR").
WithTitle("Upstream Error").
WithReason("An error occurred while processing the request from the upstream service")
}
15 changes: 6 additions & 9 deletions pkg/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,29 +36,26 @@ type FullOptions struct {
}

func Speech(c echo.Context) mo.Result[any] {
options := new(Options)
var options Options

if err := c.Bind(options); err != nil {
return mo.Err[any](apierrors.NewErrBadRequest().WithCaller())
if err := c.Bind(&options); err != nil {
return mo.Err[any](apierrors.NewErrBadRequest())
}

if options.Model == "" || options.Input == "" || options.Voice == "" {
return mo.Err[any](apierrors.NewErrBadRequest().WithCaller())
return mo.Err[any](apierrors.NewErrInvalidArgument().WithDetail("either one of model, input, and voice parameter is required"))
}

backendAndModel := lo.Ternary(
strings.Contains(options.Model, ":"),
//nolint:mnd
strings.SplitN(options.Model, ":", 2),
strings.SplitN(options.Model, ":", 2), //nolint:mnd
[]string{options.Model, ""},
)

fullOptions := FullOptions{
Options: *options,
Options: options,
Backend: backendAndModel[0],
Model: backendAndModel[1],
}

return openai(c, fullOptions)
// return mo.Ok[any](c.JSON(http.StatusOK, fullOptions))
}
71 changes: 71 additions & 0 deletions pkg/backend/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package backend

import (
"encoding/json"
"io"

"github.com/moeru-ai/unspeech/pkg/utils"
"github.com/samber/lo"
"github.com/samber/mo"
)

var _ error = (*JSONResponseError)(nil)

type JSONResponseError struct {
StatusCode int `json:"status_code"`
Message string `json:"message"`

bodyParsed map[string]any
}

func NewJSONResponseError(statusCode int, responseBody io.Reader) mo.Result[*JSONResponseError] {
jsonData, err := io.ReadAll(responseBody)
if err != nil {
return mo.Err[*JSONResponseError](err)
}

resp := &JSONResponseError{
StatusCode: statusCode,
}

err = json.Unmarshal(jsonData, &resp.bodyParsed)
if err != nil {
return mo.Err[*JSONResponseError](err)
}

errorMessage := utils.GetByJSONPath[string](resp.bodyParsed, "{ .message }")
errorStr := utils.GetByJSONPath[string](resp.bodyParsed, "{ .error }")
errorMap := utils.GetByJSONPath[map[string]any](resp.bodyParsed, "{ .error }")
errorStrFromErrorMap := utils.GetByJSONPath[string](errorMap, "{ .message }")

resp.Message = lo.Must(lo.Coalesce(errorMessage, errorStr, errorStrFromErrorMap, "Unknown error"))

return mo.Ok(resp)
}

func (r *JSONResponseError) Error() string {
return r.Message
}

var _ error = (*TextResponseError)(nil)

type TextResponseError struct {
StatusCode int `json:"status_code"`
Body string `json:"body"`
}

func (r *TextResponseError) Error() string {
return r.Body
}

func NewTextResponseError(statusCode int, responseBody io.Reader) mo.Result[*TextResponseError] {
data, err := io.ReadAll(responseBody)
if err != nil {
return mo.Err[*TextResponseError](err)
}

return mo.Ok(&TextResponseError{
StatusCode: statusCode,
Body: string(data),
})
}
32 changes: 25 additions & 7 deletions pkg/backend/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package backend
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"strings"

"github.com/labstack/echo/v4"
"github.com/moeru-ai/unspeech/pkg/apierrors"
"github.com/nekomeowww/fo"
"github.com/samber/mo"
)

Expand All @@ -20,7 +21,7 @@ func openai(c echo.Context, options FullOptions) mo.Result[any] {
Speed: options.Speed,
}

payload := fo.May(json.Marshal(values))
payload, _ := json.Marshal(values) //nolint:errchkjson

req, err := http.NewRequestWithContext(
c.Request().Context(),
Expand All @@ -29,21 +30,38 @@ func openai(c echo.Context, options FullOptions) mo.Result[any] {
bytes.NewBuffer(payload),
)
if err != nil {
return mo.Err[any](apierrors.NewErrBadRequest().WithCaller())
return mo.Err[any](apierrors.NewErrInternal().WithCaller())
}

// TODO: Bearer Auth
// Proxy the Authorization header
req.Header.Set("Authorization", c.Request().Header.Get("Authorization"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)

if err != nil {
return mo.Err[any](apierrors.NewErrBadRequest().WithCaller())
return mo.Err[any](apierrors.NewErrBadGateway().WithDetail(err.Error()).WithError(err).WithCaller())
}

defer res.Body.Close()

// body, _ := io.ReadAll(res.Body)
if res.StatusCode >= 400 && res.StatusCode < 600 {
switch {
case strings.HasPrefix(res.Header.Get("Content-Type"), "application/json"):
return mo.Err[any](apierrors.
NewUpstreamError(res.StatusCode).
WithDetail(NewJSONResponseError(res.StatusCode, res.Body).OrEmpty().Error()))
case strings.HasPrefix(res.Header.Get("Content-Type"), "text/"):
return mo.Err[any](apierrors.
NewUpstreamError(res.StatusCode).
WithDetail(NewTextResponseError(res.StatusCode, res.Body).OrEmpty().Error()))
default:
slog.Warn("unknown upstream error with unknown Content-Type",
slog.Int("status", res.StatusCode),
slog.String("content-type", res.Header.Get("Content-Type")),
slog.String("content-length", res.Header.Get("Content-Length")),
)
}
}

return mo.Ok[any](c.Stream(http.StatusOK, "audio/mp3", res.Body))
}
2 changes: 0 additions & 2 deletions pkg/jsonapi/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ type ErrorObject struct {
Links mo.Option[*Links] `json:"links,omitempty"`
// the HTTP status code applicable to this problem, expressed as a string value.
Status int `json:"status,omitempty"`
// the HTTP status code applicable to this problem, expressed as a string value.
GrpcStatus uint64 `json:"grpc_status,omitempty"`
// an application-specific error code, expressed as a string value.
Code string `json:"code,omitempty"`
// a short, human-readable summary of the problem
Expand Down
Loading

0 comments on commit bfbf5e8

Please sign in to comment.