Merge pull request 'fix: add error handling to ClaudeService streaming' (#54) from fix/stream-error-handling into main
Some checks failed
CI / PHP 8.4 (push) Has been cancelled
CI / PHP 8.3 (push) Has been cancelled

Reviewed-on: #54
This commit is contained in:
Snider 2026-02-23 11:39:17 +00:00
commit f528f94d68
2 changed files with 113 additions and 15 deletions

View file

@ -9,6 +9,8 @@ use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Generator;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class ClaudeService implements AgenticProviderInterface
{
@ -58,28 +60,47 @@ class ClaudeService implements AgenticProviderInterface
);
}
/**
* Stream a completion from Claude.
*
* Yields text chunks as strings on success.
*
* On failure, yields a single error event array and terminates:
* ['type' => 'error', 'message' => string]
*
* @return Generator<string|array{type: 'error', message: string}>
*/
public function stream(
string $systemPrompt,
string $userPrompt,
array $config = []
): Generator {
$response = $this->client()
->withOptions(['stream' => true])
->post(self::API_URL, [
'model' => $config['model'] ?? $this->model,
'max_tokens' => $config['max_tokens'] ?? 4096,
'temperature' => $config['temperature'] ?? 1.0,
'stream' => true,
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userPrompt],
],
try {
$response = $this->client()
->withOptions(['stream' => true])
->post(self::API_URL, [
'model' => $config['model'] ?? $this->model,
'max_tokens' => $config['max_tokens'] ?? 4096,
'temperature' => $config['temperature'] ?? 1.0,
'stream' => true,
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userPrompt],
],
]);
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
} catch (Throwable $e) {
Log::error('Claude stream error', [
'message' => $e->getMessage(),
'exception' => $e,
]);
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
yield ['type' => 'error', 'message' => $e->getMessage()];
}
}
public function name(): string

View file

@ -11,7 +11,9 @@ declare(strict_types=1);
use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\ClaudeService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
@ -345,3 +347,78 @@ describe('error handling', function () {
->toThrow(RuntimeException::class);
});
});
// =========================================================================
// Stream Error Handling Tests
// =========================================================================
describe('stream error handling', function () {
it('yields error event when connection fails', function () {
Http::fake(function () {
throw new ConnectionException('Connection refused');
});
$service = new ClaudeService('test-api-key');
$results = iterator_to_array($service->stream('System', 'User'));
expect($results)->toHaveCount(1)
->and($results[0])->toBeArray()
->and($results[0]['type'])->toBe('error')
->and($results[0]['message'])->toContain('Connection refused');
});
it('yields error event when request throws a runtime exception', function () {
Http::fake(function () {
throw new RuntimeException('Unexpected failure');
});
$service = new ClaudeService('test-api-key');
$results = iterator_to_array($service->stream('System', 'User'));
expect($results)->toHaveCount(1)
->and($results[0]['type'])->toBe('error')
->and($results[0]['message'])->toBe('Unexpected failure');
});
it('error event contains type and message keys', function () {
Http::fake(function () {
throw new RuntimeException('Stream broke');
});
$service = new ClaudeService('test-api-key');
$event = iterator_to_array($service->stream('System', 'User'))[0];
expect($event)->toHaveKeys(['type', 'message'])
->and($event['type'])->toBe('error');
});
it('logs stream errors', function () {
Log::spy();
Http::fake(function () {
throw new RuntimeException('Logging test error');
});
$service = new ClaudeService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Log::shouldHaveReceived('error')
->with('Claude stream error', \Mockery::on(fn ($ctx) => str_contains($ctx['message'], 'Logging test error')))
->once();
});
it('yields text chunks normally when no error occurs', function () {
$stream = "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \"Hello\"}}\n\n";
$stream .= "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \" world\"}}\n\n";
$stream .= "data: [DONE]\n\n";
Http::fake([
CLAUDE_API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
]);
$service = new ClaudeService('test-api-key');
$results = iterator_to_array($service->stream('System', 'User'));
expect($results)->toBe(['Hello', ' world']);
});
});