Przeglądaj źródła

Merge pull request #13463 from overleaf/jpa-bootstrap-ws

[misc] automatically call joinProject as part of connecting to real-time

GitOrigin-RevId: 2466168e9cebb62dec07481273050efcd0478114
Jakob Ackermann 3 lat temu
rodzic
commit
55c5330108

+ 33 - 4
services/real-time/app/js/Router.js

@@ -119,6 +119,8 @@ module.exports = Router = {
     )
     )
 
 
     session.on('connection', function (error, client, session) {
     session.on('connection', function (error, client, session) {
+      const joinProjectAutomatically = !!client.handshake.query.projectId
+
       // init client context, we may access it in Router._handleError before
       // init client context, we may access it in Router._handleError before
       //  setting any values
       //  setting any values
       client.ol_context = {}
       client.ol_context = {}
@@ -172,13 +174,18 @@ module.exports = Router = {
 
 
       // send positive confirmation that the client has a valid connection
       // send positive confirmation that the client has a valid connection
       client.publicId = 'P.' + base64id.generateId()
       client.publicId = 'P.' + base64id.generateId()
-      client.emit('connectionAccepted', null, client.publicId)
+      if (!joinProjectAutomatically) {
+        client.emit('connectionAccepted', null, client.publicId)
+      }
 
 
       client.remoteIp = websocketAddressManager.getRemoteIp(client.handshake)
       client.remoteIp = websocketAddressManager.getRemoteIp(client.handshake)
       const headers = client.handshake && client.handshake.headers
       const headers = client.handshake && client.handshake.headers
       client.userAgent = headers && headers['user-agent']
       client.userAgent = headers && headers['user-agent']
 
 
-      metrics.inc('socket-io.connection', 1, { status: client.transport })
+      metrics.inc('socket-io.connection', 1, {
+        status: client.transport,
+        method: joinProjectAutomatically ? 'auto-join-project' : undefined,
+      })
       metrics.gauge('socket-io.clients', io.sockets.clients().length)
       metrics.gauge('socket-io.clients', io.sockets.clients().length)
 
 
       logger.debug({ session, clientId: client.id }, 'client connected')
       logger.debug({ session, clientId: client.id }, 'client connected')
@@ -205,7 +212,7 @@ module.exports = Router = {
         })
         })
       }
       }
 
 
-      client.on('joinProject', function (data, callback) {
+      const joinProject = function (data, callback) {
         data = data || {}
         data = data || {}
         if (typeof callback !== 'function') {
         if (typeof callback !== 'function') {
           return Router._handleInvalidArguments(
           return Router._handleInvalidArguments(
@@ -260,7 +267,8 @@ module.exports = Router = {
             }
             }
           }
           }
         )
         )
-      })
+      }
+      client.on('joinProject', joinProject)
 
 
       client.on('disconnect', function () {
       client.on('disconnect', function () {
         metrics.inc('socket-io.disconnect', 1, { status: client.transport })
         metrics.inc('socket-io.disconnect', 1, { status: client.transport })
@@ -455,6 +463,27 @@ module.exports = Router = {
           }
           }
         )
         )
       })
       })
+
+      if (joinProjectAutomatically) {
+        const { projectId } = client.handshake.query
+        const anonymousAccessToken = session?.anonTokenAccess?.[projectId]
+        joinProject(
+          { project_id: projectId, anonymousAccessToken },
+          (err, project, permissionsLevel, protocolVersion) => {
+            if (err) {
+              client.emit('connectionRejected', err)
+              client.disconnect()
+              return
+            }
+            client.emit('joinProjectResponse', {
+              publicId: client.publicId,
+              project,
+              permissionsLevel,
+              protocolVersion,
+            })
+          }
+        )
+      }
     })
     })
   },
   },
 }
 }

+ 557 - 1
services/real-time/test/acceptance/js/JoinProjectTests.js

@@ -519,7 +519,7 @@ describe('joinProject', function () {
     })
     })
   })
   })
 
 
-  return describe('when over rate limit', function () {
+  describe('when over rate limit', function () {
     before(function (done) {
     before(function (done) {
       return async.series(
       return async.series(
         [
         [
@@ -548,4 +548,560 @@ describe('joinProject', function () {
       return this.error.code.should.equal('TooManyRequests')
       return this.error.code.should.equal('TooManyRequests')
     })
     })
   })
   })
+
+  describe('when automatically joining the project', function () {
+    describe('when authorized', function () {
+      let connectionAcceptedReceived = false
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  privilegeLevel: 'owner',
+                  project: {
+                    name: 'Test Project',
+                  },
+                },
+                (e, { project_id: projectId, user_id: userId }) => {
+                  this.project_id = projectId
+                  this.user_id = userId
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              this.client = RealTimeClient.connect(
+                `projectId=${this.project_id}`
+              )
+              this.client.on('connectionAccepted', () => {
+                connectionAcceptedReceived = true
+              })
+              this.client.on('connectionRejected', cb)
+              this.client.on(
+                'joinProjectResponse',
+                ({ project, permissionsLevel, protocolVersion }) => {
+                  this.project = project
+                  this.permissionsLevel = permissionsLevel
+                  this.protocolVersion = protocolVersion
+                  cb()
+                }
+              )
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit connectionAccepted', function () {
+        expect(connectionAcceptedReceived).to.equal(false)
+      })
+
+      it('should get the project from web', function () {
+        MockWebServer.joinProject
+          .calledWith(this.project_id, this.user_id)
+          .should.equal(true)
+      })
+
+      it('should return the project', function () {
+        this.project.should.deep.equal({
+          name: 'Test Project',
+          owner: { _id: this.user_id },
+        })
+      })
+
+      it('should return the privilege level', function () {
+        this.permissionsLevel.should.equal('owner')
+      })
+
+      it('should return the protocolVersion', function () {
+        this.protocolVersion.should.equal(2)
+      })
+
+      it('should have joined the project room', function (done) {
+        RealTimeClient.getConnectedClient(
+          this.client.socket.sessionid,
+          (error, client) => {
+            if (error) return done(error)
+            expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
+              true
+            )
+            done()
+          }
+        )
+      })
+
+      it('should have marked the user as connected', function (done) {
+        this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
+          if (error) return done(error)
+          let connected = false
+          for (const user of Array.from(users)) {
+            if (
+              user.client_id === this.client.publicId &&
+              user.user_id === this.user_id
+            ) {
+              connected = true
+              break
+            }
+          }
+          expect(connected).to.equal(true)
+          done()
+        })
+      })
+    })
+
+    describe('when authorized with token', function () {
+      let connectionAcceptedReceived = false
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  privilegeLevel: 'owner',
+                  publicAccess: 'readOnly',
+                  project: {
+                    name: 'Test Project',
+                  },
+                },
+                (
+                  e,
+                  {
+                    user_id: ownerId,
+                    project_id: projectId,
+                    anonymousAccessToken,
+                  }
+                ) => {
+                  this.ownerId = ownerId
+                  this.project_id = projectId
+                  this.anonymousAccessToken = anonymousAccessToken
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              RealTimeClient.setSession(
+                {
+                  anonTokenAccess: {
+                    [this.project_id]: this.anonymousAccessToken,
+                  },
+                },
+                cb
+              )
+            },
+
+            cb => {
+              this.client = RealTimeClient.connect(
+                `projectId=${this.project_id}`
+              )
+              this.client.on('connectionAccepted', () => {
+                connectionAcceptedReceived = true
+              })
+              this.client.on('connectionRejected', cb)
+              this.client.on(
+                'joinProjectResponse',
+                ({ project, permissionsLevel, protocolVersion }) => {
+                  this.project = project
+                  this.permissionsLevel = permissionsLevel
+                  this.protocolVersion = protocolVersion
+                  cb()
+                }
+              )
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit connectionAccepted', function () {
+        expect(connectionAcceptedReceived).to.equal(false)
+      })
+
+      it('should get the project from web', function () {
+        MockWebServer.joinProject
+          .calledWith(
+            this.project_id,
+            'anonymous-user',
+            this.anonymousAccessToken
+          )
+          .should.equal(true)
+      })
+
+      it('should return the project', function () {
+        this.project.should.deep.equal({
+          name: 'Test Project',
+          owner: { _id: this.ownerId },
+        })
+      })
+
+      it('should return the privilege level', function () {
+        this.permissionsLevel.should.equal('readOnly')
+      })
+
+      it('should return the protocolVersion', function () {
+        this.protocolVersion.should.equal(2)
+      })
+
+      it('should have joined the project room', function (done) {
+        RealTimeClient.getConnectedClient(
+          this.client.socket.sessionid,
+          (error, client) => {
+            if (error) return done(error)
+            expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
+              true
+            )
+            done()
+          }
+        )
+      })
+
+      it('should have marked the user as connected', function (done) {
+        this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
+          if (error) return done(error)
+          let connected = false
+          for (const user of Array.from(users)) {
+            if (user.client_id === this.client.publicId) {
+              connected = true
+              break
+            }
+          }
+          expect(connected).to.equal(true)
+          done()
+        })
+      })
+    })
+
+    describe('when not authorized', function () {
+      let joinProjectResponseReceived = false
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  privilegeLevel: null,
+                  project: {
+                    name: 'Test Project',
+                  },
+                },
+                (e, { project_id: projectId, user_id: userId }) => {
+                  this.project_id = projectId
+                  this.user_id = userId
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              this.client = RealTimeClient.connect(
+                `projectId=${this.project_id}`
+              )
+              this.client.on('connectionRejected', err => {
+                this.error = err
+                cb()
+              })
+              this.client.on('joinProjectResponse', () => {
+                joinProjectResponseReceived = true
+                cb()
+              })
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit joinProjectResponse', function () {
+        expect(joinProjectResponseReceived).to.equal(false)
+      })
+
+      it('should have disconnected the client', function () {
+        expect(this.client.socket.connected).to.equal(false)
+      })
+
+      it('should return an error', function () {
+        this.error.message.should.equal('not authorized')
+      })
+
+      it('should not have joined the project room', function (done) {
+        RealTimeClient.getConnectedClient(
+          this.client.socket.sessionid,
+          error => {
+            expect(error.message).to.equal('not found')
+            done()
+          }
+        )
+      })
+    })
+
+    describe('when not authorized and web replies with a 403', function () {
+      let joinProjectResponseReceived = false
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  project_id: '403403403403403403403403', // forbidden
+                  privilegeLevel: 'owner',
+                  project: {
+                    name: 'Test Project',
+                  },
+                },
+                (e, { project_id: projectId, user_id: userId }) => {
+                  this.project_id = projectId
+                  this.user_id = userId
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              this.client = RealTimeClient.connect(
+                `projectId=${this.project_id}`
+              )
+              this.client.on('connectionRejected', err => {
+                this.error = err
+                cb()
+              })
+              this.client.on('joinProjectResponse', () => {
+                joinProjectResponseReceived = true
+                cb()
+              })
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit joinProjectResponse', function () {
+        expect(joinProjectResponseReceived).to.equal(false)
+      })
+
+      it('should have disconnected the client', function () {
+        expect(this.client.socket.connected).to.equal(false)
+      })
+
+      it('should return an error', function () {
+        this.error.message.should.equal('not authorized')
+      })
+
+      it('should not have joined the project room', function (done) {
+        RealTimeClient.getConnectedClient(
+          this.client.socket.sessionid,
+          error => {
+            expect(error.message).to.equal('not found')
+            done()
+          }
+        )
+      })
+    })
+
+    describe('when deleted and web replies with a 404', function () {
+      let joinProjectResponseReceived = false
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  project_id: '404404404404404404404404', // not-found
+                  privilegeLevel: 'owner',
+                  project: {
+                    name: 'Test Project',
+                  },
+                },
+                (e, { project_id: projectId, user_id: userId }) => {
+                  this.project_id = projectId
+                  this.user_id = userId
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              this.client = RealTimeClient.connect(
+                `projectId=${this.project_id}`
+              )
+              this.client.on('connectionRejected', err => {
+                this.error = err
+                cb()
+              })
+              this.client.on('joinProjectResponse', () => {
+                joinProjectResponseReceived = true
+                cb()
+              })
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit joinProjectResponse', function () {
+        expect(joinProjectResponseReceived).to.equal(false)
+      })
+
+      it('should have disconnected the client', function () {
+        expect(this.client.socket.connected).to.equal(false)
+      })
+
+      it('should return an error', function () {
+        this.error.code.should.equal('ProjectNotFound')
+      })
+
+      it('should not have joined the project room', function (done) {
+        RealTimeClient.getConnectedClient(
+          this.client.socket.sessionid,
+          error => {
+            expect(error.message).to.equal('not found')
+            done()
+          }
+        )
+      })
+    })
+
+    describe('when invalid', function () {
+      let joinProjectResponseReceived = false
+      before(function (done) {
+        MockWebServer.joinProject.resetHistory()
+        async.series(
+          [
+            cb => {
+              this.client = RealTimeClient.connect('projectId=invalid-id')
+              this.client.on('connectionRejected', err => {
+                this.error = err
+                cb()
+              })
+              this.client.on('joinProjectResponse', () => {
+                joinProjectResponseReceived = true
+                cb()
+              })
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit joinProjectResponse', function () {
+        expect(joinProjectResponseReceived).to.equal(false)
+      })
+
+      it('should have disconnected the client', function () {
+        expect(this.client.socket.connected).to.equal(false)
+      })
+
+      it('should return an invalid id error', function () {
+        this.error.message.should.equal('invalid id')
+      })
+
+      it('should not call to web', function () {
+        MockWebServer.joinProject.called.should.equal(false)
+      })
+    })
+
+    describe('when joining more than one project', function () {
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  privilegeLevel: 'owner',
+                  project: {
+                    name: 'Other Project',
+                  },
+                },
+                (e, { project_id: projectId, user_id: userId }) => {
+                  this.other_project_id = projectId
+                  this.other_user_id = userId
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              FixturesManager.setUpProject(
+                {
+                  user_id: this.other_user_id,
+                  privilegeLevel: 'owner',
+                  project: {
+                    name: 'Test Project',
+                  },
+                },
+                (e, { project_id: projectId, user_id: userId }) => {
+                  this.project_id = projectId
+                  this.user_id = userId
+                  cb(e)
+                }
+              )
+            },
+
+            cb => {
+              this.client = RealTimeClient.connect(
+                `projectId=${this.project_id}`
+              )
+              this.client.on('connectionRejected', cb)
+              this.client.on('joinProjectResponse', () => {
+                cb()
+              })
+            },
+
+            cb => {
+              this.client.emit(
+                'joinProject',
+                { project_id: this.other_project_id },
+                error => {
+                  this.error = error
+                  cb()
+                }
+              )
+            },
+          ],
+          done
+        )
+      })
+
+      it('should return an error', function () {
+        this.error.message.should.equal('cannot join multiple projects')
+      })
+    })
+
+    describe('when over rate limit', function () {
+      let joinProjectResponseReceived = false
+      before(function (done) {
+        async.series(
+          [
+            cb => {
+              this.client = RealTimeClient.connect(
+                'projectId=429429429429429429429429'
+              )
+              this.client.on('connectionRejected', err => {
+                this.error = err
+                cb()
+              })
+              this.client.on('joinProjectResponse', () => {
+                joinProjectResponseReceived = true
+                cb()
+              })
+            },
+          ],
+          done
+        )
+      })
+
+      it('should not emit joinProjectResponse', function () {
+        expect(joinProjectResponseReceived).to.equal(false)
+      })
+
+      it('should have disconnected the client', function () {
+        expect(this.client.socket.connected).to.equal(false)
+      })
+
+      it('should return a TooManyRequests error code', function () {
+        this.error.message.should.equal('rate-limit hit when joining project')
+        this.error.code.should.equal('TooManyRequests')
+      })
+    })
+  })
 })
 })

+ 13 - 2
services/real-time/test/acceptance/js/helpers/RealTimeClient.js

@@ -62,14 +62,19 @@ module.exports = Client = {
     return callback()
     return callback()
   },
   },
 
 
-  connect(cookie) {
+  connect(query) {
     const client = io.connect('http://localhost:3026', {
     const client = io.connect('http://localhost:3026', {
       'force new connection': true,
       'force new connection': true,
+      query,
     })
     })
     client.on(
     client.on(
       'connectionAccepted',
       'connectionAccepted',
       (_, publicId) => (client.publicId = publicId)
       (_, publicId) => (client.publicId = publicId)
     )
     )
+    client.on(
+      'joinProjectResponse',
+      ({ publicId }) => (client.publicId = publicId)
+    )
     return client
     return client
   },
   },
 
 
@@ -95,7 +100,13 @@ module.exports = Client = {
         url: `http://localhost:3026/clients/${clientId}`,
         url: `http://localhost:3026/clients/${clientId}`,
         json: true,
         json: true,
       },
       },
-      (error, response, data) => callback(error, data)
+      (error, response, data) => {
+        if (response?.statusCode === 404) {
+          callback(new Error('not found'))
+        } else {
+          callback(error, data)
+        }
+      }
     )
     )
   },
   },
 
 

+ 114 - 55
services/web/frontend/js/ide/connection/ConnectionManager.js

@@ -11,6 +11,7 @@
 /* global io */
 /* global io */
 
 
 import SocketIoShim from './SocketIoShim'
 import SocketIoShim from './SocketIoShim'
+import getMeta from '../../utils/meta'
 
 
 let ConnectionManager
 let ConnectionManager
 const ONEHOUR = 1000 * 60 * 60
 const ONEHOUR = 1000 * 60 * 60
@@ -130,11 +131,15 @@ export default ConnectionManager = (function () {
           pathname: this.wsUrl || '/socket.io',
           pathname: this.wsUrl || '/socket.io',
         }
         }
       }
       }
+      const query = new URLSearchParams({
+        projectId: getMeta('ol-project_id'),
+      }).toString()
       this.ide.socket = SocketIoShim.connect(parsedURL.origin, {
       this.ide.socket = SocketIoShim.connect(parsedURL.origin, {
         resource: parsedURL.pathname.slice(1),
         resource: parsedURL.pathname.slice(1),
         reconnect: false,
         reconnect: false,
         'connect timeout': 30 * 1000,
         'connect timeout': 30 * 1000,
         'force new connection': true,
         'force new connection': true,
+        query,
       })
       })
 
 
       // handle network-level websocket errors (e.g. failed dns lookups)
       // handle network-level websocket errors (e.g. failed dns lookups)
@@ -176,9 +181,10 @@ export default ConnectionManager = (function () {
       })
       })
 
 
       // The next event we should get is an authentication response
       // The next event we should get is an authentication response
-      // from the server, either "connectionAccepted" or
+      // from the server, either "connectionAccepted" or "bootstrap" or
       // "connectionRejected".
       // "connectionRejected".
 
 
+      // Handle real-time without bootstrap capability.
       this.ide.socket.on('connectionAccepted', (_, publicId) => {
       this.ide.socket.on('connectionAccepted', (_, publicId) => {
         this.ide.socket.publicId = publicId || this.ide.socket.socket.sessionid
         this.ide.socket.publicId = publicId || this.ide.socket.socket.sessionid
         // state should be 'authenticating'...
         // state should be 'authenticating'...
@@ -201,15 +207,54 @@ export default ConnectionManager = (function () {
         }, 100)
         }, 100)
       })
       })
 
 
+      this.ide.socket.on(
+        'joinProjectResponse',
+        ({ publicId, project, permissionsLevel, protocolVersion }) => {
+          this.ide.socket.publicId = publicId
+          sl_console.log('[socket.io bootstrap] ready for joinDoc')
+          this.connected = true
+          this.gracefullyReconnecting = false
+          this.ide.pushEvent('connected')
+          this.ide.pushEvent('joinProjectResponse')
+          this.updateConnectionManagerState('joining')
+
+          this.$scope.$apply(() => {
+            if (this.$scope.state.loading) {
+              this.$scope.state.load_progress = 70
+            }
+          })
+
+          const connectionJobId = this.$scope.connection.jobId
+          this.handleJoinProjectResponse({
+            connectionJobId,
+            project,
+            permissionsLevel,
+            protocolVersion,
+          })
+        }
+      )
+
       this.ide.socket.on('connectionRejected', err => {
       this.ide.socket.on('connectionRejected', err => {
         // state should be 'authenticating'...
         // state should be 'authenticating'...
         sl_console.log(
         sl_console.log(
           '[socket.io connectionRejected] session not valid or other connection error'
           '[socket.io connectionRejected] session not valid or other connection error'
         )
         )
-        // real time sends a 'retry' message if the process was shutting down
-        if (err && err.message === 'retry') {
+        // real-time sends a 'retry' message if the process was shutting down
+        // real-time sends TooManyRequests if joinProject was rate-limited.
+        if (err?.message === 'retry' || err?.code === 'TooManyRequests') {
           return this.tryReconnectWithRateLimit()
           return this.tryReconnectWithRateLimit()
         }
         }
+        if (err?.code === 'ProjectNotFound') {
+          // A stale browser tab tried to join a deleted project.
+          // Reloading the page will render a 404.
+          this.ide
+            .showGenericMessageModal(
+              'Project has been deleted',
+              'This project has been deleted by the owner.'
+            )
+            .result.then(() => location.reload(true))
+          return
+        }
         // we have failed authentication, usually due to an invalid session cookie
         // we have failed authentication, usually due to an invalid session cookie
         return this.reportConnectionError(err)
         return this.reportConnectionError(err)
       })
       })
@@ -396,63 +441,77 @@ Something went wrong connecting to your project. Please refresh if this continue
         'joinProject',
         'joinProject',
         data,
         data,
         (err, project, permissionsLevel, protocolVersion) => {
         (err, project, permissionsLevel, protocolVersion) => {
-          if (err != null || project == null) {
-            err = err || {}
-            if (err.code === 'ProjectNotFound') {
-              // A stale browser tab tried to join a deleted project.
-              // Reloading the page will render a 404.
-              this.ide
-                .showGenericMessageModal(
-                  'Project has been deleted',
-                  'This project has been deleted by the owner.'
-                )
-                .result.then(() => location.reload(true))
-              return
-            }
-            if (err.code === 'TooManyRequests') {
-              sl_console.log(
-                `[joinProject ${connectionId}] retrying: ${err.message}`
-              )
-              setTimeout(
-                () => this.joinProject(connectionId),
-                this.joinProjectRetryInterval
-              )
-              if (
-                this.joinProjectRetryInterval <
-                this.JOIN_PROJECT_MAX_RETRY_INTERVAL
-              ) {
-                this.joinProjectRetryInterval +=
-                  this.JOIN_PROJECT_RETRY_INTERVAL
-              }
-              return
-            } else {
-              return this.reportConnectionError(err)
-            }
-          }
-
-          this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
+          this.handleJoinProjectResponse({
+            connectionId,
+            err,
+            project,
+            permissionsLevel,
+            protocolVersion,
+          })
+        }
+      )
+    }
 
 
+    handleJoinProjectResponse({
+      connectionId,
+      err,
+      project,
+      permissionsLevel,
+      protocolVersion,
+    }) {
+      if (err != null || project == null) {
+        err = err || {}
+        if (err.code === 'ProjectNotFound') {
+          // A stale browser tab tried to join a deleted project.
+          // Reloading the page will render a 404.
+          this.ide
+            .showGenericMessageModal(
+              'Project has been deleted',
+              'This project has been deleted by the owner.'
+            )
+            .result.then(() => location.reload(true))
+          return
+        }
+        if (err.code === 'TooManyRequests') {
+          sl_console.log(
+            `[joinProject ${connectionId}] retrying: ${err.message}`
+          )
+          setTimeout(
+            () => this.joinProject(connectionId),
+            this.joinProjectRetryInterval
+          )
           if (
           if (
-            this.$scope.protocolVersion != null &&
-            this.$scope.protocolVersion !== protocolVersion
+            this.joinProjectRetryInterval < this.JOIN_PROJECT_MAX_RETRY_INTERVAL
           ) {
           ) {
-            location.reload(true)
+            this.joinProjectRetryInterval += this.JOIN_PROJECT_RETRY_INTERVAL
           }
           }
-
-          this.$scope.$apply(() => {
-            this.updateConnectionManagerState('ready')
-            this.$scope.protocolVersion = protocolVersion
-            const defaultProjectAttributes = { rootDoc_id: null }
-            this.$scope.project = { ...defaultProjectAttributes, ...project }
-            this.$scope.permissionsLevel = permissionsLevel
-            this.ide.loadingManager.socketLoaded()
-            window.dispatchEvent(
-              new CustomEvent('project:joined', { detail: this.$scope.project })
-            )
-            this.$scope.$broadcast('project:joined')
-          })
+          return
+        } else {
+          return this.reportConnectionError(err)
         }
         }
-      )
+      }
+
+      this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
+
+      if (
+        this.$scope.protocolVersion != null &&
+        this.$scope.protocolVersion !== protocolVersion
+      ) {
+        location.reload(true)
+      }
+
+      this.$scope.$apply(() => {
+        this.updateConnectionManagerState('ready')
+        this.$scope.protocolVersion = protocolVersion
+        const defaultProjectAttributes = { rootDoc_id: null }
+        this.$scope.project = { ...defaultProjectAttributes, ...project }
+        this.$scope.permissionsLevel = permissionsLevel
+        this.ide.loadingManager.socketLoaded()
+        window.dispatchEvent(
+          new CustomEvent('project:joined', { detail: this.$scope.project })
+        )
+        this.$scope.$broadcast('project:joined')
+      })
     }
     }
 
 
     reconnectImmediately() {
     reconnectImmediately() {