amplify-js icon indicating copy to clipboard operation
amplify-js copied to clipboard

Getting error NotAuthorizedException: Invalid login token. Token expired:

Open mattiLeBlanc opened this issue 8 months ago • 18 comments

Before opening, please confirm:

JavaScript Framework

Angular

Amplify APIs

Authentication

Amplify Version

v6

Amplify Categories

auth, api

Backend

CDK

Environment information

 System:
    OS: macOS 14.4.1
    CPU: (10) arm64 Apple M1 Max
    Memory: 37.19 GB / 64.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.14.0 - /usr/local/bin/node
    Yarn: 1.22.18 - ~/.npm-global/bin/yarn
    npm: 8.19.1 - ~/.npm-global/bin/npm
    pnpm: 9.0.0 - ~/.npm-global/bin/pnpm
  Browsers:
    Chrome: 135.0.7049.85
    Safari: 17.4.1
  npmPackages:
    %name%:  0.1.0 
    @angular-devkit/build-angular: ^19.2.3 => 19.2.3 
    @angular/animations: ^19.2.2 => 19.2.2 
    @angular/cdk: ^19.2.3 => 19.2.3 
    @angular/cli: ^19.2.3 => 19.2.3 
    @angular/common: ^19.2.2 => 19.2.2 
    @angular/compiler: ^19.2.2 => 19.2.2 
    @angular/compiler-cli: ^19.2.2 => 19.2.2 
    @angular/core: ^19.2.2 => 19.2.2 
    @angular/elements: ^19.2.2 => 19.2.2 
    @angular/forms: ^19.2.2 => 19.2.2 
    @angular/material: ^19.2.3 => 19.2.3 
    @angular/material-moment-adapter: ^19.2.3 => 19.2.3 
    @angular/platform-browser: ^19.2.2 => 19.2.2 
    @angular/platform-browser-dynamic: ^19.2.2 => 19.2.2 
    @angular/router: ^19.2.2 => 19.2.2 
    @angular/youtube-player: ^19.2.3 => 19.2.3 
    @apollo/pdf: file:libs/pdf => 1.0.0 
    @apollo/shared: file:libs/shared => 1.0.1 
    @aws-appsync/utils: ^1.5.0 => 1.12.0 
    @aws-sdk/client-acm: ^3.568.0 => 3.758.0 
    @aws-sdk/client-appsync: ^3.568.0 => 3.770.0 
    @aws-sdk/client-cognito-identity: ^3.485.0 => 3.768.0 
    @aws-sdk/client-cognito-identity-provider: ^3.568.0 => 3.768.0 
    @aws-sdk/client-dynamodb: ^3.568.0 => 3.767.0 
    @aws-sdk/client-dynamodb-streams: ^3.563.0 => 3.758.0 
    @aws-sdk/client-kms: ^3.568.0 => 3.758.0 
    @aws-sdk/client-lambda: ^3.568.0 => 3.758.0 
    @aws-sdk/client-opensearch: ^3.563.0 => 3.758.0 
    @aws-sdk/client-osis: ^3.563.0 => 3.758.0 
    @aws-sdk/client-s3: ^3.568.0 => 3.758.0 
    @aws-sdk/client-secrets-manager: ^3.568.0 => 3.758.0 
    @aws-sdk/client-ses: ^3.568.0 => 3.758.0 
    @aws-sdk/client-sns: ^3.568.0 => 3.758.0 
    @aws-sdk/credential-provider-ini: ^3.568.0 => 3.758.0 (3.621.0)
    @aws-sdk/s3-request-presigner: ^3.568.0 => 3.758.0 
    @aws-sdk/signature-v4: ^3.374.0 => 3.374.0 
    @aws-sdk/util-dynamodb: ^3.568.0 => 3.767.0 
    @cypress/angular:  0.0.0-development 
    @cypress/angular-signals:  0.0.0-development 
    @cypress/mount-utils:  0.0.0-development 
    @cypress/react:  0.0.0-development 
    @cypress/react18:  0.0.0-development 
    @cypress/svelte:  0.0.0-development 
    @cypress/vue:  0.0.0-development 
    @cypress/vue2:  0.0.0-development 
    @fontsource/poppins: ^5.0.8 => 5.2.5 
    @googleapis/sheets: ^5.0.5 => 5.0.5 
    @googleapis/youtube: ^12.0.0 => 12.0.0 
    @graphql-codegen/cli: ^2.16.4 => 2.16.5 
    @graphql-codegen/typed-document-node: ^2.3.12 => 2.3.13 
    @graphql-codegen/typescript: ^2.8.7 => 2.8.8 
    @graphql-codegen/typescript-operations: ^2.5.12 => 2.5.13 
    @graphql-tools/load-files: ^7.0.0 => 7.0.1 
    @graphql-tools/merge: ^9.0.0 => 9.0.24 (8.4.2)
    @graphql-typed-document-node/core: ^3.1.1 => 3.2.0 
    @iplab/ngx-file-upload: ^ 19.0.3  => 19.0.3 
    @jest/globals: ^29.7.0 => 29.7.0 
    @slack/web-api: ^6.3.0 => 6.13.0 
    @swc/cli: ^0.1.62 => 0.1.65 
    @swc/core: ^1.3.68 => 1.11.11 
    @tailwindcss/postcss: ^4.0.8 => 4.0.14 
    @types/aws-lambda: ^8.10.82 => 8.10.147 
    @types/jasmine: ~5.1.0 => 5.1.7 
    @types/jest: ^29.5.12 => 29.5.14 
    @types/marked: ^4.0.8 => 4.3.2 
    @types/node: 18.0.6 => 18.0.6 
    @types/nodemailer: ^6.4.4 => 6.4.17 
    @types/oauth-signature: ^1.5.0 => 1.5.2 
    @types/pdf-parse: ^1.1.1 => 1.1.4 
    @types/pdfmake: ^0.2.2 => 0.2.11 
    @types/prettier: 2.6.0 => 2.6.0 
    @types/qrcode: ^1.5.5 => 1.5.5 
    @types/qs: ^6.9.7 => 6.9.18 
    @types/uuid: ^9.0.0 => 9.0.8 
    @types/vimeo: ^2.1.5 => 2.1.8 
    @types/vimeo__player: ^2.16.3 => 2.18.3 
    @types/youtube: ^0.0.47 => 0.0.47 (0.1.0)
    @typescript-eslint/eslint-plugin: ^6.0.0 => 6.21.0 
    @typescript-eslint/parser: ^6.0.0 => 6.21.0 
    @vimeo/player: ^2.18.0 => 2.26.0 
    angular-google-tag-manager: ^1.11.0 => 1.11.0 
    angularx-qrcode: ^19.0.0  => 19.0.0 
    autoprefixer: ^10.4.16 => 10.4.21 (10.4.20)
    aws-amplify: ^6.13.1 => 6.13.5 
    aws-amplify/adapter-core:  undefined ()
    aws-amplify/adapter-core/internals:  undefined ()
    aws-amplify/analytics:  undefined ()
    aws-amplify/analytics/kinesis:  undefined ()
    aws-amplify/analytics/kinesis-firehose:  undefined ()
    aws-amplify/analytics/personalize:  undefined ()
    aws-amplify/analytics/pinpoint:  undefined ()
    aws-amplify/api:  undefined ()
    aws-amplify/api/internals:  undefined ()
    aws-amplify/api/server:  undefined ()
    aws-amplify/auth:  undefined ()
    aws-amplify/auth/cognito:  undefined ()
    aws-amplify/auth/cognito/server:  undefined ()
    aws-amplify/auth/enable-oauth-listener:  undefined ()
    aws-amplify/auth/server:  undefined ()
    aws-amplify/data:  undefined ()
    aws-amplify/data/server:  undefined ()
    aws-amplify/datastore:  undefined ()
    aws-amplify/in-app-messaging:  undefined ()
    aws-amplify/in-app-messaging/pinpoint:  undefined ()
    aws-amplify/push-notifications:  undefined ()
    aws-amplify/push-notifications/pinpoint:  undefined ()
    aws-amplify/storage:  undefined ()
    aws-amplify/storage/s3:  undefined ()
    aws-amplify/storage/s3/server:  undefined ()
    aws-amplify/storage/server:  undefined ()
    aws-amplify/utils:  undefined ()
    aws-appsync: ^4.1.9 => 4.1.10 
    aws-cdk: ^2.173.1 => 2.1004.0 
    aws-cdk-lib: ^2.173.1 => 2.184.1 
    aws-sdk: ^2.1531.0 => 2.1692.0 
    axios: ^1.2.1 => 1.8.3 
    constructs: ^10.3.0 => 10.4.2 
    copyfiles: ^2.4.1 => 2.4.1 
    cross-fetch: ^3.1.5 => 3.2.0 
    cross-fetch-polyfill:  0.0.0 
    csvtojson: ^2.0.10 => 2.0.10 
    cypress: ^13.3.2 => 13.17.0 
    dayjs: ^1.11.10 => 1.11.13 
    dotenv: ^16.0.3 => 16.4.7 
    esbuild: ^0.18.11 => 0.18.20 (0.25.1)
    eslint: ^8.57.1 => 8.57.1 
    google-auth-library: ^9.1.0 => 9.15.1 
    graphql: 14.7.0 => 14.7.0 (15.8.0)
    graphql-tag: ^2.12.6 => 2.12.6 
    jasmine-core: ~5.1.0 => 5.1.2 (4.6.1)
    jest: ^29.7.0 => 29.7.0 
    json-2-csv: ^3.18.0 => 3.20.0 
    jwt-simple: ^0.5.6 => 0.5.6 
    karma: ~6.4.0 => 6.4.4 
    karma-chrome-launcher: ~3.2.0 => 3.2.0 
    karma-coverage: ~2.2.0 => 2.2.1 
    karma-coverage-coffee-example:  1.0.0 
    karma-jasmine: ~5.1.0 => 5.1.0 
    karma-jasmine-html-reporter: ~2.1.0 => 2.1.0 
    marked: ^4.2.12 => 4.3.0 
    material-icons: ^1.13.12 => 1.13.14 
    material-symbols: ^0.28.2 => 0.28.2 
    mochawesome: ^7.1.3 => 7.1.3 
    mochawesome-merge: ^4.3.0 => 4.4.1 
    mochawesome-report-generator: ^6.2.0 => 6.2.0 
    node-device-detector: ^2.1.6 => 2.2.0 
    nodemailer: ^6.9.3 => 6.10.0 
    nosleep.js: ^0.12.0 => 0.12.0 
    nx: ^19.5.3 => 19.8.14 
    oauth-signature: ^1.5.0 => 1.5.0 
    pdf-parse: ^1.1.1 => 1.1.1 
    pdfmake: ^0.2.18 => 0.2.18 
    prettier: ^3.3.3 => 3.5.3 
    process: ^0.11.10 => 0.11.10 
    qs: ^6.11.0 => 6.14.0 (6.13.0)
    rxjs: ~7.8.0 => 7.8.2 (7.8.1)
    rxjs/ajax:  undefined ()
    rxjs/fetch:  undefined ()
    rxjs/operators:  undefined ()
    rxjs/testing:  undefined ()
    rxjs/webSocket:  undefined ()
    sharp: ^0.33.5 => 0.33.5 
    short-uuid: ^4.2.2 => 4.2.2 
    source-map-support: ^0.5.21 => 0.5.21 (0.5.13)
    tailwindcss: 3.4.17 => 3.4.17 (4.0.14)
    ts-jest: ^29.2.3 => 29.2.6 
    ts-node: ^10.9.1 => 10.9.2 
    tslib: ^2.3.0 => 2.8.1 (1.14.1, 2.4.1)
    tsx: ^4.7.0 => 4.19.3 
    typescript: ^5.0.4 => 5.8.2 
    uuid: ^9.0.0 => 9.0.1 (8.3.2, 3.4.0, 8.0.0)
    vimeo: ^2.3.1 => 2.3.1 
    web-animations-js: ^2.3.2 => 2.3.2 
    zen-observable-ts: 1.1.0 => 1.1.0 (0.8.21, 1.2.5)
    zone.js: ~0.15.0 => 0.15.0 
    zxcvbn: ^4.4.2 => 4.4.2 
  npmGlobalPackages:
    @angular/cli: 19.1.6
    angular-http-server: 1.10.0
    aws-cdk: 2.173.4
    aws: 0.0.3-2
    envinfo: 7.13.0
    firebase-tools: 11.16.1
    nativescript: 8.2.3
    node-gyp: 8.4.1
    npm: 8.19.1
    pnpm: 9.0.0
    sass-migrator: 2.3.1
    yarn: 1.22.18


Describe the bug

On app refresh I am getting: Uncaught (in promise) NotAuthorizedException: Invalid login token. Token expired: 1744757890 >= 1744728899

My idToken is expired, but my refreshToken is still valid (as far as I know).

The stack trace:

Image

It is actually triggered by a cognito call trying to do Login, which returns: {"__type":"NotAuthorizedException","message":"Invalid login token. Token expired: 1744757890 >= 1744728899"}

I am not sure how to reproduce this, what happened is: I am logged in, my macbook ran out of battery overnight, I charged and turned it on and went back to the session of my app which now shows this error.

I also noticed a similar token error running virtual machine in Android Studio, and running my Angular app in Capacitor and after closing down the VM and start it back up, which keeps the state, it was not able to login or recover from the expired session.

Is there a bug somewhere when a token is expired?

Expected behavior

Request a new idtoken via refreshtoken if idToken is expired

Reproduction steps

I am not sure how to reproduce this, what happened is: I am logged in, my macbook ran out of battery overnight, I charged and turned it on and went back to the session of my app which now shows this error.

I also noticed a similar token error running virtual machine in Android Studio, and running my Angular app in Capacitor and after closing down the VM and start it back up, which keeps the state, it was not able to login or recover from the expired session.

I am adding some screenshots of the stack trace source code:

Image Image Image Image Image Image

Code Snippet

// Put your code below this line.

Log output

// Put your logs below this line


aws-exports.js

No response

Manual configuration

No response

Additional configuration

No response

Mobile Device

No response

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

No response

mattiLeBlanc avatar Apr 15 '25 23:04 mattiLeBlanc

Hi @mattiLeBlanc fetchAuthSession() should refresh expired tokens automatically, and then use the refreshed idToken to retrieve AWS credentials, this process is being verified by integration tests. I'm wondering if some external factors was disturbing the process.

Do you have the network requests records when this error occurred? I'd like to inspect whether the corresponding service calls were corrected made.

HuiSF avatar Apr 16 '25 15:04 HuiSF

I dont have them at the moment. I think it recovered after numerous refreshes, because I was persistent. But it kind a was a dead end, and since it is running on a TV (android Tv using Capacitor) the only way out is kill the app.

If I see the issue again, I will try to record the Network error. I remember it was just trying to do the first cognito call with payload Logins: { cognito-idp.ap...... : '323444234 }. That one failed

mattiLeBlanc avatar Apr 17 '25 02:04 mattiLeBlanc

Thanks for the additional details @mattiLeBlanc you mentioned

I remember it was just trying to do the first cognito call with payload Logins: { cognito-idp.ap...... : '323444234 }. That one failed

This actual matches the error shown in the screenshot, it's the step when fetch the AWS credentials using id token, but the id token expired.

fetchAuthSession() does automatically (when tokens are expired) and proactively (5 seconds prior to expiration) refresh tokens, and when tokens are refreshed, it also fetch new AWS credentials using refreshed id token.

Looking at the error returned by the service, however, it looks like that when it was fetching the AWS credentials, the id token is still active, but when the request reached to the service, the id token became expired. This sounds like a clock skew issue.

Amplify library does record the clock skew if there is a time gap between the client and service which mitigates this. Could you check whether the tokens store has an item with name that ends with .clockDrift?

In addition, since you mentioned that your app is running on the TV, I'm not sure if there is any system behavior that delays sending out the network request from your app, such as an app suspense or something similar?

HuiSF avatar Apr 17 '25 16:04 HuiSF

Hi @mattiLeBlanc, following up to check if you got a change to look at the comment above

ashwinkumar6 avatar Apr 28 '25 20:04 ashwinkumar6

@ashwinkumar6 sorry for not getting back to you. I can confirm he there is a .clockDrift available in the sessions I see on tablet, laptop and tv. In regards to the CapacitorJS wrapped Angular app installed on a Chromecast; it the Chromecast could be turned off for a while but still has a valid refreshToken. I havent seen it there but I did see it on my Macbook as mentioned in the ticket. Is there a way the Auth code can recover from that specific error, like even IF the token is expired, but the refreshToken is still valid, I wouldnt expect that error to be displayed. I would expect it would just get a new token. But I am not aware of all the logic checks being done before issuing a new AccessToken.

mattiLeBlanc avatar Apr 28 '25 23:04 mattiLeBlanc

@mattiLeBlanc our suggestion would be to retry the API call again if you hit Invalid login Token error. What you are seeing might be happening because of the OS involved and the calls are delayed causing this issue. And Amplify does not have any control over it. So retrying based on the specific error might be the best way to go about it. And when fetchAuthSession if the refreshToken is valid it will use that to get tokens again or if it got expired, it will fetch new refreshToken and accessTokens.

ashika112 avatar Apr 29 '25 22:04 ashika112

@ashika112 I set the refresh token to expiry in 90 years or so, so it should always attempt to get a new accesstoken, right? Problem is, when this happens the end user is just looking at white screen and cant do anything. Maybe I can catch the error and attempt a refresh or some other way to repair the situation.

mattiLeBlanc avatar Apr 30 '25 01:04 mattiLeBlanc

@mattiLeBlanc do you have network records like hui has asked? it is difficult to say where exactly this is all happening without having more info. Since the API works as expected at our end and we dont have control over how the OS makes the request calls with delay, I would suggest you catch the error and make a retry. Let us know once you try that and if there are further issues here.

ashika112 avatar May 01 '25 17:05 ashika112

Any updates here?

ashika112 avatar May 06 '25 05:05 ashika112

@ashika112 I do not have any records. I only have the screenshots I posted above, I am sorry.

mattiLeBlanc avatar May 06 '25 05:05 mattiLeBlanc

@mattiLeBlanc Got it. We are going to close this issue since there is no way for us to re-produce the exact issue and there is a workaround suggested. If there is anything else outside of this, feel free to open new issue. If you have further issue after retry and can provide with some replication here, we will re-open this issue and work it through.

ashika112 avatar May 06 '25 19:05 ashika112

@ashika112 I have a never version of this issue:

I am getting the error:

Graphql Error running query onResidentUpdate. Authmode Cognito. Error: Connection failed: UnauthorizedException Error: Graphql Error running query onResidentUpdate. Authmode Cognito. Error: Connection failed: UnauthorizedException 

which is a Grapqlh subscription.

In my angular App I already run this in my main.ts:


Amplify.configure(awsConfig.clientApp, {
  API: {
    GraphQL: {
      headers: async () => {
        try {
          let currentSession = await fetchAuthSession();
          const tokenExpiry = currentSession.tokens.accessToken.payload.exp * 1000;
          const HALF_MINUTE = 30000;
          const now = new Date().getTime() + HALF_MINUTE;
          if (tokenExpiry <= now) {
            currentSession = await fetchAuthSession({forceRefresh: true});
            console.log('============================= fetched a new token===================');
          }
          if (currentSession.tokens) {
            const idToken = currentSession.tokens.idToken?.toString();
            return { Authorization: idToken };
          } else {
            signOut();
            return undefined
          }
        } catch (error) {
          // signOut();
          return undefined;
        }
      }
    }
  }
});

which should force refresh the token if is within 30 sec of expiry. I see this solution work well in my local env with my development service, the console log "fetched new token" is triggered often during development refreshes (ng serve) and no issues.

But on our production environment, we have live customers grabbing their tables out of storage, turning it on and expecting to be still logged into the app.

The error that was reported was accompanied by the IdToken which expired 40min before the error happened, AND the report also included a valid refreshToken. So the amplify client SHOULD be able to reissue a new access and id token, right? Still, it didnt.

Now, I am querying ChatGPT about this too and it made an interesting observation:

---------begin chatgpt-------------

✅ 1. fetchAuthSession returned a fresh token, but API.graphql still used an old one Why?

The headers() function is async, but Amplify may cache or ignore it during certain API.graphql flows — especially subscriptions

In @aws-amplify/api-graphql, subscription initialisation may capture the auth headers once and not re-evaluate them, even if expired

Evidence: The error occurred during a GraphQL subscription setup (onResidentUpdate), which uses a WebSocket connection — not regular HTTP.

🔥 WebSocket-based GraphQL subscriptions in Amplify use a separate authorization handshake — and may not honour late changes to the headers() function.

✅ 2. Amplify's internal session state was stale at the WebSocket layer Even though fetchAuthSession({ forceRefresh: true }) works correctly, the WebSocket connection may have:

Been created earlier

Used an expired token at the time of connection

Not reconnected correctly when forceRefresh occurred

------------end chatgpt-----------

Are websockets treated differently and should have add an check in my subscription call to see if the token has expired and force refresh it within my RXJS Pipe?

Full API service code:


export class ApiService {

  private client = generateClient();

  subscription<T>({ statement: query, variables, type }: subscriptionIput): Observable<T> {
    const payload: any = {
      query,
      authMode: 'userPool',
    }
    if (variables) {
      payload.variables = variables;
    }
    return from(this.client.graphql(payload) as Promise<GraphQLResult<object>>)
      .pipe(
        map(res => res.data[type as keyof object]),
        catchError((error: GraphQLResult<object> | Error) => {
          // graphql error
          if ('errors' in error) {
            const gqError = error.errors![0];
            throw new Error(`Graphql Error running query ${type}. Authmode Cognito. Error: ${gqError?.message} ${gqError?.path ? gqError.path.toString() : ''}`);
            // normal error
          } else if ('stack' in error) {
            throw new Error(`Runtime error running query ${type}. Authmode Cognito. Error: ${error.stack}}`);
            // fallback for something else
          } else {
            throw new Error(`Unknown Error running query ${type}. Authmode Cognito. Error: ${error}}`);
          }
        }),
      )
  }


  graphql<V>({ statement: query, variables, type, iam = false, familyApi = false }: queryInput) {
    if (familyApi) {
      Amplify.configure(awsConfig.familyApp);
    } else {
      Amplify.configure(awsConfig.clientApp);
    }

    const payload: any = {
      query,
      authMode: iam ? 'iam' : 'userPool',
    }
    if (variables) {
      payload.variables = variables;
    }
    // return from(fetchAuthSession())
    // .pipe(
    //   switchMap(() => {
        return from(this.client.graphql(payload) as Promise<GraphQLResult<object>>)
        .pipe(
          map(res => res.data[type as keyof object]),
          catchError((error: GraphQLResult<object> | Error) => {
            // graphql error
            if ('errors' in error) {
              const gqError = error.errors![0];
              throw new Error(`Graphql Error running query ${type}. Authmode ${iam ? 'IAM' : 'Cognito'}. Error: ${gqError?.message} ${gqError?.path ? gqError.path.toString() : ''}`);
              // normal error
            } else if ('stack' in error) {
              throw new Error(`Runtime error running query ${type}. Authmode ${iam ? 'IAM' : 'Cognito'}. Error: ${error.stack}}`);
              // fallback for something else
            } else {
              throw new Error(`Unknown Error running query ${type}. Authmode ${iam ? 'IAM' : 'Cognito'}. Error: ${error}}`);
            }
          }),
        ) as Observable<V>
      // })
    // )

  }
}

These errors are quite problematic and I can't seem to reproduce them myself, but they happen a couple of times a day with multiple customers and I am afraid it is giving us a bad reputation. Any suggestions?

mattiLeBlanc avatar May 08 '25 23:05 mattiLeBlanc

I am trying a new solution with a defer and a retry which will call fetchAuthSession multiple times with exponential backoff.

However, is their an internal problem with Subsrcription token renewal since the token is used during the initial connect and if browser goes to sleep and wakes up again, it may not reconnect with a new fresh token?

mattiLeBlanc avatar May 08 '25 23:05 mattiLeBlanc

@mattiLeBlanc we have verified in web app this already. And we dont see an issue in that part at all.

ashika112 avatar May 13 '25 18:05 ashika112

@ashika112 Well it is happening daily with multiple users that use Android 10 tablets. I am recording the errors triggered in their UI. The subscription call was triggering these unauthorised errors on a daily basis whilst having a valid refreshtoken and an expired accessToken. I can't reproduce it on my side which is frustrating so I have no clue how they can trigger this error in the subscription call that is setup on page load.

However, I have to see how well the retry mechanism will work.

It would be nice if you can streamline the error message though:

From getMusicStream:

From graphlq query I get Error: UnauthorizedError: GraphQL Error ... Error: Unauthorized From graohql subscribtion I get Error: Connection failed: UnauthorizedException

So you use UnauthorizedError abd and UnauthorizedException. This could be the same error perhaps?

mattiLeBlanc avatar May 14 '25 00:05 mattiLeBlanc

Re-opening this to track the conversation. Will take a look at this soon and update.

ashika112 avatar May 14 '25 18:05 ashika112

Hi @mattiLeBlanc

  • Could you provide the entire source code to understand how you're performing the retry and also understand the graphQL implementations
  • Also you mentioned users in Android 10 facing this issue, is there a possibility it's specific to this particular subset of users

ashwinkumar6 avatar May 20 '25 19:05 ashwinkumar6

Hi @mattiLeBlanc , could you provide the information requested by @ashwinkumar6 to help us continue the investigation?

Simone319 avatar Jun 13 '25 15:06 Simone319

Hi @mattiLeBlanc, I will close this ticket due to inactivity. Please, feel free to reopen the ticket if you are still facing any issues.

adrianjoshua-strutt avatar Jun 24 '25 14:06 adrianjoshua-strutt

@ashwinkumar6 Hi sorry for not replying, the message got of my radar. the retry mechanism is as follows:

Firstly I have this graphql function in my api.service

graphqlWithRetry<T>({ statement: query, variables, type, iam = false, familyApi = false }: queryInput) {
    const authMode = iam ? 'iam' : 'userPool';
    const client = familyApi
    ? (this.familyClient ??= generateClient<never, any>({
        endpoint: awsConfig.familyApp.API.GraphQL.endpoint,
        authMode
      }))
    : this.client;

    const payload: any = {
      query,
      authMode
    }
    if (variables) {
      payload.variables = variables;
    }

    return defer(() => {
      return from(client.graphql(payload) as Promise<GraphQLResult<object>>).pipe(
        map(res => res.data[type as keyof object] as T),
        withGraphqlErrors<T>(type, authMode),
        withAuthRetry<T>(),
        forwardErrorToGlobalHandler<T>()
      );
    });
  }

and these are the helper functions:

import { Observable, throwError, timer, from, OperatorFunction } from 'rxjs';
import { catchError, retry, switchMap } from 'rxjs/operators';
import { fetchAuthSession } from 'aws-amplify/auth';

export class AuthRetryError extends Error {
  constructor(public original: any) {
    super('UnauthorizedException');
    this.name = 'AuthRetryError';
  }
}

function isUnauthorizedError(error: any): boolean {
  const errorString = JSON.stringify(error).toLowerCase();

  return (
    errorString.includes('unauthorizedexception') ||
    errorString.includes('unauthorizederror') ||
    errorString.includes('"message":"unauthorized"') ||
    error.message?.toLowerCase().includes('unauthorized') ||
    errorString.includes('connection failed')
  );
}

/**
 * Operator that retries on Cognito Unauthorized errors using RxJS 8+ `retry` syntax.
 * Generated by ChatGPT at 9/5/2025 based on my instructions.
 */
export function withAuthRetry<T>(maxRetries = 3, baseDelayMs = 300): OperatorFunction<T, T> {
  return source$ => source$.pipe(
    catchError((error: any) => {
      return isUnauthorizedError(error)
        ? throwError(() => new AuthRetryError(error))
        : throwError(() => error);
    }),
    retry({
      count: maxRetries,
      delay: (error, retryCount) => {
        if (!(error instanceof AuthRetryError)) throw error;
        const delayMs = 2 ** retryCount * baseDelayMs;
        console.warn(`Retrying due to UnauthorizedException, delay ${delayMs}ms`);
        return from(fetchAuthSession({ forceRefresh: true })).pipe(
          // Wait for token refresh, then delay
          switchMap(() => timer(delayMs))
        );
      }
    })
  );
}

export function forwardErrorToGlobalHandler<T>(): OperatorFunction<T, T> {
  return catchError((err: any): Observable<T> => {
    setTimeout(() => { throw err; });
    return throwError(() => err) as Observable<T>;
  });
}

export function withGraphqlErrors<T>(type: string, authmode: string): OperatorFunction<T, T> {
  return (source$: Observable<T>): Observable<T> => {
    return source$.pipe(
      catchError((error: any) => {
        if (error instanceof AuthRetryError) {
          return throwError(() => error); // Let retry handle it
        }
        // Format all other errors
        const gqlError = error?.errors?.[0];
        const message = gqlError
          ? `GraphQL Error in "${type}". Authmode: ${authmode}. Error: ${gqlError.message}`
          : error?.stack
            ? `Runtime Error in "${type}". Authmode: ${authmode}. Error: ${error.stack}`
            : `Unknown Error in "${type}". Authmode: ${authmode}. Error: ${JSON.stringify(error)}`;
        return throwError(() => new Error(message));
      }),
    );
  };
}

It is hard to validate how well it works, since I cant get my api to simulate a session issue. ChatGPT helped with creating the retry setup, but I have seen that AI make errors and we have been through a couple of iterations.

Besides that, i swap out the accesstoken for the idtoken and do an expiry check there too (legacy solution but I haven't removed it yet)


Amplify.configure(awsConfig.clientApp, {
  API: {
    GraphQL: {
      headers: async () => {
        try {
          let currentSession = await fetchAuthSession();
          const tokenExpiry = currentSession.tokens.accessToken.payload.exp * 1000;
          const HALF_MINUTE = 30000;
          const now = new Date().getTime() + HALF_MINUTE;
          if (tokenExpiry <= now) {
            currentSession = await fetchAuthSession({forceRefresh: true});
            console.log('============================= fetched a new token===================');
          }
          if (currentSession.tokens) {
            const idToken = currentSession.tokens.idToken?.toString();
            return { Authorization: idToken };
          } else {
            signOut();
            return undefined
          }
        } catch (error) {
          // signOut();
          return undefined;
        }
      }
    }
  }
});

mattiLeBlanc avatar Jun 25 '25 09:06 mattiLeBlanc