Go-redis context canceled when calling from Typescript

273 Views Asked by At

I am trying to create a web admin dashboard, using Typescript with redux-saga fro frontend and for backend Docker, Golang, Postgres as "main" db and Redis to store access tokens; I followed the idea from this tutorial and its corresponding repo. I am now focusing on the JWT part.

This is the function that handles the signIn request, the one that should give the user a token:

func (h *Handler) signIn(c *gin.Context) {
    var req apipayloads.SignInRequestPayload

    if ok := bindData(c, &req); !ok {
        return
    }

    u := &models.User{
        Email:    req.Email,
        Password: req.Password,
    }

    ctx := c.Request.Context()
    err := h.UserService.SignIn(ctx, u)

    if err != nil {
        log.Printf("Failed to sign in user: %v\n", err.Error())
        c.JSON(apperrors.Status(err), gin.H{
            "error": err,
        })
        return
    }

    tokens, err := h.TokenService.NewTokenPair(ctx, u, "")

    if err != nil {
        log.Printf("Failed to create tokens for user: %v\n", err.Error())

        c.JSON(apperrors.Status(err), gin.H{
            "error": err,
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "accessToken":  tokens.AccessToken,
        "refreshToken": tokens.RefreshToken,
        "user": &models.UserPublic{
            Name:    u.Name,
            Surname: u.Surname,
            Email:   u.Email,
            IsAdmin: u.IsAdmin,
        },
    })

and the NewTokenPair function:

func (s *tokenService) NewTokenPair(ctx context.Context, u *models.User, prevTokenID string) (*models.Token, error) {
    // No need to use a repository for idToken as it is unrelated to any data source
    idToken, err := utils.GenerateAccessToken(u, s.PrivKey, s.AccessExpiration)
    if err != nil {
        log.Printf("Error generating accessToken for uid: %v. Error: %v\n", u.UID, err.Error())
        return nil, apperrors.NewInternal()
    }

    refreshToken, err := utils.GenerateRefreshToken(u.UID, s.RefreshSecret, s.RefreshExpiration)
    if err != nil {
        log.Printf("Error generating refreshToken for uid: %v. Error: %v\n", u.UID, err.Error())
        return nil, apperrors.NewInternal()
    }

    // set freshly minted refresh token to valid list
    if err := s.TokenRepository.SetRefreshToken(ctx, u.UID.String(), refreshToken.ID, refreshToken.ExpiresIn); err != nil {
        log.Printf("Error storing tokenID for uid: %v. Error: %v\n", u.UID, err.Error())
        return nil, apperrors.NewInternal()
    }

    // delete user's current refresh token (used when refreshing idToken)
    if prevTokenID != "" {
        if err := s.TokenRepository.DeleteRefreshToken(ctx, u.UID.String(), prevTokenID); err != nil {
            log.Printf("Could not delete previous refreshToken for uid: %v, tokenID: %v\n", u.UID.String(), prevTokenID)
        }
    }

    return &models.Token{
        AccessToken:  idToken,
        RefreshToken: refreshToken.SS,
    }, nil
}

My problem is that when I call the signin endpoint from Postman, I obtain the correct response. When I call it from the frontend I created with Typescript, I get

Could not SET refresh token to redis for userID/tokenID: 8f1f51fe-87f4-49af-8e1b-d0ec1b8d6504/de20cfa1-18a7-44e5-8abb-ad8009fe39d3: context canceled

I read online that the problem may be due to context.WithTimeout but I tried to replace it with context.Background and nothing changed...and as this is the first time I use both Go and Redis, I don't know where else I can even search for the problem.

Please let me know if you need more details or other code pieces to understand where is the problem.ù

EDIT:

As asked in the comments, this is the part where I use context.WithTimeout():

func Timeout(timeout time.Duration, errTimeout *apperrors.Error) gin.HandlerFunc {
    return func(c *gin.Context) {
        // set Gin's writer as our custom writer
        tw := &timeoutWriter{ResponseWriter: c.Writer, h: make(http.Header)}
        c.Writer = tw

        // wrap the request context with a timeout
        ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
        defer cancel()

        // update gin request context
        c.Request = c.Request.WithContext(ctx)

        finished := make(chan struct{})        // to indicate handler finished
        panicChan := make(chan interface{}, 1) // used to handle panics if we can't recover

        go func() {
            defer func() {
                if p := recover(); p != nil {
                    panicChan <- p
                }
            }()

            c.Next() // calls subsequent middleware(s) and handler
            finished <- struct{}{}
        }()

        select {
        case <-panicChan:
            // if we cannot recover from panic,
            // send internal server error
            e := apperrors.NewInternal()
            tw.ResponseWriter.WriteHeader(e.Status())
            eResp, _ := json.Marshal(gin.H{
                "error": e,
            })
            tw.ResponseWriter.Write(eResp)
        case <-finished:
            // if finished, set headers and write resp
            tw.mu.Lock()
            defer tw.mu.Unlock()
            // map Headers from tw.Header() (written to by gin)
            // to tw.ResponseWriter for response
            dst := tw.ResponseWriter.Header()
            for k, vv := range tw.Header() {
                dst[k] = vv
            }
            tw.ResponseWriter.WriteHeader(tw.code)
            // tw.wbuf will have been written to already when gin writes to tw.Write()
            tw.ResponseWriter.Write(tw.wbuf.Bytes())
        case <-ctx.Done():
            // timeout has occurred, send errTimeout and write headers
            tw.mu.Lock()
            defer tw.mu.Unlock()
            // ResponseWriter from gin
            tw.ResponseWriter.Header().Set("Content-Type", "application/json")
            tw.ResponseWriter.WriteHeader(errTimeout.Status())
            eResp, _ := json.Marshal(gin.H{
                "error": errTimeout,
            })
            tw.ResponseWriter.Write(eResp)
            c.Abort()
            tw.SetTimedOut()
        }
    }
}

and the, when I create the handler:

type Handler struct {
    UserService  interfaces.IUserService
    TokenService interfaces.ITokenService
}

type Config struct {
    R               *gin.Engine
    UserService     interfaces.IUserService
    TokenService    interfaces.ITokenService
    TimeoutDuration time.Duration
}

func NewHandler(c *Config) {
    h := &Handler{
        UserService:  c.UserService,
        TokenService: c.TokenService,
    }

    // Create an account group
    auth := c.R.Group(os.Getenv("AUTH_URL"))

    if gin.Mode() != gin.TestMode {
        auth.Use(middleware.Timeout(c.TimeoutDuration, apperrors.NewServiceUnavailable()))
        auth.POST("/signIn", h.signIn)
    } else {
        auth.POST("/signIn", h.signIn)
    }
}

I have also tried to remove this part and just do

auth := c.R.Group(os.Getenv("AUTH_URL"))
auth.POST("/signIn", h.signIn)

But nothing changed.

EDIT 2: Frontend code

I designed a login page where the user needs to insert email and password, and a button to perform the request. This page is on the url http://localhost:3001. After pressing the button, a redirection to http://localhost:3001/email=testdeffo%40test.com&password=test happens. Thisis what the call stack looks like from the developer tools:

call stack

The first call uses the method OPTIONS, and the third one is a GET. The middle one looks like this:

second call

This is the saga.ts code:

const login = async (payload: SignInRequestPayload) => {
  await axios.post<SignInResponsePayload[]>(
    (Config.test ? webapi.testUrl : webapi.prodUrl) + webapi.auth.signIn,
    {
      ...payload,
    },
    {
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
    }
  );
};

function* loginSaga(action: any) {
  try {
    const response: SignInSuccessPayload = yield call(login, {
      email: action.payload.email,
      password: action.payload.password,
    });
    console.log("LOG 1")
    yield put(signInSuccess(response));
  } catch (e: any) {
    yield put(
      signInFailure({
        error: e.message,
      })
    );
  }
}

function* authSaga() {
  yield all([takeLatest(SIGNIN_REQUEST, loginSaga)]);
}

export default authSaga;

I did put some console.log around, and what happens is that the code never prints LOG1. I also tried to add a return in the login function like this:

const login = async (payload: SignInRequestPayload) => {
  const { data } = await axios.post<SignInResponsePayload>(
    (Config.test ? webapi.testUrl : webapi.prodUrl) + webapi.auth.signIn,
    {
      ...payload,
    }
  );
  console.info("LOG2");
  return data;
};

But also in this case, LOG2 never gets printed.

EDIT 3

Maybe it can be useful if I also add my backend docker-compose.yaml:

version: "3.8"
services:
  reverse-proxy:
    # The official v2 Traefik docker image
    image: traefik:v2.2
    # Enables the web UI and tells Traefik to listen to docker
    command:
      - "--api.insecure=true"
      - "--providers.docker"
      - "--providers.docker.exposedByDefault=false"
    ports:
      # The HTTP port
      - "80:80"
      # The Web UI (enabled by --api.insecure=true)
      - "8080:8080"
    volumes:
      # So that Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock
  postgres-auth:
    image: "postgres:alpine"
    environment:
      - POSTGRES_PASSWORD=password
    ports:
      - "5432:5432"
    #   Set a volume for data and initial sql script
    #   May configure initial db for future demo
    volumes:
      - "pgdata_auth:/var/lib/postgresql/data"
      # - ./init:/docker-entrypoint-initdb.d/
    command: ["postgres", "-c", "log_statement=all"]
  redis-auth:
    image: "redis:alpine"
    ports:
      - "6379:6379"
    volumes:
      - "redisdata_auth:/data"
  apiserver:
    build:
      context: .
      target: builder
    image: apiserver
    expose:
      - "8080"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.apiserver.rule=Host(`laboratoriomister.test`) && PathPrefix(`/api/`)"
      - "traefik.http.middlewares.cors.headers.accesscontrolalloworigin=*"
      - "traefik.http.middlewares.cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
      - "traefik.http.middlewares.cors.headers.accesscontrolallowheaders=Content-Type"
    environment:
      - ENV=dev
    volumes:
      - .:/app
    # have to use $$ (double-dollar) so docker doesn't try to substitute a variable
    command: reflex -r "\.go$$" -s -- sh -c "go run ./"
    env_file: .env
    depends_on:
      - postgres-auth
      - redis-auth
volumes:
  pgdata_auth:
  redisdata_auth:

Please let me know if you need more information or code pieces. Thanks in advance

1

There are 1 best solutions below

1
VonC On BEST ANSWER

A "context canceled" error should mean the context you are using in the request is canceled before the operation can complete.
Your signIn endpoint workflow is:

[Client (Typescript)] --HTTP Request--> [Go Server] --Set Token--> [Redis]

Check if the Redis operation is taking too long and the context is timing out. And make sure the context passed to the Redis operation is not canceled prematurely.

func (s *tokenService) setRefreshTokenWithCtx(ctx context.Context, userID, tokenID string, expiresIn time.Duration) error {
    // That will block until the token is set or the context is canceled
    err := s.TokenRepository.SetRefreshToken(ctx, userID, tokenID, expiresIn)
    if err != nil {
        return fmt.Errorf("could not SET refresh token to redis for userID/tokenID: %s/%s: %v", userID, tokenID, err)
    }
    return nil
}

When you call this function, pass in a context that has a reasonable deadline or timeout:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

err := setRefreshTokenWithCtx(ctx, u.UID.String(), refreshToken.ID, refreshToken.ExpiresIn)
if err != nil {
    log.Printf("Error: %v\n", err)
    // Handle the error, possibly by returning a specific error to the client
}

In your TypeScript code, you need to handle responses and potential timeouts:

const login = async (payload: SignInRequestPayload): Promise<SignInResponsePayload> => {
  try {
    const response = await axios.post<SignInResponsePayload>(
      `${Config.test ? webapi.testUrl : webapi.prodUrl}${webapi.auth.signIn}`,
      payload,
      {
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
        },
        timeout: 5000, // Set a timeout for the request
      }
    );
    return response.data;
  } catch (error) {
    console.error("An error occurred during login:", error);
    throw error;
  }
};

Finally, make sure your frontend is correctly handling CORS and preflight requests, as this could also lead to canceled contexts if not handled properly. If the preflight request does not succeed due to CORS policy, the actual request might be canceled. See "Issue with CORS that’s been absolutely killing me - I’m sure it’s a basic config issue I’m missing!" by austincollinpena for illustration: that means making sure Traefik is configured to handle CORS requests appropriately:

labels:
  - "traefik.http.middlewares.myapp-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
  - "traefik.http.middlewares.myapp-cors.headers.accesscontrolallowheaders=*"
  - "traefik.http.middlewares.myapp-cors.headers.accesscontrolalloworigin=*"
  - "traefik.http.routers.myapp.middlewares=myapp-cors"

From the comments, if the browser is canceling the original request due to a navigation event (like a redirect), this would result in the context canceled error in Go.
So make sure the frontend does not navigate away from the page or redirect before the API call is completed.

In your frontend code, particularly in the submit handler of your form, you should prevent the default form submission behavior that causes the page to reload or navigate away. That is typically done using event.preventDefault().

Removing the middleware does not affect the problem, so that is not an issue. Still, It is a good practice to use the request's context when available, as it is tied to the lifecycle of the request. context.Background() is a better fit for background tasks and initializations.

// Your form submission handler
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
  event.preventDefault(); // That prevents the default form behavior

  // Your login logic
  try {
    const response = await login({
      email: '[email protected]',
      password: 'pass',
    });
    // Handle successful login here
  } catch (error) {
    // Handle errors here
  }
};

// Your login function remains unchanged

Attach this handleSubmit function to your form's onSubmit event:

<form onSubmit={handleSubmit}>
  {/* Your form inputs... */}
  <button type="submit">Login</button>
</form>

For your Go server-side code, use the request's context as it allows for better control and cleanup, especially when dealing with user requests that can be terminated by the user at any point.
Only use context.Background() if there is no request context available or for operations that need to be completely decoupled from the request's lifecycle.