Skip to content

fix(controller): back off per service on repeated failures - #55

Merged
s3rius merged 1 commit into
fix/hcloud-rate-limitfrom
fix/error-backoff
Oct 6, 2026
Merged

s3rius merged 1 commit into
fix/hcloud-rate-limitfrom
fix/error-backoff

Conversation

@lexfrei

@lexfrei lexfrei commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Failed reconciles now back off per service instead of retrying every 30 seconds forever. Stacked on #37, so the base is fix/hcloud-rate-limit.

Today every error except a rate limit is retried after a fixed 30 seconds, however long it lasts. When the failing step is a Hetzner API call, a service stuck on a permanent error repeats that call twice a minute. That spends the per-project request budget, and every other service shares it.

The delay now starts at 5 seconds and doubles up to 5 minutes. It's the same per-item backoff the upstream cloud-provider service controller uses (NewTypedItemExponentialFailureRateLimiter(5s, 300s) in controllers/service/controller.go).

  • State is a map keyed by namespace/name in CurrentContext, next to the rate limit gate.
  • A successful reconcile drops the entry. A skip drops it too, which covers a service that stopped being a robotlb load balancer.
  • A wait at the rate limit gate doesn't lengthen or reset the delay. Rate limit handling itself is unchanged.
  • A service deleted before it got the robotlb finalizer is not reconciled again, so its entry can't be dropped on success. Entries that haven't failed for an hour are pruned on the next failure of any service.

Watch events still trigger a reconcile right away. The backoff only spaces out retries of a service where nothing changed.

The first retries now come sooner than before: 5, 10 and 20 seconds instead of 30 each time. Since every failed attempt publishes a warning event, the first minute of a failure shows more events than it used to.

The delay has no jitter, same as upstream, so services that fail together also retry together. The existing spread could fix that later if it turns out to matter. A service held at the rate limit gate for more than an hour loses its entry and starts again at 5 seconds.

The FORGET_AFTER boundary could use a test of its own (a failure right at the limit keeps the entry, one second later drops it). a_long_failing_service_is_not_forgotten would pass with a much shorter window.

The key is namespace/name, not the UID, same as the upstream workqueue. A service recreated under the same name within that hour inherits the old delay until its first success.

README doesn't mention the retry delay, so no docs change.

Closes #46

A reconcile that failed for any reason other than a rate limit was
retried after a fixed 30 seconds, however long the error persisted.
When the failing step is a Hetzner API call, a service stuck on a
permanent error repeats that call twice a minute and spends the
per-project request budget that every other service shares.

Retry each service after 5 seconds, doubling up to 5 minutes, the
per-item backoff the upstream cloud-provider service controller uses.
A successful reconcile, or a skip once the service is no longer a
robotlb load balancer, starts the delay short again. A wait at the
rate limit gate neither lengthens nor resets it. Entries that have
not failed for an hour are dropped, so services deleted before they
got the finalizer do not accumulate.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@s3rius
s3rius merged commit d9927e5 into fix/hcloud-rate-limit Oct 6, 2026
1 check passed
s3rius pushed a commit that referenced this pull request Oct 13, 2026
…ce (#37)

* fix(hcloud): back off on rate limits and report failures on the service

Once the Hetzner Cloud API budget ran out, every service was retried
every 30 seconds, and each retry spent the budget being waited for. The
budget is per project, so after a 429 all services now pause together,
starting at one minute and doubling up to 16 minutes while the limit
keeps being hit. Adding targets stops at the first 429 instead of
calling the API for every remaining node. The generated client drops
response headers, so RateLimit-Reset cannot be used.

A failed reconciliation now leaves a SyncLoadBalancerFailed warning
event on the service, so kubectl describe shows the last error. Errors
are logged on one line with the status and the message Hetzner reported,
and the API token, which Hetzner quotes in some messages, is redacted
from logs and events. The chart role gains permission to create events.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix(hcloud): spread wakeups after a rate limit pause

Services paused by the rate limit gate were all requeued for the moment
it reopened, so they hit the API in one burst while the budget was
still nearly empty. Each wait now grows by up to a quarter, derived
from the service name, so services wake up spread over that window.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix(hcloud): report services waiting at the rate limit gate

With wakeups spread by service name, the same service wakes first after
every pause, hits the limit and closes the gate again. Every other
service only ever saw the closed gate, which published no event and
logged at debug level, so after the event TTL they showed nothing at
all. A service that wakes up to a closed gate now gets an event and an
info line.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>

* docs(helm): describe the default service account permissions

The comment claimed the default grants all permissions, while it lists
only what robotlb needs.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix(controller): back off per service on repeated failures (#55)

A reconcile that failed for any reason other than a rate limit was
retried after a fixed 30 seconds, however long the error persisted.
When the failing step is a Hetzner API call, a service stuck on a
permanent error repeats that call twice a minute and spends the
per-project request budget that every other service shares.

Retry each service after 5 seconds, doubling up to 5 minutes, the
per-item backoff the upstream cloud-provider service controller uses.
A successful reconcile, or a skip once the service is no longer a
robotlb load balancer, starts the delay short again. A wait at the
rate limit gate neither lengthens nor resets it. Entries that have
not failed for an hour are dropped, so services deleted before they
got the finalizer do not accumulate.

Assisted-by: LLM

Signed-off-by: Aleksei Sviridkin <f@lex.la>

---------

Signed-off-by: Aleksei Sviridkin <f@lex.la>
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.

2 participants