Skip to content

Commit 2af0ae2

Browse files
fix(service-automation): a connection already marked reconnect required answers the cause it stores, and the refusal log names it (#29065)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XAJtxVwrJVr4YFeLFREMkGX)_ Fixes #28963 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:连接器连接被标成「需要重新连接」后,之后的每一次调度都回答该行存下的原因(上一张卡让每条刷新路径存下真实原因:服务器拒绝、没发 refresh token、没有可用 client)。以前只有标记它的那一次说对,之后每次(换副本、重启、重试)都说「授权服务器拒绝了授权」。刷新标记连接时写的服务器日志也改为这句存下的原因,三条路径各说各的原因。 - **为什么改**:管理员从调度拒绝和服务器日志里读到的原因,在没发 refresh token 或没有 client 的情况下是错的(#28955 开发者发现,seat 1 立卡)。 - **风险与回滚**:低。只改拒绝文字和一行日志;503 `SERVICE_UNAVAILABLE`、状态值、错误码、写入都不变。没存原因的行仍回答原来的拒绝句。回滚即 revert。 - **先看**:`packages/services/service-automation/src/oauth2-grant/grant-handle.ts` 的 `markedReason` 及其两处调用;`plugin.ts` 的 `onGrantRefused` 日志行;`grant-handle.test.ts` 末尾的 `[#28963]` 四条。 - **两分钟验证**:`pnpm --filter @objectstack/service-automation exec vitest run src/oauth2-grant/grant-handle.test.ts src/connector-connection-door.test.ts`,预期 `Tests 46 passed (46)`(main 上 6 条红)。 - **需要你做的**:无,席位落地。 ## What changes - **`grant-handle.ts`: a connection already marked `reconnect_required` answers what it stores.** A new private `markedReason(connector, record)` returns the row's `statusReason`, and the default refusal sentence (`reconnectRequiredReason(connector, organizationId)`, unchanged) only when the row stores none. Both places that refuse a marked row use it: `select` (every dispatch's first read) and `refreshOnce`'s first check (the re-read under the cluster lock, which sees a row another replica marked while this one waited). The stored reasons are the per-path sentences PR #28955 introduced, so the first dispatch and every later one now answer the same sentence. - **`onGrantRefused` carries the reason.** `OAuth2GrantHandleDeps.onGrantRefused` is now `(organizationId, reason)`: `grantRefused` passes the sentence it just stored. The interface is internal: `oauth2-grant/` is not re-exported from the package entry (`src/index.ts` has no `oauth2-grant` export, and no other package names it), so no published member changes. - **`plugin.ts`: the log line is that reason.** The warn line was "connector instance 'X': the authorization server refused the grant of organization 'Y'; it needs to be reconnected there" on all three refresh paths. It is now `[Automation] ` + the stored reason, which names the instance, the organization and the cause in PR #28955's words. Still `warn`; it is distinct from the hold's own "… is degraded: …" line. - **Changeset** `.changeset/28963-reconnect-required-stored-cause.md`: `patch` on `@objectstack/service-automation`, both declaration lines. - No new status value, error code or published member; no `packages/spec/**`; no docs page change (`connectors.mdx` already says the connection's `statusReason` names the cause). **Why `Clause-②: no`.** Nothing that was accepted is refused and nothing refused is accepted: the 503 stays a 503 `SERVICE_UNAVAILABLE`, only its message on an already-marked row and one log line's text change. ## Tests Four new cases at the end of `src/oauth2-grant/grant-handle.test.ts` (`[#28963]`), on the file's in-memory connection store: - **A row marked because no refresh token was issued** answers the stored no-refresh-token sentence on the first dispatch, on two later `get`s and a `refresh`, and from a second handle that never saw the marking (another replica, a restart). None says `refused`; no token request is made. - **A row another replica marked while this one waited for the lock** (the lock double marks it `no_client` before running the refresh): the dispatch answers that stored sentence. This is the `refreshOnce` first check. - **A marked row that stores no cause** still answers `reconnectRequiredReason`'s refusal sentence, at `select` and under the lock (control; green on main too). - **Each refresh path tells the plugin its own cause:** no refresh token, no client and `invalid_grant` each call `onGrantRefused('org_a', stored)` once, with the stored sentence naming that cause. Two existing cases now also read the second argument (`invalid_grant` gets the refusal message; the kept-client case gets the no-client sentence). `src/connector-connection-door.test.ts` (real plugin, real ObjectQL on SQLite, local OAuth server): - **New:** a connect whose code exchange issues no refresh token, then a dispatch the upstream answers 401. The row stores the no-refresh-token sentence, the 503 message equals it, the log carries `[Automation] REASON.` and nowhere `refused the grant`, and the instance is held with that same cause. The rig gains `idp.exchangeIssuesRefreshToken` (default true, reset after each case). - **Updated:** the no-leak case's "the logs did record it" assertion reads the refused path's new line, `[Automation] connector instance 'crm' needs to be reconnected for organization 'org_a': its authorization server refused the grant`. <details><summary>Red on main, green on head</summary> - **Main** (the tests at `9ae5df9d`, before the fix): `Tests 6 failed | 40 passed (46)`. The reds: the two existing cases and the per-path case (`expected "vi.fn()" to be called with arguments: [ 'org_a', …(1) ]`), the no-refresh-token and replica cases (`Expected: "…its authorization server issued no refresh token…"` / `"…keeps no client to refresh with…"`, `Received: "…its authorization server refused the grant (revoked, or expired with no usable refresh)…"`), and the door case (`expected '…' to contain '[Automation] connector instance \'crm…'`). The no-stored-cause control is green both ways. - **Head:** `Test Files 2 passed (2)`, `Tests 46 passed (46)`. </details> <details><summary>Ablation, one per site</summary> Each through `node scripts/ablation-replace.mjs` (anchor 1 → 0, blob moved, restored; `git diff HEAD` empty after all four), running both files. The package's tests import `./grant-handle.js` and `./plugin.js` from `src/`, so no build sits between the mutation and the run. | Site | Mutation | Result | |---|---|---| | `select` | `markedReason(connector, record)` → `reconnectRequiredReason(connector, record.organizationId)` | 1 failed: the no-refresh-token "every later dispatch" case | | `refreshOnce` first check | `markedReason(connector, current)` → `reconnectRequiredReason(connector, organizationId)` | 1 failed: the replica-marked-under-lock case | | `grantRefused` callback | `onGrantRefused?.(record.organizationId, reason)` → `onGrantRefused?.(record.organizationId)` | 3 failed: the per-path case and the two updated cases | | plugin log line | `` `[Automation] ${reason}.` `` → the old "the authorization server refused the grant of organization" line | 2 failed: the new door case and the no-leak case | </details> ## Acceptance notes - **The plugin's hold-reason fallback (`plugin.ts`, the `holdConnectorUntilConnected` call in the reconcile) already met ruling 1 on main**, so it is unchanged: it reads `state.reconnectRequiredReason`, the first marked row's `statusReason`, and falls back to `reconnectRequiredReason(name, org)` only when that row stores none. The new door case pins it: the hold names the no-refresh-token cause. - **`onGrantRefused` keeps its name** although it now also fires for the two paths that never asked the server; renaming an internal callback is outside this card. - **Who read the old log text:** only `connector-connection-door.test.ts` (the no-leak case), updated here. Searched objectstack, objectui and hotcrm for `refused the grant of organization`: no other hit. ## Verification All readings at head `543224b1`: the change (`9ae5df9d` tests, `d40bcfaf` fix, `d5ba4f70` changeset) merged once with `origin/main` `ab085a04`, a clean merge. One later main commit, `0be247ce`, touches only `ui/` (plugin-detail). <details><summary>Package suite, typecheck, lint</summary> - Dependency closure: `pnpm turbo run build --filter='@objectstack/service-automation...' --concurrency=2`, `Tasks: 32 successful, 32 total`, lock VERDICT command-exit 0. - `pnpm --filter @objectstack/service-automation test` at `543224b1`: `Test Files 256 passed (256)`, `Tests 3303 passed (3303)`, lock VERDICT command-exit 0 (before the merge, at `d5ba4f70`: 255/255, 3289/3289). - `pnpm --filter @objectstack/service-automation typecheck` (tsc + test-typecheck) at `d5ba4f70`: lock VERDICT command-exit 0. The merge touched no file of this diff. - The repo's `pnpm lint` command scoped to `packages/services/service-automation`: exit 0. </details> <details><summary>Derived gates (`dispatch-gates.mjs --commands`, 61 families)</summary> - All 61 exit 0 at `543224b1`; `dispatch-gates --ran`: `61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED`. Record: `pm-orders/server/28963-gates-ran.txt` in the project. - The same list at `d5ba4f70` before the merge: 60 exit 0 and `check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET, other packages' `dist/` missing); at `543224b1` it ran and exits 0. `check-plugin-teardown-shape.mjs --self-test` needs its pinned fixture commit on this shallow clone (`git fetch --depth=1 origin 621a487…`), then exits 0. - Repo-wide `pnpm lint` and the CI-only families are CI's; see the PR's check-runs. </details> `test:repo`: not run locally (no repo-project test or derived list names it); CI's Test Core is the record. Dogfood battery: not owed (no `!`, no narrowing arm) and not run. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nz6egXzQp6jxykFAm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51058ef commit 2af0ae2

5 files changed

Lines changed: 158 additions & 16 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a connection already marked "reconnect required" answers the cause it stores, and the refusal log names it (#28963)
6+
7+
Clause-②: no — the text of a dispatch refusal on an already-marked connection and of one log line. No accept set, status value, error code or export changes meaning.
8+
Metadata-protocol: none
9+
10+
A refresh that marks a connector connection `reconnect_required` stores a reason naming its cause: the authorization server refused the grant, it issued no refresh token, or the connection keeps no client to refresh with. Two places still said the server refused the grant whatever had happened.
11+
12+
- **Every later dispatch on the marked connection** answers the reason the row stores, the same sentence the first dispatch answered. Before, only the dispatch that marked the row named the cause; every one after it (another replica, a restart, a retry) said the authorization server refused the grant. A row that stores no reason still answers the refusal sentence.
13+
- **The server log line** written when a refresh marks a connection is now that stored reason, so it names the cause on each of the three refresh paths. Before, it said "the authorization server refused the grant" on all three.
14+
- Unchanged: the 503 `SERVICE_UNAVAILABLE` refusal itself, the reason each path stores, the instance's "reconnect required" hold, and every status, code and write.

‎packages/services/service-automation/src/connector-connection-door.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const idp = {
6262
requests: [] as TokenRequest[],
6363
/** Answer to a refresh_token grant; default rotate. */
6464
refresh: 'rotate' as 'rotate' | 'invalid_grant' | 'down' | 'no_rotate',
65+
/** Whether the code exchange issues a refresh token; default yes. */
66+
exchangeIssuesRefreshToken: true,
6567
issued: 0,
6668
};
6769

@@ -86,7 +88,11 @@ beforeAll(async () => {
8688
if (form.grant_type === 'authorization_code') {
8789
if (form.code !== GOOD_CODE) return answer(400, { error: 'invalid_grant' });
8890
return answer(200, {
89-
access_token: `dummy-at-exchange-${n}`, token_type: 'Bearer', expires_in: 3600, refresh_token: `dummy-rt-${n}`, scope: 'crm.read',
91+
access_token: `dummy-at-exchange-${n}`,
92+
token_type: 'Bearer',
93+
expires_in: 3600,
94+
...(idp.exchangeIssuesRefreshToken ? { refresh_token: `dummy-rt-${n}` } : {}),
95+
scope: 'crm.read',
9096
});
9197
}
9298
if (form.grant_type === 'refresh_token') {
@@ -200,6 +206,7 @@ afterEach(async () => {
200206
}
201207
idp.requests.length = 0;
202208
idp.refresh = 'rotate';
209+
idp.exchangeIssuesRefreshToken = true;
203210
idp.issued = 0;
204211
vi.restoreAllMocks();
205212
});
@@ -490,6 +497,25 @@ describe('ADR-0143 D4 — dispatch with the connection', () => {
490497
expect((await rowsOf(ql))[0]).toMatchObject({ status: 'connected', revision: 2 });
491498
});
492499

500+
it('[#28963] a connection marked because no refresh token was issued: the log and the hold name that cause, not a refusal', async () => {
501+
const out = captureOutput();
502+
const { call, connect, provider, engine, ql } = await boot();
503+
idp.exchangeIssuesRefreshToken = false;
504+
await (await connect()).done();
505+
provider.state.accepted = new Set();
506+
const refused = await call('org_a').catch((e) => e);
507+
expect(refused).toMatchObject({ status: 503, message: expect.stringContaining('issued no refresh token') });
508+
const [row] = await rowsOf(ql);
509+
expect(row).toMatchObject({ status: 'reconnect_required', status_reason: refused.message });
510+
// The log line is the stored reason, whose cause is the one that happened.
511+
await vi.waitFor(() => expect(out.text()).toContain(`[Automation] ${refused.message}.`));
512+
expect(out.text()).not.toContain('refused the grant');
513+
// The instance is held with the cause the row stores.
514+
await vi.waitFor(() => expect(engine.getConnectorDegradedReason('crm')).toContain('issued no refresh token'));
515+
expect(engine.getConnectorDegradedReason('crm')).not.toContain('refused the grant');
516+
expect(idp.requests.filter((r) => r.form.grant_type === 'refresh_token')).toHaveLength(0);
517+
});
518+
493519
it("the run's organization selects the connection; a run with none is refused when several exist", async () => {
494520
const { call, connect, provider } = await boot();
495521
await (await connect(ADMIN_A)).done();
@@ -573,7 +599,9 @@ describe('ADR-0143 D3/D6 — what a grant leaves behind', () => {
573599
}
574600
// The logs did record the connect — the pin is not passing on silence.
575601
expect(out.text()).toContain("connector instance 'crm' connected for organization 'org_a' by user 'usr_admin'");
576-
expect(out.text()).toContain("refused the grant of organization 'org_a'");
602+
expect(out.text()).toContain(
603+
"[Automation] connector instance 'crm' needs to be reconnected for organization 'org_a': its authorization server refused the grant",
604+
);
577605
});
578606

579607
it('⛔ writes only its own record — never sys_metadata, never a sign-in account (D6) — and reads no account either', async () => {

‎packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts‎

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { callWithBorrowedCredential, BORROWED_CREDENTIAL_SKEW_MS } from '@object
1111
import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration';
1212
import type { ILock } from '@objectstack/spec/contracts';
1313
import type { ConnectorConnectionRecord, ConnectorConnectionStore, ConnectorGrantWrite } from '../connector-connections.js';
14-
import { createOAuth2GrantHandle, type OAuth2GrantConfig } from './grant-handle.js';
14+
import { createOAuth2GrantHandle, reconnectRequiredReason, type OAuth2GrantConfig } from './grant-handle.js';
1515

1616
const TOKEN_URL = 'https://idp.example.com/oauth/token';
1717

@@ -308,7 +308,7 @@ describe('ADR-0143 D4 — authorization_code: refresh', () => {
308308
expect(refused.message).not.toContain('dummy-rt-a');
309309
expect(calls.markReconnectRequired).toEqual(['org_a']);
310310
expect(held.get('org_a')).toMatchObject({ status: 'reconnect_required', refreshToken: undefined });
311-
expect(onGrantRefused).toHaveBeenCalledWith('org_a');
311+
expect(onGrantRefused).toHaveBeenCalledWith('org_a', refused.message);
312312
// The next dispatch reads the row: refused before any token request.
313313
await expect(handle.get({ organizationId: 'org_a' })).rejects.toMatchObject({ status: 503 });
314314
expect(idp.state.requests).toHaveLength(1);
@@ -410,7 +410,7 @@ describe('ADR-0143 D2.1 — an MCP instance whose connection keeps the client it
410410
await expect(handle.get({ organizationId: 'org_a' })).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 });
411411
expect(idp.seen).toHaveLength(0);
412412
expect(calls.markReconnectRequired).toEqual(['org_a']);
413-
expect(onGrantRefused).toHaveBeenCalledWith('org_a');
413+
expect(onGrantRefused).toHaveBeenCalledWith('org_a', expect.stringContaining('keeps no client to refresh with'));
414414
});
415415
});
416416

@@ -458,3 +458,94 @@ describe('[#28863] the stored reason of a connection marked reconnect required n
458458
expect(idp.state.requests).toHaveLength(1);
459459
});
460460
});
461+
462+
describe('[#28963] a connection already marked reconnect required answers the cause it stores, and the log names it', () => {
463+
const NO_REFRESH_TOKEN = 'issued no refresh token, so an access token the connection has to renew cannot be renewed';
464+
const NO_CLIENT = 'keeps no client to refresh with, so an access token it has to renew cannot be renewed';
465+
const REFUSED = 'its authorization server refused the grant (revoked, or expired with no usable refresh)';
466+
const refusalOf = (p: Promise<unknown>) => p.then(() => { throw new Error('the dispatch was not refused'); }, (e: Error & { status?: number; code?: string }) => e);
467+
468+
it('a row marked because no refresh token was issued answers that sentence on the first dispatch and on every later one', async () => {
469+
const idp = tokenEndpoint();
470+
const { store, held } = fakeConnections([{ org: 'org_a' }]);
471+
const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch });
472+
const first = await refusalOf(handle.get({ organizationId: 'org_a' }));
473+
const stored = String(held.get('org_a')?.statusReason);
474+
expect(stored).toContain(NO_REFRESH_TOKEN);
475+
expect(first.message).toBe(stored);
476+
// Every later dispatch reads the marked row and answers what it stores, not the refusal sentence.
477+
for (const later of [
478+
() => handle.get({ organizationId: 'org_a' }),
479+
() => handle.get({ organizationId: 'org_a' }),
480+
() => handle.refresh({ organizationId: 'org_a' }),
481+
]) {
482+
const refused = await refusalOf(later());
483+
expect(refused).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' });
484+
expect(refused.message).toBe(stored);
485+
expect(refused.message).not.toMatch(/refused/);
486+
}
487+
// So does a handle that never saw the marking (another replica, a restart).
488+
const other = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch });
489+
expect((await refusalOf(other.get({ organizationId: 'org_a' }))).message).toBe(stored);
490+
expect(idp.state.requests).toHaveLength(0);
491+
});
492+
493+
it('a row another replica marked while this one waited for the lock answers the cause that replica stored', async () => {
494+
const idp = tokenEndpoint();
495+
const { store, held } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]);
496+
const markedThere = reconnectRequiredReason('crm', 'org_a', 'no_client');
497+
const lock = {
498+
async withLock(_key: string, fn: () => Promise<unknown>) {
499+
Object.assign(held.get('org_a')!, { status: 'reconnect_required', statusReason: markedThere, refreshToken: undefined });
500+
return fn();
501+
},
502+
} as unknown as ILock;
503+
const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, lock: () => lock, fetch: idp.fetch });
504+
const refused = await refusalOf(handle.get({ organizationId: 'org_a' }));
505+
expect(refused).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' });
506+
expect(refused.message).toBe(markedThere);
507+
expect(idp.state.requests).toHaveLength(0);
508+
});
509+
510+
it('a marked row that stores no cause still answers the refusal sentence, on both reads', async () => {
511+
const idp = tokenEndpoint();
512+
const { store, held } = fakeConnections([{ org: 'org_a', status: 'reconnect_required' }]);
513+
const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch });
514+
const atSelect = await refusalOf(handle.get({ organizationId: 'org_a' }));
515+
expect(atSelect.message).toBe(reconnectRequiredReason('crm', 'org_a'));
516+
expect(atSelect.message).toContain(REFUSED);
517+
518+
held.get('org_a')!.status = 'connected';
519+
held.get('org_a')!.refreshToken = 'dummy-rt-a';
520+
const lock = {
521+
async withLock(_key: string, fn: () => Promise<unknown>) {
522+
held.get('org_a')!.status = 'reconnect_required';
523+
return fn();
524+
},
525+
} as unknown as ILock;
526+
const locked = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, lock: () => lock, fetch: idp.fetch });
527+
const underLock = await refusalOf(locked.get({ organizationId: 'org_a' }));
528+
expect(underLock.message).toBe(reconnectRequiredReason('crm', 'org_a'));
529+
expect(idp.state.requests).toHaveLength(0);
530+
});
531+
532+
it('the plugin is told the cause of each refresh path, in the words it stored', async () => {
533+
const cases = [
534+
{ cause: NO_REFRESH_TOKEN, config: AUTH_CODE, row: { org: 'org_a' }, answer: undefined },
535+
{ cause: NO_CLIENT, config: { grant: 'authorization_code' } as OAuth2GrantConfig, row: { org: 'org_a', refreshToken: 'dummy-rt-a' }, answer: undefined },
536+
{ cause: REFUSED, config: AUTH_CODE, row: { org: 'org_a', refreshToken: 'dummy-rt-a' }, answer: { status: 400, body: { error: 'invalid_grant' } } },
537+
];
538+
for (const { cause, config, row, answer } of cases) {
539+
const idp = tokenEndpoint();
540+
if (answer) idp.state.answer = () => answer;
541+
const { store, held } = fakeConnections([row]);
542+
const onGrantRefused = vi.fn();
543+
const handle = createOAuth2GrantHandle({ connector: 'crm', config, connections: store, fetch: idp.fetch, onGrantRefused });
544+
await refusalOf(handle.get({ organizationId: 'org_a' }));
545+
const stored = String(held.get('org_a')?.statusReason);
546+
expect(stored).toContain(cause);
547+
expect(onGrantRefused).toHaveBeenCalledTimes(1);
548+
expect(onGrantRefused).toHaveBeenCalledWith('org_a', stored);
549+
}
550+
});
551+
});

‎packages/services/service-automation/src/oauth2-grant/grant-handle.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,12 @@ export interface OAuth2GrantHandleDeps {
7878
/** The cluster lock, when the deployment has a cluster service. Read at refresh time. */
7979
readonly lock?: () => ILock | undefined;
8080
/**
81-
* Told when the authorization server refused an organization's grant and
82-
* the connection was marked `reconnect_required`, so the plugin re-judges
81+
* Told when a refresh marked an organization's connection
82+
* `reconnect_required`, with the `reason` it stored (whose cause is the
83+
* one that happened on that path), so the plugin logs it and re-judges
8384
* the instance. A throw here is the listener's own.
8485
*/
85-
readonly onGrantRefused?: (organizationId: string) => void;
86+
readonly onGrantRefused?: (organizationId: string, reason: string) => void;
8687
readonly timeoutMs?: number;
8788
readonly fetch?: typeof fetch;
8889
readonly now?: () => number;
@@ -155,6 +156,15 @@ export function reconnectRequiredReason(
155156
+ 'uses the new connection (ADR-0143 D4)';
156157
}
157158

159+
/**
160+
* The dispatch refusal of a connection already marked `reconnect_required`:
161+
* the reason the row stores, whose cause is the one that marked it, and the
162+
* authorization server's refusal only for a row that stores none.
163+
*/
164+
function markedReason(connector: string, record: ConnectorConnectionRecord): string {
165+
return record.statusReason ?? reconnectRequiredReason(connector, record.organizationId);
166+
}
167+
158168
/** Create the handle of one `oauth2` instance. */
159169
export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2GrantHandle {
160170
const { connector, config } = deps;
@@ -235,7 +245,7 @@ export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2Gran
235245
throw unavailable(`the connection of connector instance '${connector}' could not be read now; the next dispatch retries`);
236246
}
237247
if (!record) throw unavailable(notConnectedReason(connector, organizationId));
238-
if (record.status === 'reconnect_required') throw unavailable(reconnectRequiredReason(connector, record.organizationId));
248+
if (record.status === 'reconnect_required') throw unavailable(markedReason(connector, record));
239249
return record;
240250
};
241251

@@ -248,7 +258,7 @@ export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2Gran
248258
/* the refusal stands either way; the row is re-judged on the next dispatch */
249259
}
250260
try {
251-
deps.onGrantRefused?.(record.organizationId);
261+
deps.onGrantRefused?.(record.organizationId, reason);
252262
} catch {
253263
/* the listener's throw is its own */
254264
}
@@ -286,7 +296,7 @@ export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2Gran
286296
const refreshOnce = async (organizationId: string): Promise<CachedToken> => {
287297
const current = await deps.connections.read(organizationId, connector);
288298
if (!current) throw unavailable(notConnectedReason(connector, organizationId));
289-
if (current.status === 'reconnect_required') throw unavailable(reconnectRequiredReason(connector, organizationId));
299+
if (current.status === 'reconnect_required') throw unavailable(markedReason(connector, current));
290300
let refreshToken: string | undefined;
291301
try {
292302
refreshToken = await deps.connections.resolveRefreshToken(current);

‎packages/services/service-automation/src/plugin.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3697,11 +3697,10 @@ export class AutomationServicePlugin implements Plugin {
36973697
},
36983698
connections: this.connectorConnectionStoreOrNone(),
36993699
lock: () => this.clusterService(ctx)?.lock,
3700-
onGrantRefused: (organizationId) => {
3701-
ctx.logger.warn(
3702-
`[Automation] connector instance '${name}': the authorization server refused the grant of organization `
3703-
+ `'${organizationId}'; it needs to be reconnected there (ADR-0143 D4).`,
3704-
);
3700+
onGrantRefused: (_organizationId, reason) => {
3701+
// The reason the connection stored: it names the instance, the
3702+
// organization and the cause that happened on that refresh path.
3703+
ctx.logger.warn(`[Automation] ${reason}.`);
37053704
void this.materializeDeclaredConnectors(ctx, { fatal: false }).catch(() => { /* logged by the reconcile */ });
37063705
},
37073706
...(typeof entry.requestTimeoutMs === 'number' ? { timeoutMs: entry.requestTimeoutMs } : {}),

0 commit comments

Comments
 (0)