Skip to content

Commit f464c41

Browse files
nficanocursoragent
andcommitted
fix: always emit error payload retryable flag (§12) (#162)
ErrorPayload::toArray() always emits a retryable boolean, derived from the canonical code's default when not set explicitly, so LEASE_EXPIRED and BUDGET_EXHAUSTED travel as retryable:false and INTERNAL_ERROR as retryable:true. Adds an effectiveRetryable() accessor. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7cb5158 commit f464c41

3 files changed

Lines changed: 36 additions & 5 deletions

File tree

src/Errors/ErrorPayload.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ public static function fromException(ARCPException $e): self
5050
);
5151
}
5252

53+
/**
54+
* The effective retryability emitted on the wire: an explicit flag wins,
55+
* otherwise the canonical code's default (§12), defaulting to false for
56+
* namespaced/extension codes with no enum mapping.
57+
*/
58+
public function effectiveRetryable(): bool
59+
{
60+
return $this->retryable ?? ($this->canonical()?->defaultRetryable() ?? false);
61+
}
62+
5363
/** Map the wire code to a canonical {@see ErrorCode}, when possible. */
5464
public function canonical(): ?ErrorCode
5565
{
@@ -71,21 +81,23 @@ public function isNamespaced(): bool
7181
* @return array{
7282
* code: string,
7383
* message: string,
74-
* retryable?: bool,
84+
* retryable: bool,
7585
* details?: ErrorDetails,
7686
* cause?: array<string, mixed>,
7787
* trace_id?: string,
7888
* }
7989
*/
8090
public function toArray(): array
8191
{
92+
// §12: error payloads MUST carry a retryable boolean. Emit the
93+
// effective value even when not set explicitly so LEASE_EXPIRED /
94+
// BUDGET_EXHAUSTED always travel as retryable:false and
95+
// INTERNAL_ERROR as retryable:true.
8296
$out = [
8397
'code' => $this->code,
8498
'message' => $this->message,
99+
'retryable' => $this->effectiveRetryable(),
85100
];
86-
if ($this->retryable !== null) {
87-
$out['retryable'] = $this->retryable;
88-
}
89101
if ($this->details !== []) {
90102
$out['details'] = $this->details;
91103
}

tests/Unit/ErrorsTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ public function testErrorPayloadRoundTrip(): void
172172
self::assertSame($payload->details, $back->details);
173173
}
174174

175+
public function testRetryableAlwaysEmittedAndDerived(): void
176+
{
177+
// §12: every encoded error payload carries a retryable boolean even
178+
// when not set explicitly.
179+
$leaseExpired = new ErrorPayload('LEASE_EXPIRED', 'lease ended');
180+
self::assertArrayHasKey('retryable', $leaseExpired->toArray());
181+
self::assertFalse($leaseExpired->toArray()['retryable']);
182+
183+
$budget = new ErrorPayload('BUDGET_EXHAUSTED', 'no funds');
184+
self::assertFalse($budget->toArray()['retryable']);
185+
186+
$internal = new ErrorPayload('INTERNAL', 'boom');
187+
self::assertTrue($internal->toArray()['retryable']);
188+
189+
// An explicit flag still wins over the derived default.
190+
$forced = new ErrorPayload('INTERNAL', 'boom', retryable: false);
191+
self::assertFalse($forced->toArray()['retryable']);
192+
}
193+
175194
public function testErrorPayloadFromException(): void
176195
{
177196
$e = new PermissionDeniedException('p', 'r');

tests/Unit/MessageCatalogRoundTripTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public function testCatalogRegistersEverything(): void
108108
public static function specimens(): iterable
109109
{
110110
$now = new \DateTimeImmutable('2026-05-09T12:00:00Z');
111-
$err = new ErrorPayload('INTERNAL', 'something went wrong');
111+
$err = new ErrorPayload('INTERNAL', 'something went wrong', true);
112112

113113
yield 'session.open' => [new SessionOpen(
114114
Auth::bearer('t'),

0 commit comments

Comments
 (0)