Skip to content

feat(middleware): add authz cache, circuit breaker, and bounded retry#129

Merged
fredcamaral merged 1 commit into
developfrom
feat/authz-resilience-108
Jul 21, 2026
Merged

feat(middleware): add authz cache, circuit breaker, and bounded retry#129
fredcamaral merged 1 commit into
developfrom
feat/authz-resilience-108

Conversation

@fredcamaral

Copy link
Copy Markdown
Member

Stacked on #127 (feat/auth-required-fail-closed-107). This PR is based on #107's branch and builds on its Required fail-closed flag. Merge after #127. Once #127 lands, rebase this onto develop and retarget the base to develop.

What

Adds three opt-in authorization resilience layers (all default-off → default behavior byte-for-byte unchanged) plus a prerequisite context fix, composed as breaker( retry( httpCall(ctx) ) ) with the cache checked before the breaker.

Prerequisite bug fix

checkAuthorization built the authz POST with http.NewRequest (no context), so the caller's deadline/cancellation never reached the call. Now uses http.NewRequestWithContext(ctx, ...) wrapped in context.WithTimeout(ctx, AUTH_TIMEOUT).

Layers

  • Per-request timeoutAUTH_TIMEOUT, default 30s (behavior-neutral; was the client-wide timeout). Now genuinely per-request, cancellable, and caps the retry budget.
  • TTL decision cacheAUTH_CACHE_TTL > 0. Keyed by (sub, resource, action, product)never the raw token. Bounded, 16-way sharded, no-dependency map with lazy expiry (no background goroutine to leak). get never returns an expired entry. Caches positive and negative decisions. Staleness window = TTL (documented revocation-lag tradeoff).
  • Circuit breakerAUTH_BREAKER_ENABLED (sony/gobreaker, promoted to direct). On open: serves only a fresh positive cache hit (checked before the breaker), otherwise denies. Never serves a stale grant.
  • Bounded retryAUTH_RETRY_MAX (cenkalti/backoff/v5, already a dep). Retries only transient failures (network/timeout/5xx); never 401/403. MaxElapsedTime capped at AUTH_TIMEOUT.

Fail-closed contract

Every fallback — breaker open, retry exhausted, timeout, any error — denies (Fiber 403 / gRPC PermissionDenied). Complements #107: misconfig at construction → 503 (#107, never mounts open) vs runtime outage → deny/403 (#108). Two triggers, both fail-closed. No path fails open — proven by tests for breaker-open-no-cache, retry-exhausted, timeout, and expired-cache-under-outage.

Composition & default path

When all knobs are off, checkAuthorization makes a single call and returns the outcome exactly as before (all pre-existing tests pass, including the 500-on-authz-down and 403-with-error-body cases). The transient-vs-authoritative classification and 403-deny only engage when a resilience layer is active.

Tests

18 new tests: cache hit avoids a second POST; TTL expiry re-queries; negative decision cached; retry on 5xx eventually succeeds; retry NOT on 403; breaker opens after N and denies without touching the service; breaker-open serves a fresh positive cache hit but denies an uncached key; per-request timeout and caller-cancellation abort the call; cache is bounded and race-clean; config wiring (defaults / all-enabled / invalid-values).

Verification

go build, go vet, go test -race, golangci-lint run ./... (gocognit/gocyclo under ceilings — no //nolint), and gosec ./... all clean locally. go mod tidy run after promoting gobreaker to a direct dependency.

Closes #108

https://claude.ai/code/session_01RcQLK4qK1kbpvXWjuAR3Xh

Every authorized request made a synchronous POST /v1/authorize with no cache,
breaker, or retry, so an authz-service blip or outage amplified straight into
the request path. The call was also built with http.NewRequest (no context),
so a caller's deadline/cancellation never reached it.

Add three opt-in resilience layers (all default-off, so default behavior is
byte-for-byte unchanged) plus the context fix, composed as
breaker(retry(httpCall(ctx))) with the cache checked before the breaker:

- Prereq fix: build the authz request with http.NewRequestWithContext, and wrap
  each call in context.WithTimeout(ctx, AUTH_TIMEOUT) (default 30s =
  behavior-neutral). The caller's cancellation now aborts the call, and the
  deadline caps the retry budget.
- TTL decision cache (AUTH_CACHE_TTL > 0) keyed by (sub, resource, action,
  product) — never the raw token. Bounded, sharded, no-dependency map with lazy
  expiry (no background goroutine to leak). get never returns an expired entry.
  Documented staleness/revocation-lag window = TTL.
- Circuit breaker (AUTH_BREAKER_ENABLED, sony/gobreaker promoted to direct). On
  open it serves ONLY a fresh positive cache hit (checked before the breaker),
  otherwise denies — never a stale grant.
- Bounded retry (AUTH_RETRY_MAX, cenkalti/backoff/v5) on TRANSIENT failures only
  (network/timeout/5xx); authoritative 401/403 are never retried.

Every fallback — breaker open, retry exhausted, timeout — denies (fail closed,
403 / PermissionDenied). This complements #107: misconfig at construction ->
503 (never mounts open); runtime outage -> deny. Two triggers, both fail-closed.

Stacks on #107 (PR #127): built on its Required fail-closed flag. Must merge
after #127; rebase onto develop and retarget base to develop once #127 lands.

Closes #108

Claude-Session: https://claude.ai/code/session_01RcQLK4qK1kbpvXWjuAR3Xh
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • develop

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9064590c-1c22-48f9-a21e-15fb871255a2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/authz-resilience-108

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

Base automatically changed from feat/auth-required-fail-closed-107 to develop July 21, 2026 12:47
@fredcamaral
fredcamaral merged commit 7cbd20b into develop Jul 21, 2026
3 checks passed
@fredcamaral
fredcamaral deleted the feat/authz-resilience-108 branch July 21, 2026 12:48
@lerian-studio-midaz-push-bot

Copy link
Copy Markdown

🎉 This PR is included in version 3.0.0-beta.7 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@lerian-studio-midaz-push-bot

Copy link
Copy Markdown

🎉 This PR is included in version 3.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant