{"record":{"id":"0002836de8351a5f","repo":"paperclipai/paperclip","slug":"credentials-photon-rejected-the-selected-line-reconnect-the","errorCode":"credentials","errorMessage":"Photon rejected the selected line credentials; reconnect the channel","messagePattern":"Photon rejected the selected line credentials; reconnect the channel","errorType":"error_code","errorClass":"PhotonError","httpStatus":null,"severity":"error","filePath":"server/src/services/photon/recovery-transport.ts","lineNumber":158,"sourceCode":"      controller.signal.addEventListener(\"abort\", abort, { once: true });\n      try {\n        for await (const bytes of call) {\n          const frame = decodePhotonRecoveryFrame(bytes as Buffer);\n          if (frame) yield frame;\n        }\n      } catch (error) {\n        if (controller.signal.aborted) return;\n        const code = (error as { code?: number }).code;\n        if (code === status.OUT_OF_RANGE || code === status.FAILED_PRECONDITION)\n          throw new PhotonError(\n            \"history_gap\",\n            \"Photon cannot recover the saved cursor; reconnect after reviewing the history gap\",\n          );\n        if (\n          code === status.UNAUTHENTICATED ||\n          code === status.PERMISSION_DENIED\n        )\n          throw new PhotonError(\n            \"credentials\",\n            \"Photon rejected the selected line credentials; reconnect the channel\",\n          );\n        if (code === status.RESOURCE_EXHAUSTED)\n          throw new PhotonError(\n            \"quota\",\n            \"Photon recovery is temporarily rate limited\",\n          );\n        throw photonFailure(error);\n      } finally {\n        controller.signal.removeEventListener(\"abort\", abort);\n        call.cancel();\n      }\n    }\n    return new TypedEventStream(receive(), async () => controller.abort());\n  }\n  close(): void {\n    this.client.close();","sourceCodeStart":140,"sourceCodeEnd":176,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/photon/recovery-transport.ts#L140-L176","documentation":"PhotonRecoveryTransport.catchUp() opens a gRPC server-stream (the catchup path) authenticated with a Bearer line token. When the server ends the stream with UNAUTHENTICATED or PERMISSION_DENIED, the transport maps it to a PhotonError with code 'credentials'. It signals that the token or line identity is no longer accepted and the channel must be re-established with fresh credentials rather than retried in place.","triggerScenarios":"Calling catchUp() (directly or via the recovery path) when: the Photon line token is expired or rotated between token fetch and stream start; the token lacks permission for the company/line scope of the cursor; the server revoked the line credentials mid-stream; or the authorization metadata carries a stale/malformed token.","commonSituations":"Long-running processes holding tokens past their TTL; token rotation on the Photon side while an instance reconnects; connecting a line to a different Photon tenant than the one that issued the token; misconfigured PHOTON address pointing at an environment with different auth (staging vs prod).","solutions":["Refresh the line credentials via authentication.token() (or re-run the line auth flow) and construct a new PhotonRecoveryTransport, since the error message instructs reconnecting the channel","Verify the token's audience/permissions cover the company and line scope of the saved cursor","Confirm the Photon address points at the environment that issued the token (no staging/prod mismatch)","If the token was rotated externally, redistribute the new secret to this instance's configuration"],"exampleFix":"// before: reuse a long-lived transport with a stale token\nconst stream = transport.catchUp(lastSequence);\n// after: rebuild credentials and transport on 'credentials' errors\ntry {\n  return transport.catchUp(lastSequence);\n} catch (e) {\n  if (e instanceof PhotonError && e.code === \"credentials\") {\n    transport.close();\n    await authentication.refresh();\n    transport = new PhotonRecoveryTransport(authentication);\n    return transport.catchUp(lastSequence);\n  }\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"// check token presence and shape before opening the stream\nif (!authentication?.address || typeof authentication.token !== \"function\")\n  throw new Error(\"Photon line authentication is not configured\");\nconst token = await authentication.token();\nif (!token || token.split(\".\").length !== 2) console.warn(\"Photon line token looks malformed\");","typeGuard":"function isPhotonCredentialsError(e: unknown): e is PhotonError {\n  return e instanceof PhotonError && e.code === \"credentials\";\n}","tryCatchPattern":"try {\n  stream = transport.catchUp(lastSequence);\n} catch (e) {\n  if (isPhotonCredentialsError(e)) {\n    transport.close();\n    await authentication.refresh(); // re-auth, do NOT blind-retry\n    transport = new PhotonRecoveryTransport(authentication);\n    stream = transport.catchUp(lastSequence);\n  } else throw e;\n}","preventionTips":["Refresh line tokens proactively before their TTL expires instead of reusing cached ones across reconnects","Pin each line to the Photon environment that issued its token (avoid staging/prod address mixups)","Monitor for recurring 'credentials' errors — they usually indicate rotation or permission drift, not transient faults","Always tear down the old channel (transport.close()) before rebuilding with new credentials"],"tags":["grpc","authentication","recovery","streaming"],"backgroundTag":"authentication-required","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-22T11:17:16.035Z"}