Skip to content

fix(grpc): recreate stuck clients before retries and fix reflection client recreation - #190

Open
SeqviriouM wants to merge 2 commits into
mainfrom
gateway/grpc-recreate-logic
Open

fix(grpc): recreate stuck clients before retries and fix reflection client recreation#190
SeqviriouM wants to merge 2 commits into
mainfrom
gateway/grpc-recreate-logic

Conversation

@SeqviriouM

@SeqviriouM SeqviriouM commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary by Sourcery

Ensure gRPC retries use fresh clients when channels are stuck and keep reflection-client caches synchronized.

Bug Fixes:

  • Recreate stuck gRPC service clients before retrying connectivity failures and refresh reflection clients when their channels become unhealthy.
  • Prevent failed client recreation from leaving requests unresolved and close replaced clients to avoid connection leaks.

Enhancements:

  • Coordinate service and reflection-client cache invalidation while avoiding removal of clients replaced concurrently.
  • Document the updated channel-state-dependent client recreation and retry behavior.

Build:

  • Upgrade the @grpc/grpc-js dependency to 1.14.4.

@SeqviriouM
SeqviriouM requested a review from DakEnviy as a code owner August 21, 2026 10:40
@sourcery-ai

sourcery-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adjusts gRPC client recreation and retry behavior to avoid reusing stuck channels, ensures reflection clients are recreated and closed correctly, and introduces connectivity-aware error handling and caching utilities while bumping @grpc/grpc-js.

File-Level Changes

Change Details Files
Make service client recreation conditional on channel connectivity state and integrate it cleanly with the retry loop.
  • Capture channel connectivity state on errors and log it with request metadata.
  • Define an isChannelBroken heuristic (non-READY, non-IDLE) to distinguish stuck channels from slow backends.
  • Change shouldRecreateService logic so broken channels are recreated before retries, and healthy channels only after retries are exhausted.
  • Ensure service recreation is synchronous with cache clearing so retries cannot reuse stale clients, and use an extended close timeout (deadline + grace).
lib/components/grpc.ts
README.md
Refactor service and reflection client caching/cleanup to avoid leaks and correctly handle concurrent recreation.
  • Introduce CLIENT_CLOSE_GRACE_MS, serviceCachePromiseSymbol, and ServiceClientWithCacheRef to track which promise a client was cached under.
  • Add markCachedServicePromise to stamp resolved clients with their cache promise without blocking on potentially hanging promises.
  • Refactor clearInstancesCache to synchronously unset cache entries only when they still point to the given client/promise and to use a new closeServiceClient helper for safe delayed close with error logging.
  • Centralize cache clearing in clearServiceCache and wire it into all recreateService call sites (regular and reflection-based services).
lib/components/grpc.ts
Ensure reflection clients are recreated and closed alongside service clients when channels are suspected to be stuck.
  • Add clearReflectionClientCache to drop cached reflection clients per endpoint and close their underlying grpc clients best-effort.
  • Call clearReflectionClientCache when clearing a reflection service instance cache or when getService/retry-time client creation fails with connectivity-like errors.
  • Wrap reflection service cache entries with markCachedServicePromise and close superseded reflection clients during cache refresh to prevent connection leaks.
lib/components/grpc.ts
lib/utils/grpc-reflection.ts
Introduce connectivity-aware gRPC error classification for targeted cache clearing and retries.
  • Add isConnectivityGrpcError helper that treats retryable and recreate-service errors as connectivity-related.
  • Use isConnectivityGrpcError in createGrpcAction to decide when reflection clients should be dropped after getService errors so deterministic config/proto issues don’t trigger unnecessary recreation.
lib/components/grpc.ts
lib/utils/grpc.ts
Upgrade gRPC library dependency to pick up fixes while keeping the rest of the stack unchanged.
  • Bump @grpc/grpc-js from 1.12.6 to 1.14.4 and update lockfile accordingly.
package.json
package-lock.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

Fixed security issues:

  • @grpc/grpc-js (link) · Dashboard

  • In the getService retry error branch you call clearReflectionClientCache(actionEndpoint), but in the dynamic-endpoint case actionEndpoint is not defined in this scope; consider using getActionEndpoint(args) (wrapped in a try/catch as you did earlier) or reusing clearServiceCache to avoid a potential ReferenceError and keep the behavior consistent.

  • clearReflectionClientCache closes reflection clients immediately, while other client shutdown paths use closeServiceClient with a grace period; consider reusing closeServiceClient (and/or CLIENT_CLOSE_GRACE_MS) here to avoid abruptly terminating any in-flight reflection calls.

Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the `getService` retry error branch you call `clearReflectionClientCache(actionEndpoint)`, but in the dynamic-endpoint case `actionEndpoint` is not defined in this scope; consider using `getActionEndpoint(args)` (wrapped in a try/catch as you did earlier) or reusing `clearServiceCache` to avoid a potential `ReferenceError` and keep the behavior consistent.
- `clearReflectionClientCache` closes reflection clients immediately, while other client shutdown paths use `closeServiceClient` with a grace period; consider reusing `closeServiceClient` (and/or `CLIENT_CLOSE_GRACE_MS`) here to avoid abruptly terminating any in-flight reflection calls.

## Individual Comments

### Comment 1
<location path="lib/components/grpc.ts" line_range="408" />
<code_context>
+ * the cache already holds another client (e.g. a concurrent request has
+ * re-created it earlier).
+ */
 function clearInstancesCache<Context extends GatewayContext>(
     service: ServiceClient,
     instancesMap: typeof serviceInstancesMap | typeof reflectionServiceInstancesMap,
</code_context>
<issue_to_address>
**issue (complexity):** Consider splitting cache clearing into separate reflection and non-reflection helpers and moving reflection-specific logic into grpc-reflection to simplify control flow and remove symbol-based coupling.

You can recover a lot of readability by de‑genericizing the cache clearing and pulling the reflection‑specific lifecycle into its own helpers, without losing any of the new behavior.

### 1. Split `clearInstancesCache` by cache type

Right now `clearInstancesCache` must understand both:

- `serviceInstancesMap: Record<string, Record<string, ServiceClient>>`
- `reflectionServiceInstancesMap: Record<string, Record<string, Promise<ServiceClient>>>`

and uses `serviceCachePromiseSymbol` + `markCachedServicePromise` to reconcile them. You can keep the semantics but make each function operate on a single, known shape and drop the symbol tagging entirely.

For example:

```ts
// non-reflection: cache stores concrete clients
function clearServiceInstance<Context extends GatewayContext>(
    service: ServiceClient,
    protoKey: string,
    actionEndpoint: string,
    closeTimeout: number,
    ctx: Context,
): boolean {
    const cachePath: [string, string] = [protoKey, actionEndpoint];
    const cached = _.get(serviceInstancesMap, cachePath);

    if (cached !== service) {
        ctx.log(`Service client not matched cached service for cachePath '${cachePath}'`);
        return false;
    }

    _.unset(serviceInstancesMap, cachePath);
    closeServiceClient(service, closeTimeout, (error) => {
        ctx.logError('Failed to close connection during clearing instances cache', error, {
            cachePath,
        });
    });

    return true;
}
```

```ts
// reflection: cache stores Promise<ServiceClient>
async function clearReflectionServiceInstance<Context extends GatewayContext>(
    service: ServiceClient,
    protoKey: string,
    actionEndpoint: string,
    closeTimeout: number,
    ctx: Context,
): Promise<boolean> {
    const cachePath: [string, string] = [protoKey, actionEndpoint];
    const cachedPromise = _.get(reflectionServiceInstancesMap, cachePath);
    if (!cachedPromise) {
        ctx.log(`No reflection client in cache for cachePath '${cachePath}'`);
        return false;
    }

    let cachedClient: ServiceClient | undefined;
    try {
        cachedClient = await Promise.race([
            cachedPromise,
            // cheap safeguard: don't hang forever on a never‑resolving promise
            new Promise<ServiceClient>((_, reject) =>
                setTimeout(() => reject(new Error('Reflection client promise timeout')), 0),
            ),
        ]);
    } catch {
        // if it never resolved or failed, we still want to drop the cache entry
    }

    if (cachedClient && cachedClient !== service) {
        ctx.log(
            `Reflection service client not matched cached promise for cachePath '${cachePath}'`,
        );
        return false;
    }

    _.unset(reflectionServiceInstancesMap, cachePath);
    closeServiceClient(service, closeTimeout, (error) => {
        ctx.logError(
            'Failed to close connection during clearing reflection instances cache',
            error,
            {cachePath},
        );
    });

    return true;
}
```

This removes:

- `serviceCachePromiseSymbol`
- `ServiceClientWithCacheRef`
- `markCachedServicePromise`
- The “sometimes a client, sometimes a promise” semantics in `clearInstancesCache`.

### 2. Inline `clearServiceCache` into explicit recreate paths

With specialized clear functions, `clearServiceCache` becomes thin indirection. Making recreate behavior explicit per branch is easier to follow and keeps the reflection‑specific behavior local.

For example:

```ts
if (typeof config.endpoint === 'function') {
    // ...
    recreateService = async (service, closeTimeout, ctx, args) => {
        const actionEndpoint = getActionEndpoint(args);
        if ('reflection' in config) {
            const cleared = await clearReflectionServiceInstance(
                service,
                config.protoKey,
                actionEndpoint,
                closeTimeout,
                ctx,
            );
            if (cleared) {
                clearReflectionClientCache(actionEndpoint);
            }
        } else {
            clearServiceInstance(service, config.protoKey, actionEndpoint, closeTimeout, ctx);
        }
    };
} else if (endpoints) {
    // ...
    recreateService = async (service, closeTimeout, ctx) => {
        if ('reflection' in config) {
            const cleared = await clearReflectionServiceInstance(
                service,
                config.protoKey,
                actionEndpoint,
                closeTimeout,
                ctx,
            );
            if (cleared) {
                clearReflectionClientCache(actionEndpoint);
            }
        } else {
            clearServiceInstance(service, config.protoKey, actionEndpoint, closeTimeout, ctx);
        }
    };
}
```

This keeps:

- Synchronous cache invalidation for the non‑reflection clients.
- Reflection client cache tied to service client recreation.
- The “close with grace period” behavior via `closeServiceClient`.

### 3. Move reflection‑specific helpers out of `grpc.ts`

Given you already have `grpc-reflection.ts`, the reflection lifecycle is a good candidate to encapsulate there:

- `getServiceInstanceReflectCached`
- `clearReflectionServiceInstance` (from above)
- `clearReflectionClientCache`

Then `grpc.ts` only depends on a small, reflection‑aware interface instead of knowing about promise caches and reflection client coupling:

```ts
// grpc.ts
import {
    getServiceInstanceReflectCached,
    clearReflectionServiceInstance,
    clearReflectionClientCache,
} from '../utils/grpc-reflection';
```

This addresses the current cross‑cutting concerns: all reflection‑only details (promise cache, coupling between reflection client and service client) are in one place.

---

These changes keep all the new behaviors (no leaked connections, synchronous cache clearing, reflection cache tied to client recreation, channel‑state‑aware retries) but substantially reduce the layers of indirection and remove the symbol‑based coupling between creation, storage, and clearing.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread lib/components/grpc.ts
* the cache already holds another client (e.g. a concurrent request has
* re-created it earlier).
*/
function clearInstancesCache<Context extends GatewayContext>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider splitting cache clearing into separate reflection and non-reflection helpers and moving reflection-specific logic into grpc-reflection to simplify control flow and remove symbol-based coupling.

You can recover a lot of readability by de‑genericizing the cache clearing and pulling the reflection‑specific lifecycle into its own helpers, without losing any of the new behavior.

1. Split clearInstancesCache by cache type

Right now clearInstancesCache must understand both:

  • serviceInstancesMap: Record<string, Record<string, ServiceClient>>
  • reflectionServiceInstancesMap: Record<string, Record<string, Promise<ServiceClient>>>

and uses serviceCachePromiseSymbol + markCachedServicePromise to reconcile them. You can keep the semantics but make each function operate on a single, known shape and drop the symbol tagging entirely.

For example:

// non-reflection: cache stores concrete clients
function clearServiceInstance<Context extends GatewayContext>(
    service: ServiceClient,
    protoKey: string,
    actionEndpoint: string,
    closeTimeout: number,
    ctx: Context,
): boolean {
    const cachePath: [string, string] = [protoKey, actionEndpoint];
    const cached = _.get(serviceInstancesMap, cachePath);

    if (cached !== service) {
        ctx.log(`Service client not matched cached service for cachePath '${cachePath}'`);
        return false;
    }

    _.unset(serviceInstancesMap, cachePath);
    closeServiceClient(service, closeTimeout, (error) => {
        ctx.logError('Failed to close connection during clearing instances cache', error, {
            cachePath,
        });
    });

    return true;
}
// reflection: cache stores Promise<ServiceClient>
async function clearReflectionServiceInstance<Context extends GatewayContext>(
    service: ServiceClient,
    protoKey: string,
    actionEndpoint: string,
    closeTimeout: number,
    ctx: Context,
): Promise<boolean> {
    const cachePath: [string, string] = [protoKey, actionEndpoint];
    const cachedPromise = _.get(reflectionServiceInstancesMap, cachePath);
    if (!cachedPromise) {
        ctx.log(`No reflection client in cache for cachePath '${cachePath}'`);
        return false;
    }

    let cachedClient: ServiceClient | undefined;
    try {
        cachedClient = await Promise.race([
            cachedPromise,
            // cheap safeguard: don't hang forever on a never‑resolving promise
            new Promise<ServiceClient>((_, reject) =>
                setTimeout(() => reject(new Error('Reflection client promise timeout')), 0),
            ),
        ]);
    } catch {
        // if it never resolved or failed, we still want to drop the cache entry
    }

    if (cachedClient && cachedClient !== service) {
        ctx.log(
            `Reflection service client not matched cached promise for cachePath '${cachePath}'`,
        );
        return false;
    }

    _.unset(reflectionServiceInstancesMap, cachePath);
    closeServiceClient(service, closeTimeout, (error) => {
        ctx.logError(
            'Failed to close connection during clearing reflection instances cache',
            error,
            {cachePath},
        );
    });

    return true;
}

This removes:

  • serviceCachePromiseSymbol
  • ServiceClientWithCacheRef
  • markCachedServicePromise
  • The “sometimes a client, sometimes a promise” semantics in clearInstancesCache.

2. Inline clearServiceCache into explicit recreate paths

With specialized clear functions, clearServiceCache becomes thin indirection. Making recreate behavior explicit per branch is easier to follow and keeps the reflection‑specific behavior local.

For example:

if (typeof config.endpoint === 'function') {
    // ...
    recreateService = async (service, closeTimeout, ctx, args) => {
        const actionEndpoint = getActionEndpoint(args);
        if ('reflection' in config) {
            const cleared = await clearReflectionServiceInstance(
                service,
                config.protoKey,
                actionEndpoint,
                closeTimeout,
                ctx,
            );
            if (cleared) {
                clearReflectionClientCache(actionEndpoint);
            }
        } else {
            clearServiceInstance(service, config.protoKey, actionEndpoint, closeTimeout, ctx);
        }
    };
} else if (endpoints) {
    // ...
    recreateService = async (service, closeTimeout, ctx) => {
        if ('reflection' in config) {
            const cleared = await clearReflectionServiceInstance(
                service,
                config.protoKey,
                actionEndpoint,
                closeTimeout,
                ctx,
            );
            if (cleared) {
                clearReflectionClientCache(actionEndpoint);
            }
        } else {
            clearServiceInstance(service, config.protoKey, actionEndpoint, closeTimeout, ctx);
        }
    };
}

This keeps:

  • Synchronous cache invalidation for the non‑reflection clients.
  • Reflection client cache tied to service client recreation.
  • The “close with grace period” behavior via closeServiceClient.

3. Move reflection‑specific helpers out of grpc.ts

Given you already have grpc-reflection.ts, the reflection lifecycle is a good candidate to encapsulate there:

  • getServiceInstanceReflectCached
  • clearReflectionServiceInstance (from above)
  • clearReflectionClientCache

Then grpc.ts only depends on a small, reflection‑aware interface instead of knowing about promise caches and reflection client coupling:

// grpc.ts
import {
    getServiceInstanceReflectCached,
    clearReflectionServiceInstance,
    clearReflectionClientCache,
} from '../utils/grpc-reflection';

This addresses the current cross‑cutting concerns: all reflection‑only details (promise cache, coupling between reflection client and service client) are in one place.


These changes keep all the new behaviors (no leaked connections, synchronous cache clearing, reflection cache tied to client recreation, channel‑state‑aware retries) but substantially reduce the layers of indirection and remove the symbol‑based coupling between creation, storage, and clearing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant