Skip to content

Commit 9186a2a

Browse files
authored
Merge pull request #40 from petal-labs/feat/sdk-reliability-hardening
feat: SDK reliability hardening (errors, structured output, timeouts)
2 parents a300ffb + 5e94143 commit 9186a2a

44 files changed

Lines changed: 2720 additions & 80 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/batch.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package core
22

33
import (
44
"context"
5+
"errors"
56
"time"
67
)
78

@@ -79,6 +80,10 @@ func AsBatchProvider(p Provider) (BatchProvider, bool) {
7980
}
8081

8182
// BatchWaiter provides utilities for waiting on batch completion.
83+
//
84+
// BatchWaiter polls BatchProvider directly and does not go through
85+
// core.Client, so core.WithTimeout does not apply; bound Wait via
86+
// WithMaxWait and/or a context deadline passed by the caller.
8287
type BatchWaiter struct {
8388
provider BatchProvider
8489
pollInterval time.Duration
@@ -109,14 +114,36 @@ func (w *BatchWaiter) WithMaxWait(d time.Duration) *BatchWaiter {
109114

110115
// Wait blocks until the batch completes or the context is cancelled.
111116
// Returns the final batch info, or an error if the wait times out or fails.
117+
//
118+
// Each poll is individually bounded by the remaining maxWait budget: a
119+
// GetBatchStatus call that hangs (e.g. a stuck network request) cannot
120+
// exceed the overall deadline, since the per-poll context is derived from
121+
// that deadline rather than from the caller's context alone.
112122
func (w *BatchWaiter) Wait(ctx context.Context, id BatchID) (*BatchInfo, error) {
113123
deadline := time.Now().Add(w.maxWait)
114124
ticker := time.NewTicker(w.pollInterval)
115125
defer ticker.Stop()
116126

117127
for {
118-
info, err := w.provider.GetBatchStatus(ctx, id)
128+
// Bound this poll by the remaining maxWait budget (capped further
129+
// by the caller's own ctx deadline, if any). context.WithDeadline
130+
// automatically uses the earlier of the two deadlines, so a
131+
// caller-supplied deadline shorter than maxWait still wins.
132+
pollCtx, cancel := context.WithDeadline(ctx, deadline)
133+
info, err := w.provider.GetBatchStatus(pollCtx, id)
134+
cancel()
135+
119136
if err != nil {
137+
// The caller's own context expiring/cancelling takes priority
138+
// over our internally imposed deadline.
139+
if ctx.Err() != nil {
140+
return nil, ctx.Err()
141+
}
142+
// pollCtx timed out against our maxWait deadline (this is what
143+
// bounds a stuck/hanging GetBatchStatus call).
144+
if errors.Is(err, context.DeadlineExceeded) {
145+
return nil, ErrBatchTimeout
146+
}
120147
return nil, err
121148
}
122149

core/batch_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,33 @@ func (m *mockBatchProvider) ListBatches(_ context.Context, _ int) ([]BatchInfo,
116116
return nil, nil
117117
}
118118

119+
// stuckBatchProvider implements BatchProvider with a GetBatchStatus that
120+
// blocks until its context is cancelled, simulating a hung network call.
121+
// Used to verify that BatchWaiter.Wait bounds each poll by the remaining
122+
// maxWait budget instead of hanging indefinitely.
123+
type stuckBatchProvider struct{}
124+
125+
func (s *stuckBatchProvider) CreateBatch(_ context.Context, _ []BatchRequest) (BatchID, error) {
126+
return "batch_123", nil
127+
}
128+
129+
func (s *stuckBatchProvider) GetBatchStatus(ctx context.Context, _ BatchID) (*BatchInfo, error) {
130+
<-ctx.Done()
131+
return nil, ctx.Err()
132+
}
133+
134+
func (s *stuckBatchProvider) GetBatchResults(_ context.Context, _ BatchID) ([]BatchResult, error) {
135+
return nil, nil
136+
}
137+
138+
func (s *stuckBatchProvider) CancelBatch(_ context.Context, _ BatchID) error {
139+
return nil
140+
}
141+
142+
func (s *stuckBatchProvider) ListBatches(_ context.Context, _ int) ([]BatchInfo, error) {
143+
return nil, nil
144+
}
145+
119146
func TestAsBatchProvider(t *testing.T) {
120147
t.Run("supports batch", func(t *testing.T) {
121148
mock := &fullBatchProvider{}
@@ -246,6 +273,25 @@ func TestBatchWaiter(t *testing.T) {
246273
})
247274
}
248275

276+
func TestBatchWaiterDoesNotHangOnStuckPoll(t *testing.T) {
277+
p := &stuckBatchProvider{}
278+
w := NewBatchWaiter(p).
279+
WithPollInterval(10 * time.Millisecond).
280+
WithMaxWait(100 * time.Millisecond)
281+
282+
start := time.Now()
283+
_, err := w.Wait(context.Background(), "batch-1")
284+
if err == nil {
285+
t.Fatal("want timeout error")
286+
}
287+
if elapsed := time.Since(start); elapsed > 2*time.Second {
288+
t.Fatalf("Wait hung for %v; maxWait budget not enforced per-poll", elapsed)
289+
}
290+
if !errors.Is(err, ErrBatchTimeout) {
291+
t.Errorf("Wait() error = %v, want ErrBatchTimeout", err)
292+
}
293+
}
294+
249295
func TestBatchWaiterWaitAndCollect(t *testing.T) {
250296
t.Run("waits and collects results", func(t *testing.T) {
251297
mock := &mockBatchProvider{

core/client.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,18 +349,41 @@ func (b *ChatBuilder) ResponseJSON() *ChatBuilder {
349349
// This enables structured output mode where the model produces JSON conforming to the schema.
350350
// The schema parameter defines the structure the output must conform to.
351351
//
352+
// This always forces schema.Strict = true. Strict mode requires the schema to
353+
// set "additionalProperties": false and list every property in "required" at
354+
// every object node; validate() rejects non-compliant schemas with
355+
// ErrInvalidSchema before the request is sent. Use ResponseJSONSchemaNonStrict
356+
// to opt out of strict mode for schemas that cannot meet those constraints.
357+
//
352358
// Example:
353359
//
354360
// schema := &core.JSONSchemaDefinition{
355361
// Name: "person",
356-
// Strict: true,
357-
// Schema: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}`),
362+
// Schema: json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}`),
358363
// }
359364
// resp, err := client.Chat(model).
360365
// User("Extract: John is 30 years old").
361366
// ResponseJSONSchema(schema).
362367
// GetResponse(ctx)
363368
func (b *ChatBuilder) ResponseJSONSchema(schema *JSONSchemaDefinition) *ChatBuilder {
369+
if schema != nil {
370+
schema.Strict = true
371+
}
372+
b.req.ResponseFormat = ResponseFormatJSONSchema
373+
b.req.JSONSchema = schema
374+
return b
375+
}
376+
377+
// ResponseJSONSchemaNonStrict constrains the model output to match a specific
378+
// JSON Schema without enforcing strict mode. This forces schema.Strict = false,
379+
// skipping the strict-schema validation that ResponseJSONSchema applies. Use
380+
// this when a schema cannot satisfy strict mode's requirements (every object
381+
// node needs "additionalProperties": false and a "required" array covering
382+
// all declared properties) and the provider still accepts loose schemas.
383+
func (b *ChatBuilder) ResponseJSONSchemaNonStrict(schema *JSONSchemaDefinition) *ChatBuilder {
384+
if schema != nil {
385+
schema.Strict = false
386+
}
364387
b.req.ResponseFormat = ResponseFormatJSONSchema
365388
b.req.JSONSchema = schema
366389
return b
@@ -477,6 +500,51 @@ func (b *ChatBuilder) validate() error {
477500
}
478501
}
479502

503+
// Capability gate: reject schema-based structured output requests
504+
// (ResponseFormatJSONSchema) against a provider/model that does not
505+
// support core.FeatureStructuredOutput. This must run before the
506+
// strict-schema check below so an unsupported provider fails with
507+
// ErrStructuredOutputUnsupported rather than a schema validation error.
508+
//
509+
// Plain JSON mode (ResponseFormatJSON / json_object) is intentionally
510+
// NOT hard-gated here: it has no schema/shape contract to silently
511+
// violate, and many models support json_object without being tagged
512+
// with FeatureStructuredOutput (e.g. OpenAI's gpt-3.5-turbo, gpt-4, and
513+
// gpt-4-turbo). Gating it would reject requests that previously worked.
514+
// If a provider genuinely can't do json_object, its descriptive API
515+
// error surfaces instead.
516+
if b.req.ResponseFormat == ResponseFormatJSONSchema {
517+
if !b.client.provider.Supports(FeatureStructuredOutput) {
518+
return fmt.Errorf("%w: provider %s model %s",
519+
ErrStructuredOutputUnsupported, b.client.provider.ID(), b.req.Model)
520+
}
521+
522+
// Model-level gate: the provider supports structured output overall,
523+
// but the specific requested model may not (e.g. OpenAI supports it,
524+
// but gpt-3.5-turbo / gpt-4 do not). If the model is present in the
525+
// provider's catalog and lacks the capability, reject it. If the
526+
// model is NOT present in the catalog (unknown, brand-new, or a
527+
// custom deployment not yet reflected in the static catalog), fall
528+
// back to allowing it since the provider-level check already passed.
529+
for _, m := range b.client.provider.Models() {
530+
if m.ID == b.req.Model {
531+
if !m.HasCapability(FeatureStructuredOutput) {
532+
return fmt.Errorf("%w: provider %s model %s",
533+
ErrStructuredOutputUnsupported, b.client.provider.ID(), b.req.Model)
534+
}
535+
break
536+
}
537+
}
538+
}
539+
540+
// Strict structured output requires a schema shape the provider can
541+
// enforce exactly.
542+
if b.req.ResponseFormat == ResponseFormatJSONSchema && b.req.JSONSchema != nil && b.req.JSONSchema.Strict {
543+
if err := validateStrictSchema(b.req.JSONSchema.Schema); err != nil {
544+
return err
545+
}
546+
}
547+
480548
return nil
481549
}
482550

0 commit comments

Comments
 (0)