@@ -151,6 +151,7 @@ import {
151151} from "./oauth-helpers" ;
152152import { connectionIdentifier } from "./connection-name-identifier" ;
153153import { annotateToolResultOutcome } from "./tool-result" ;
154+ import { isUnauthorizedToolFailure } from "./auth-tool-failure" ;
154155
155156const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90 ;
156157const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90 ;
@@ -1500,10 +1501,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
15001501 where : ( b : AnyCb ) => b . and ( byOwner ( owner ) ( b ) , b ( "slug" , "=" , slug ) ) ,
15011502 } ) ;
15021503
1504+ /** What drove a refresh: the pre-call expiry check (`proactive`), or an
1505+ * upstream 401 on a token we believed was still valid (`reactive`). */
1506+ type RefreshTrigger = "proactive" | "reactive" ;
1507+
15031508 // Perform the actual refresh-token grant and persist the rotated material.
15041509 const performTokenRefresh = (
15051510 row : ConnectionRow ,
15061511 provider : CredentialProvider ,
1512+ trigger : RefreshTrigger ,
15071513 ) : Effect . Effect < string | null , StorageFailure | CredentialResolutionError > =>
15081514 Effect . gen ( function * ( ) {
15091515 const owner = row . owner as Owner ;
@@ -1653,11 +1659,45 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
16531659 } ) ;
16541660
16551661 return token . access_token ;
1656- } ) ;
1662+ } ) . pipe (
1663+ // The refresh path was previously invisible to telemetry: no span, no
1664+ // log, no metric. When a customer reported "my OAuth just died", there
1665+ // was no way to answer "did a refresh even fire, and what did the AS
1666+ // say?" without a repro. Stamp the outcome and the AS's own error code
1667+ // (an enumerable RFC 6749 §5.2 identifier, never user content or token
1668+ // material) so refresh health is greppable in traces.
1669+ Effect . tap ( ( ) => Effect . annotateCurrentSpan ( { "executor.oauth.refresh.outcome" : "ok" } ) ) ,
1670+ Effect . tapError ( ( error : StorageFailure | CredentialResolutionError ) =>
1671+ Effect . annotateCurrentSpan ( {
1672+ "executor.oauth.refresh.outcome" : "fail" ,
1673+ "executor.oauth.refresh.error" : Predicate . isTagged ( error , "CredentialResolutionError" )
1674+ ? "CredentialResolutionError"
1675+ : "StorageFailure" ,
1676+ // Whether the AS's refusal was definitive (RFC 6749 invalid_grant →
1677+ // the refresh token itself is dead) or a transient failure the next
1678+ // invoke can retry. The split is the actionable half of the signal.
1679+ ...( Predicate . isTagged ( error , "CredentialResolutionError" )
1680+ ? { "executor.oauth.refresh.reauth_required" : error . reauthRequired === true }
1681+ : { } ) ,
1682+ } ) ,
1683+ ) ,
1684+ Effect . withSpan ( "executor.oauth.refresh" , {
1685+ attributes : {
1686+ "executor.integration" : String ( row . integration ) ,
1687+ "executor.connection" : String ( row . name ) ,
1688+ // Which path drove this refresh: the expiry check ahead of a call,
1689+ // or an upstream 401 on a token we believed was still good. The
1690+ // ratio is the health signal — a rising `reactive` share means
1691+ // tokens are dying earlier than their advertised expiry.
1692+ "executor.oauth.refresh.trigger" : trigger ,
1693+ } ,
1694+ } ) ,
1695+ ) ;
16571696
16581697 const refreshConnectionToken = (
16591698 row : ConnectionRow ,
16601699 provider : CredentialProvider ,
1700+ trigger : RefreshTrigger = "proactive" ,
16611701 ) : Effect . Effect < string | null , StorageFailure | CredentialResolutionError > =>
16621702 // Share a single refresh per connection so concurrent resolves of the same
16631703 // connection all await one refresh-token grant (the AS rotates the refresh
@@ -1666,11 +1706,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
16661706 // expiry can refresh again.
16671707 Effect . gen ( function * ( ) {
16681708 const key = connectionKey ( row ) ;
1709+ // Joining an in-flight grant is correct for BOTH triggers: whatever
1710+ // that peer mints is newer than the token this fiber just saw rejected,
1711+ // which is exactly what a reactive retry wants. The gate is cleared on
1712+ // settle, so a 401 arriving after a refresh completed starts a fresh
1713+ // grant rather than replaying the stale memoized one.
16691714 const existing = refreshInFlight . get ( key ) ;
16701715 if ( existing ) return yield * existing ;
16711716 // `Effect.cached` memoizes the grant onto a deferred: it runs once and
16721717 // replays to every awaiter sharing this entry.
1673- const memoized = yield * Effect . cached ( performTokenRefresh ( row , provider ) ) ;
1718+ const memoized = yield * Effect . cached ( performTokenRefresh ( row , provider , trigger ) ) ;
16741719 const gated = memoized . pipe (
16751720 Effect . ensuring ( Effect . sync ( ( ) => refreshInFlight . delete ( key ) ) ) ,
16761721 ) ;
@@ -1720,6 +1765,29 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17201765 ) ,
17211766 ) ;
17221767
1768+ /** Re-mint an OAuth connection's access token unconditionally, ignoring the
1769+ * stored expiry. Drives the reactive path: the upstream just rejected the
1770+ * token we sent, which is authoritative regardless of what `expires_at`
1771+ * claims (revoked server-side, an idle-timeout policy shorter than the
1772+ * advertised lifetime, or an expiry the AS never advertised at all).
1773+ *
1774+ * Returns null when the connection can't be re-minted without a human —
1775+ * not OAuth-backed, or holding no refresh token — so the caller keeps the
1776+ * upstream's own auth failure instead of inventing one. */
1777+ const forceRefreshConnectionValues = (
1778+ row : ConnectionRow ,
1779+ ) : Effect . Effect <
1780+ Record < string , string | null > | null ,
1781+ StorageFailure | CredentialResolutionError
1782+ > =>
1783+ Effect . gen ( function * ( ) {
1784+ if ( row . oauth_client == null || row . refresh_item_id == null ) return null ;
1785+ const provider = credentialProviders . get ( row . provider ) ;
1786+ if ( ! provider ) return null ;
1787+ const access = yield * refreshConnectionToken ( row , provider , "reactive" ) ;
1788+ return { [ PRIMARY_INPUT_VARIABLE ] : access } ;
1789+ } ) ;
1790+
17231791 /** The primary (`token`) value — the public seam for OAuth + single-input
17241792 * callers that only ever need one value. */
17251793 const resolveConnectionValue = (
@@ -3798,27 +3866,58 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
37983866 const values = yield * resolveConnectionValues ( connectionRow ) ;
37993867 const integrationRow = yield * findIntegrationRow ( parsed . integration ) ;
38003868 const grantedScopes = grantedScopesFromRow ( connectionRow ) ;
3801- const credential : ToolInvocationCredential = {
3802- owner : parsed . owner ,
3803- integration : parsed . integration ,
3804- connection : parsed . connection ,
3805- template : AuthTemplateSlug . make ( connectionRow . template ) ,
3806- value : values [ PRIMARY_INPUT_VARIABLE ] ?? null ,
3807- values,
3808- config : integrationRow ? decodeJsonColumn ( integrationRow . config ) : undefined ,
3809- ...( grantedScopes ? { grantedScopes } : { } ) ,
3869+ const invokeTool = runtime . plugin . invokeTool ;
3870+ const invokeWith = (
3871+ resolved : Record < string , string | null > ,
3872+ ) : Effect . Effect < unknown , ToolInvocationError > => {
3873+ const credential : ToolInvocationCredential = {
3874+ owner : parsed . owner ,
3875+ integration : parsed . integration ,
3876+ connection : parsed . connection ,
3877+ template : AuthTemplateSlug . make ( connectionRow . template ) ,
3878+ value : resolved [ PRIMARY_INPUT_VARIABLE ] ?? null ,
3879+ values : resolved ,
3880+ config : integrationRow ? decodeJsonColumn ( integrationRow . config ) : undefined ,
3881+ ...( grantedScopes ? { grantedScopes } : { } ) ,
3882+ } ;
3883+ return wrapInvocationError (
3884+ invokeTool ( {
3885+ ctx : runtime . ctx ,
3886+ toolRow : row ,
3887+ credential,
3888+ args,
3889+ elicit : buildElicit ( address , args , handler ) ,
3890+ invokeOptions : options ,
3891+ } ) ,
3892+ ) ;
38103893 } ;
38113894
3812- return yield * wrapInvocationError (
3813- runtime . plugin . invokeTool ( {
3814- ctx : runtime . ctx ,
3815- toolRow : row ,
3816- credential,
3817- args,
3818- elicit : buildElicit ( address , args , handler ) ,
3819- invokeOptions : options ,
3820- } ) ,
3895+ const first = yield * invokeWith ( values ) ;
3896+ // Reactive refresh. `expires_at` is only ever the AS's ADVERTISED
3897+ // lifetime; the upstream rejecting the token is the authoritative word
3898+ // on whether it is still good. The two diverge routinely: server-side
3899+ // revocation, an identity provider's idle-timeout policy shorter than
3900+ // the token lifetime, and connections whose AS omitted `expires_in`
3901+ // entirely (null expiry → the proactive check never fires, so this is
3902+ // their ONLY route back to a working token short of a reconnect).
3903+ //
3904+ // Deliberately narrow: exactly one retry, only on the 401 that means
3905+ // "this credential is not valid", and only for a connection holding a
3906+ // refresh token. A 403 is excluded — it means authenticated-but-not-
3907+ // permitted, and re-minting the same grant returns the same answer.
3908+ // If the retry also fails its result stands, so a genuinely dead grant
3909+ // still surfaces the upstream's own auth failure and its reconnect
3910+ // guidance rather than a masked one.
3911+ if ( ! isUnauthorizedToolFailure ( first ) ) return first ;
3912+ const refreshed = yield * forceRefreshConnectionValues ( connectionRow ) . pipe (
3913+ // A failed re-mint is not this call's failure to report: the upstream
3914+ // already produced an auth failure with recovery guidance, which is
3915+ // strictly more actionable than a refresh-plumbing error. Keep it.
3916+ Effect . catchTag ( "CredentialResolutionError" , ( ) => Effect . succeed ( null ) ) ,
38213917 ) ;
3918+ if ( ! refreshed ) return first ;
3919+ yield * Effect . annotateCurrentSpan ( { "executor.oauth.refresh.retried" : true } ) ;
3920+ return yield * invokeWith ( refreshed ) ;
38223921 } ) . pipe (
38233922 // Expected tool failures (`ToolResult.fail`) resolve through the
38243923 // success channel, so the tracer alone would record them as healthy
0 commit comments