Skip to content

Commit f8aa081

Browse files
nficanocursoragent
andcommitted
fix: safe credential rotation ordering and retried revocation (#90 #91 #159)
- JobCredentialControls::rotateCredential() adds the new credential before removing the old one, so a store add() failure can no longer leave the job with neither credential. - Rotation revocation now retries a transient failure before logging a permanent error (§9.8.2), mirroring CredentialLifecycle. - Both revoke paths apply a brief backoff between attempts instead of two identical back-to-back calls. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8e1dadb commit f8aa081

2 files changed

Lines changed: 35 additions & 17 deletions

File tree

src/Internal/Runtime/CredentialLifecycle.php

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,22 @@ private function revokeCredential(
202202
$provisioner->revoke($credentialId);
203203
return true;
204204
} catch (\Throwable $e) {
205-
if ($attempt === 2) {
206-
$this->runtime->logger->error(
207-
'credential revocation failed; record retained for retry',
208-
[
209-
'credential_id' => $credentialId,
210-
'job_id' => (string) $job->id,
211-
'error' => $e->getMessage(),
212-
],
213-
);
205+
if ($attempt < 2) {
206+
// Brief backoff so the retry has a chance to clear a
207+
// transient upstream failure rather than failing twice
208+
// back-to-back.
209+
\Amp\delay(0.02 * $attempt);
210+
211+
continue;
214212
}
213+
$this->runtime->logger->error(
214+
'credential revocation failed; record retained for retry',
215+
[
216+
'credential_id' => $credentialId,
217+
'job_id' => (string) $job->id,
218+
'error' => $e->getMessage(),
219+
],
220+
);
215221
}
216222
}
217223
return false;

src/Runtime/JobCredentialControls.php

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ public function rotateCredential(Credential $new, string $previousCredentialId):
2727
if (!$provisioner instanceof CredentialProvisioner) {
2828
throw new FailedPreconditionException('no credential provisioner configured');
2929
}
30-
$this->runtime->credentials->remove($this->jobId, $previousCredentialId);
30+
// Add the replacement before removing the old record so a store
31+
// add() failure cannot leave the job with neither credential.
3132
$this->runtime->credentials->add($this->jobId, $new);
33+
$this->runtime->credentials->remove($this->jobId, $previousCredentialId);
3234
$this->revokePreviousCredential($provisioner, $previousCredentialId);
3335
$this->runtime->emit($this->session, new EventEmit('status', [
3436
'phase' => 'credential_rotated',
@@ -44,13 +46,23 @@ private function revokePreviousCredential(
4446
CredentialProvisioner $provisioner,
4547
string $credentialId,
4648
): void {
47-
try {
48-
$provisioner->revoke($credentialId);
49-
} catch (\Throwable $e) {
50-
$this->runtime->logger->warning(
51-
'credential revocation failed during rotation',
52-
['credential_id' => $credentialId, 'error' => $e->getMessage()],
53-
);
49+
// §9.8.2: revocation is best-effort but SHOULD retry transient
50+
// failures before giving up and logging a permanent failure.
51+
for ($attempt = 1; $attempt <= 2; $attempt++) {
52+
try {
53+
$provisioner->revoke($credentialId);
54+
return;
55+
} catch (\Throwable $e) {
56+
if ($attempt < 2) {
57+
\Amp\delay(0.02 * $attempt);
58+
59+
continue;
60+
}
61+
$this->runtime->logger->error(
62+
'credential revocation failed during rotation',
63+
['credential_id' => $credentialId, 'error' => $e->getMessage()],
64+
);
65+
}
5466
}
5567
}
5668
}

0 commit comments

Comments
 (0)