Skip to content

Commit 47807b3

Browse files
konardclaude
andcommitted
feat(app): implement Effect-native error handling for HTTP errors
BREAKING CHANGE: HTTP errors (4xx, 5xx) now go to the error channel instead of success channel, forcing explicit handling. Before: const result = yield* client.GET("/pets/{id}", dispatcher) // result is ApiResponse<200 | 404 | 500> - must check status manually After: const result = yield* client.GET("/pets/{id}", dispatcher) // result is ApiSuccess<200> - only success statuses // 404, 500 go to error channel, handled via Effect.catchTag Key changes: - ApiSuccess<Responses>: 2xx responses only (success channel) - ApiFailure<Responses> = HttpError<Responses> | BoundaryError (error channel) - HttpErrorResponseVariant: adds _tag: "HttpError" for discrimination - Dispatchers use Effect.fail for non-2xx statuses - Updated tests to verify non-2xx goes to isLeft (error channel) - Updated examples to show Effect.catchTag error handling This design forces developers to explicitly handle HTTP errors, following Effect-TS best practices for typed error handling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 34e4f42 commit 47807b3

9 files changed

Lines changed: 374 additions & 203 deletions

File tree

packages/app/examples/test-create-client.ts

Lines changed: 113 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
1-
// CHANGE: Example script demonstrating createClient API usage with automatic type inference
2-
// WHY: Verify simplified API works as requested by reviewer without explicit type annotations
3-
// QUOTE(TZ): "А почему он заставляет явно описать тип? apiClient.GET и так должен вернуть тип"
4-
// REF: PR#3 comment from skulidropek
1+
// CHANGE: Example script demonstrating Effect-native error handling with createClient
2+
// WHY: Show how to handle HTTP errors (404, 500) via Effect error channel
3+
// QUOTE(TZ): "Мы не заставляем обрабатывать потенциальные исключения... Должно быть типо результат который принимается и потециальные исключения которые надо обработать"
4+
// REF: PR#3 comment from skulidropek about Effect representation
55
// SOURCE: n/a
66
// PURITY: SHELL
7-
// EFFECT: Demonstrates Effect-based API calls with automatic type inference
7+
// EFFECT: Demonstrates Effect-based API calls with forced error handling
88

99
import * as FetchHttpClient from "@effect/platform/FetchHttpClient"
10-
import { Console, Effect, Exit } from "effect"
10+
import { Console, Effect, Exit, Match } from "effect"
1111
import { createClient, type ClientOptions } from "../src/shell/api-client/create-client.js"
1212
import { dispatchercreatePet, dispatchergetPet, dispatcherlistPets } from "../src/generated/dispatch.js"
1313
import type { Paths } from "../tests/fixtures/petstore.openapi.js"
14-
15-
// Helper type for Error schema body
16-
type ErrorBody = { readonly code: number; readonly message: string }
17-
18-
// Helper to check if body is an Error schema response
19-
const isErrorBody = (body: unknown): body is ErrorBody =>
20-
typeof body === "object" &&
21-
body !== null &&
22-
"message" in body &&
23-
typeof (body as ErrorBody).message === "string"
14+
// Types are automatically inferred - no need to import them explicitly
2415

2516
/**
2617
* Example: Create API client with simplified API
@@ -36,18 +27,21 @@ const clientOptions: ClientOptions = {
3627
const apiClient = createClient<Paths>(clientOptions)
3728

3829
/**
39-
* Example program: List all pets
30+
* Example program: List all pets with Effect-native error handling
31+
*
32+
* NEW DESIGN:
33+
* - Success channel (yield*): Only 2xx responses
34+
* - Error channel (catchTag/catchAll): HTTP errors (500) + boundary errors
4035
*
41-
* NOTE: Types are now automatically inferred from the dispatcher!
42-
* No explicit type annotation needed on the result variable.
36+
* This FORCES developers to handle HTTP errors explicitly!
4337
*
4438
* @pure false - performs HTTP request
4539
*/
4640
const listAllPetsExample = Effect.gen(function*() {
4741
yield* Console.log("=== Example 1: List all pets ===")
4842

4943
// Execute request - type is automatically inferred from dispatcherlistPets
50-
// No need for explicit type annotation!
44+
// Now: success = 200 only, error = 500 | BoundaryError
5145
const result = yield* apiClient.GET(
5246
"/pets",
5347
dispatcherlistPets,
@@ -56,27 +50,36 @@ const listAllPetsExample = Effect.gen(function*() {
5650
}
5751
)
5852

59-
// Pattern match on the response - TypeScript knows the possible statuses
60-
if (result.status === 200) {
61-
const pets = result.body as Array<{ id: string; name: string; tag?: string }>
62-
yield* Console.log(`Success: Got ${pets.length} pets`)
53+
// Success! We only get here if status was 200
54+
// No need to check status - TypeScript knows it's 200
55+
const pets = result.body
56+
yield* Console.log(`Success: Got ${pets.length} pets`)
57+
if (pets.length > 0) {
6358
yield* Console.log(` First pet: ${JSON.stringify(pets[0], null, 2)}`)
64-
} else if (result.status === 500 && isErrorBody(result.body)) {
65-
yield* Console.log(`Server error: ${result.body.message}`)
6659
}
67-
})
60+
}).pipe(
61+
// HTTP errors (500) now require explicit handling!
62+
Effect.catchTag("HttpError", (error) =>
63+
Console.log(`Server error (500): ${JSON.stringify(error.body)}`)),
64+
// Boundary errors are also in error channel
65+
Effect.catchTag("TransportError", (error) =>
66+
Console.log(`Transport error: ${error.error.message}`)),
67+
Effect.catchTag("UnexpectedStatus", (error) =>
68+
Console.log(`Unexpected status: ${error.status}`))
69+
)
6870

6971
/**
7072
* Example program: Get specific pet
7173
*
72-
* Demonstrates path parameters with automatic type inference.
74+
* Demonstrates handling multiple HTTP error statuses (404, 500).
7375
*
7476
* @pure false - performs HTTP request
7577
*/
7678
const getPetExample = Effect.gen(function*() {
7779
yield* Console.log("\n=== Example 2: Get specific pet ===")
7880

79-
// Type is inferred from dispatchergetPet - no annotation needed!
81+
// Type is inferred from dispatchergetPet
82+
// Success = 200, Error = 404 | 500 | BoundaryError
8083
const result = yield* apiClient.GET(
8184
"/pets/{petId}",
8285
dispatchergetPet,
@@ -85,21 +88,25 @@ const getPetExample = Effect.gen(function*() {
8588
}
8689
)
8790

88-
if (result.status === 200) {
89-
const pet = result.body as { id: string; name: string; tag?: string }
90-
yield* Console.log(`Success: Got pet "${pet.name}"`)
91-
yield* Console.log(` Tag: ${pet.tag ?? "none"}`)
92-
} else if (result.status === 404 && isErrorBody(result.body)) {
93-
yield* Console.log(`Not found: ${result.body.message}`)
94-
} else if (result.status === 500 && isErrorBody(result.body)) {
95-
yield* Console.log(`Server error: ${result.body.message}`)
96-
}
97-
})
91+
// Success! Status is guaranteed to be 200
92+
yield* Console.log(`Success: Got pet "${result.body.name}"`)
93+
yield* Console.log(` Tag: ${result.body.tag ?? "none"}`)
94+
}).pipe(
95+
// Handle HTTP errors using Match for exhaustive pattern matching
96+
Effect.catchTag("HttpError", (error) =>
97+
Match.value(error.status).pipe(
98+
Match.when(404, () => Console.log(`Not found: ${JSON.stringify(error.body)}`)),
99+
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
100+
Match.orElse(() => Console.log(`Unexpected HTTP error: ${error.status}`))
101+
)),
102+
Effect.catchTag("TransportError", (error) =>
103+
Console.log(`Transport error: ${error.error.message}`))
104+
)
98105

99106
/**
100107
* Example program: Create new pet
101108
*
102-
* Demonstrates POST requests with body.
109+
* Demonstrates handling validation errors (400).
103110
*
104111
* @pure false - performs HTTP request
105112
*/
@@ -111,7 +118,8 @@ const createPetExample = Effect.gen(function*() {
111118
tag: "cat"
112119
}
113120

114-
// Type is inferred from dispatchercreatePet - no annotation needed!
121+
// Type is inferred from dispatchercreatePet
122+
// Success = 201, Error = 400 | 500 | BoundaryError
115123
const result = yield* apiClient.POST(
116124
"/pets",
117125
dispatchercreatePet,
@@ -121,58 +129,55 @@ const createPetExample = Effect.gen(function*() {
121129
}
122130
)
123131

124-
if (result.status === 201) {
125-
const pet = result.body as { id: string; name: string; tag?: string }
126-
yield* Console.log(`Success: Created pet with ID ${pet.id}`)
127-
yield* Console.log(` Name: ${pet.name}`)
128-
} else if (result.status === 400 && isErrorBody(result.body)) {
129-
yield* Console.log(`Validation error: ${result.body.message}`)
130-
} else if (result.status === 500 && isErrorBody(result.body)) {
131-
yield* Console.log(`Server error: ${result.body.message}`)
132-
}
133-
})
132+
// Success! Status is guaranteed to be 201
133+
yield* Console.log(`Success: Created pet with ID ${result.body.id}`)
134+
yield* Console.log(` Name: ${result.body.name}`)
135+
}).pipe(
136+
// Handle HTTP errors - FORCED by TypeScript!
137+
Effect.catchTag("HttpError", (error) =>
138+
Match.value(error.status).pipe(
139+
Match.when(400, () => Console.log(`Validation error: ${JSON.stringify(error.body)}`)),
140+
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
141+
Match.orElse(() => Console.log(`Unexpected HTTP error: ${error.status}`))
142+
)),
143+
Effect.catchTag("TransportError", (error) =>
144+
Console.log(`Transport error: ${error.error.message}`))
145+
)
134146

135147
/**
136-
* Example program: Handle transport error
148+
* Example program: Using Effect.either for conditional error handling
137149
*
138-
* Demonstrates error handling with Effect.either.
150+
* Demonstrates how to access both success and error in one place.
139151
*
140152
* @pure false - performs HTTP request
141153
*/
142-
const errorHandlingExample = Effect.gen(function*() {
143-
yield* Console.log("\n=== Example 4: Error handling ===")
144-
145-
// Create client with invalid URL to trigger transport error
146-
const invalidClient = createClient<Paths>({
147-
baseUrl: "http://invalid.localhost:99999",
148-
credentials: "include"
149-
})
154+
const eitherExample = Effect.gen(function*() {
155+
yield* Console.log("\n=== Example 4: Using Effect.either ===")
150156

151157
const result = yield* Effect.either(
152-
invalidClient.GET("/pets", dispatcherlistPets)
158+
apiClient.GET("/pets/{petId}", dispatchergetPet, {
159+
params: { petId: "999" } // Non-existent pet
160+
})
153161
)
154162

155-
if (result._tag === "Left") {
163+
if (result._tag === "Right") {
164+
// Success - got the pet
165+
yield* Console.log(`Found pet: ${result.right.body.name}`)
166+
} else {
167+
// Error - check the type
156168
const error = result.left
157-
if (error._tag === "TransportError") {
158-
yield* Console.log(`Transport error caught: ${error.error.message}`)
159-
} else if (error._tag === "UnexpectedStatus") {
160-
yield* Console.log(`Unexpected status: ${error.status}`)
161-
} else if (error._tag === "ParseError") {
162-
yield* Console.log(`Parse error: ${error.error.message}`)
163-
} else {
164-
yield* Console.log(`Other error: ${error._tag}`)
169+
if ("_tag" in error) {
170+
if (error._tag === "HttpError") {
171+
// HTTP error from schema (404 or 500)
172+
yield* Console.log(`HTTP error ${error.status}: ${JSON.stringify(error.body)}`)
173+
} else {
174+
// Boundary error (TransportError, UnexpectedStatus, etc.)
175+
yield* Console.log(`Boundary error: ${error._tag}`)
176+
}
165177
}
166-
} else {
167-
yield* Console.log("Expected error but got success")
168178
}
169179
})
170180

171-
/**
172-
* Helper type for ApiFailure errors
173-
*/
174-
type ApiError = { readonly _tag: string }
175-
176181
/**
177182
* Main program - runs all examples
178183
*
@@ -181,38 +186,51 @@ type ApiError = { readonly _tag: string }
181186
const mainProgram = Effect.gen(function*() {
182187
yield* Console.log("========================================")
183188
yield* Console.log(" OpenAPI Effect Client - Examples")
189+
yield* Console.log(" Effect-Native Error Handling")
184190
yield* Console.log("========================================\n")
185191

186-
yield* Console.log("Demonstrating simplified API with automatic type inference:")
187-
yield* Console.log(' import createClient from "openapi-effect"')
188-
yield* Console.log(" const client = createClient<Paths>({ ... })")
189-
yield* Console.log(" const result = yield* client.GET(\"/path\", dispatcher)")
190-
yield* Console.log(" // result is automatically typed!\n")
192+
yield* Console.log("NEW DESIGN:")
193+
yield* Console.log(" - Success channel: 2xx responses only")
194+
yield* Console.log(" - Error channel: HTTP errors (4xx, 5xx) + boundary errors")
195+
yield* Console.log(" - Developers MUST handle HTTP errors explicitly!\n")
196+
197+
yield* Console.log("Example code:")
198+
yield* Console.log(' const result = yield* client.GET("/path", dispatcher)')
199+
yield* Console.log(" // result is 200 - no need to check status!")
200+
yield* Console.log("").pipe(Effect.flatMap(() =>
201+
Console.log(" // HTTP errors handled via Effect.catchTag or Effect.match\n")
202+
))
191203

192204
// Note: These examples will fail with transport errors since
193205
// we're not connecting to a real server. This is intentional
194206
// to demonstrate error handling.
195207

196-
yield* Effect.catchAll(listAllPetsExample, (error: ApiError) =>
197-
Console.log(`Transport error (expected): ${error._tag === "TransportError" ? "Cannot connect to example server" : error._tag}`)
208+
yield* listAllPetsExample.pipe(
209+
Effect.catchAll((error) =>
210+
Console.log(`Unhandled error in listAllPets: ${JSON.stringify(error)}`))
198211
)
199212

200-
yield* Effect.catchAll(getPetExample, (error: ApiError) =>
201-
Console.log(`Transport error (expected): ${error._tag === "TransportError" ? "Cannot connect to example server" : error._tag}`)
213+
yield* getPetExample.pipe(
214+
Effect.catchAll((error) =>
215+
Console.log(`Unhandled error in getPet: ${JSON.stringify(error)}`))
202216
)
203217

204-
yield* Effect.catchAll(createPetExample, (error: ApiError) =>
205-
Console.log(`Transport error (expected): ${error._tag === "TransportError" ? "Cannot connect to example server" : error._tag}`)
218+
yield* createPetExample.pipe(
219+
Effect.catchAll((error) =>
220+
Console.log(`Unhandled error in createPet: ${JSON.stringify(error)}`))
206221
)
207222

208-
yield* errorHandlingExample
223+
yield* eitherExample.pipe(
224+
Effect.catchAll((error) =>
225+
Console.log(`Unhandled error in either example: ${JSON.stringify(error)}`))
226+
)
209227

210228
yield* Console.log("\nAll examples completed!")
211-
yield* Console.log("\nType safety verification:")
212-
yield* Console.log(" - Response types automatically inferred from dispatcher")
213-
yield* Console.log(" - No explicit type annotations required")
214-
yield* Console.log(" - All paths type-checked against OpenAPI schema")
215-
yield* Console.log(" - All errors explicit in Effect type")
229+
yield* Console.log("\nKey benefits of Effect-native error handling:")
230+
yield* Console.log(" - HTTP errors (404, 500) FORCE explicit handling")
231+
yield* Console.log(" - No accidental ignoring of error responses")
232+
yield* Console.log(" - Type-safe discrimination via _tag and status")
233+
yield* Console.log(" - Exhaustive pattern matching with Match.exhaustive")
216234
})
217235

218236
/**

packages/app/src/core/api-client/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
// CHANGE: Main entry point for api-client core module
2-
// WHY: Export public API with clear separation of concerns
3-
// QUOTE(ТЗ): "Публичный API должен иметь вид: strictClient.GET(path, options): Effect<ApiResponse<Op>, BoundaryError, never>"
4-
// REF: issue-2, section 6
1+
// CHANGE: Main entry point for api-client core module with Effect-native error handling
2+
// WHY: Export public API with proper separation: 2xx → success, non-2xx → error channel
3+
// QUOTE(ТЗ): "Публичный API должен иметь вид: strictClient.GET(path, options): Effect<ApiSuccess<Op>, ApiFailure<Op>, R>"
4+
// REF: issue-2, section 6, PR#3 comment about Effect representation
55
// SOURCE: n/a
66
// PURITY: CORE (re-exports)
77
// COMPLEXITY: O(1)
88

99
// Core types (compile-time)
1010
export type {
11-
ApiResponse,
11+
ApiFailure,
1212
ApiSuccess,
1313
BodyFor,
1414
BoundaryError,
1515
ContentTypesFor,
1616
DecodeError,
17+
HttpError,
18+
HttpErrorResponseVariant,
1719
HttpErrorVariants,
1820
OperationFor,
1921
ParseError,

0 commit comments

Comments
 (0)