Skip to content

fix: retry lazy publisher init after a failed attempt - #653

Open
ihlokalise wants to merge 2 commits into
kibertoad:mainfrom
ihlokalise:fix/lazy-init-retry-after-failure
Open

ihlokalise wants to merge 2 commits into
kibertoad:mainfrom
ihlokalise:fix/lazy-init-retry-after-failure

Conversation

@ihlokalise

@ihlokalise ihlokalise commented Oct 23, 2026 •

Copy link
Copy Markdown

In plain words

Before a publisher can send its first message, it has to get ready: find the queue or topic, check it exists, and so on. If you don't call init() yourself, publish() does this for you the first time it's called. This is the "lazy init".

The bug: if that first get-ready step fails once, the publisher remembers the failure and never tries again. Every later publish() gets the same old error until the process restarts, even after the cause has gone away.

Think of calling a shop that doesn't answer. You write "they're closed" on a sticky note, and from then on you read the note instead of calling again. The shop opened an hour ago, but you never find out.

When this bites: anything short-lived during that first attempt, such as a network blip, AWS throttling GetQueueUrl, credentials that aren't ready yet at startup, or a queue that is still being created. A one-second hiccup turns into every message failing until the pod restarts. Fixing the queue or the permissions doesn't help, because the publisher never looks again.

Why it happened

The code already meant to forget the attempt once it finished. It just never got there when the attempt failed:

if (!this.initPromise) this.initPromise = this.init()
await this.initPromise        // throws here...
this.initPromise = undefined  // ...so this line never runs, and the failed attempt stays cached

The fix

The attempt is now forgotten in a finally, so it is cleared whether init succeeds or fails, and the next publish() simply tries again:

const initPromise = this.initPromise
try {
  await initPromise
} finally {
  if (this.initPromise === initPromise) this.initPromise = undefined
}

The === initPromise check makes sure we only clear our own attempt and never wipe out a newer retry that another caller has already started.

The same pattern was in four publishers, and all four are fixed:

  • AbstractSqsPublisher
  • AbstractSnsPublisher
  • AbstractPubSubPublisher
  • AbstractAmqpPublisher: this one doesn't await (its publish is synchronous), so the attempt is cleared in its existing .catch

Nothing else changes. A successful init behaves exactly as before. Concurrent publishes still share a single init attempt. A failed publish() still throws the init error to its caller, as it does today. It just doesn't keep throwing it forever.

Tests

One test per publisher: the first init() fails, and the next publish() succeeds and the message is delivered.

Each new test fails without the fix and passes with it. I checked this by reverting the library change and re-running the tests. The full publisher suites for sqs, sns, amqp and gcp-pubsub pass, and lint (biome + tsc) is clean for all four packages.

Summary by CodeRabbit

  • Bug Fixes
    • Message publishers can retry after a lazy initialization failure instead of remaining stuck on the failed attempt. A later publish can succeed once the initialization issue is resolved.
    • Concurrent publish attempts continue to share the same initialization attempt, and a failed earlier attempt won’t clear a newer retry. This helps preserve successful retries across AMQP, GCP Pub/Sub, SNS, and SQS publishers.

A failed lazy init left its rejected promise cached on the publisher, so every
later publish re-awaited the same rejection until the process restarted. Clear
the cached promise on failure as well as on success, in the SQS, SNS, GCP Pub/Sub
and AMQP publishers.
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: dbd4303f-a1ef-439c-ab90-db6f2bd57179
📥 Commits

Reviewing files that changed from the base of the PR and between fa5c83d and 4d83140.

📒 Files selected for processing: 3
  • packages/amqp/lib/AbstractAmqpPublisher.ts
  • packages/sns/test/publishers/SnsPermissionPublisher.spec.ts
  • packages/sqs/test/publishers/SqsPermissionPublisher.spec.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

Four message publishers now clear a settled lazy-initialization promise when it is still current. Tests cover retries after initialization failure across AMQP, GCP Pub/Sub, SNS, and SQS.

Changes: Publisher initialization retry

Layer / File(s) Summary
Clear settled initialization promises
packages/amqp/lib/AbstractAmqpPublisher.ts, packages/gcp-pubsub/lib/pubsub/AbstractPubSubPublisher.ts, packages/sns/lib/sns/AbstractSnsPublisher.ts, packages/sqs/lib/sqs/AbstractSqsPublisher.ts
Each publisher clears the initialization promise after it settles only if that promise remains current.
Verify retry behavior
packages/amqp/test/publishers/AmqpPermissionPublisher.spec.ts, packages/gcp-pubsub/test/publishers/PubSubPermissionPublisher.spec.ts, packages/sns/test/publishers/SnsPermissionPublisher.spec.ts, packages/sqs/test/publishers/SqsPermissionPublisher.spec.ts
Tests verify retry after initialization failure. AMQP also checks error handling and message delivery. SQS tests also cover creating a missing queue before a later publish and concurrent publishes sharing one failed initialization attempt.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Suggested reviewers: kibertoad, carlosgamero

Merge Risk: ⚪ Minimal · up to 4d831

No actionable risk introduced by this change remains after normal checks; the PR is ready to merge.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: retrying lazy publisher initialization after a failed attempt.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 8…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 8 files.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread packages/sns/test/publishers/SnsPermissionPublisher.spec.ts
Comment thread packages/sqs/test/publishers/SqsPermissionPublisher.spec.ts
Comment thread packages/amqp/lib/AbstractAmqpPublisher.ts
}
await this.initPromise
this.initPromise = undefined
const initPromise = this.initPromise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not caused by this PR, but it's close to it: in SQS, SNS and PubSub, close() doesn't clear initPromise (AMQP does). If close() runs while a lazy init is still going, the init finishes afterwards and sets isInitted = true again, so the closed publisher looks ready. A later publish() could also wait on the old promise.

Fine to leave it for a follow-up if you prefer. I just wanted to mention it since we're touching this code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@CarlosGamero How we would want publish() to behave when close() is triggered? Should we reject publishing or still send?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good question! I’d keep today’s behaviour: after close(), isInitted is false, so the next publish() just runs the lazy init again and sends. Rejecting would be a breaking change and isn’t related to this fix.

With that in mind, the case I mentioned ends up almost the same as a normal re-init, so it’s low risk. The only small thing would be clearing initPromise in close() like AMQP already does, to keep the publishers consistent, but that’s optional and fine for a follow-up. Not a blocker for this PR 👍

- AMQP: clear the cached init promise only on an init rejection, not when the
  publish after a successful init throws
- close the publishers created in the new SNS and SQS tests
- SQS: cover a real init failure (missing queue, then created) and concurrent
  publishes sharing one failed init
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.

3 participants