Skip to content

Commit 49cb382

Browse files
authored
refactor: unify the HTTP exception hierarchy and route errors through the factory (#100)
PR: #100
1 parent 26578ba commit 49cb382

20 files changed

Lines changed: 649 additions & 344 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,8 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
247247
|---|---|
248248
| `client` | `HttpClient`, `AsyncHttpClient` — the two transport SPIs (sync and async). |
249249
| `http.request` | `Request`, `RequestBody`, `FileRequestBody`, `LoggableRequestBody`, `Method`. |
250-
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), `HttpResponseException`, plus the raw-vs-parsed seam: `ResponseHandler<T>` (with dep-free `string()`/`empty()` handlers) and a lazy, parse-once `ParsedResponse<T>`. |
251-
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `retryable` derived from `RetryUtils.isRetryable`, plus `NetworkException` and `HttpExceptionFactory`. |
250+
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), plus the raw-vs-parsed seam: `ResponseHandler<T>` (with dep-free `string()`/`empty()` handlers) and a lazy, parse-once `ParsedResponse<T>`. |
251+
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `isRetryable` derived from `RetryUtils.isRetryable` and exposed via the `Retryable` interface, plus `NetworkException` and `HttpExceptionFactory`. |
252252
| `http.common` | `Headers`, `HttpHeaderName` (interned), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
253253
| `http.context` | `CallContext``DispatchContext``RequestContext``ExchangeContext` chain, `ContextStore`. |
254254
| `http.pipeline` | Sync (`HttpStep` / `HttpPipeline` / `HttpPipelineBuilder` / `PipelineNext` / `Stage`) and async (`AsyncHttpStep` / `AsyncHttpPipeline` / `AsyncHttpPipelineBuilder` / `AsyncPipelineNext`) pipeline machinery, plus `AsyncPipelineBridges`. |

docs/architecture.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -582,8 +582,8 @@ responds in a uniform way:
582582
subsequent blocking call also surfaces it.
583583
3. Throws `InterruptedIOException` (or the operation's natural failure exception with
584584
`InterruptedException` added as a suppressed cause).
585-
4. Classifies the interruption as **non-retryable**`HttpResponseException.isRetryable`
586-
returns `false` for an interrupt-driven failure.
585+
4. Classifies the interruption as **non-retryable**an interrupt-driven failure is not a
586+
`Retryable` condition, so the retry step never re-issues it.
587587

588588
Loops bounded by user input (retry attempts, paged iteration, server-sent-event
589589
consumption, drain loops in body logging) check `Thread.currentThread().isInterrupted` at
@@ -660,8 +660,8 @@ they should construct a fresh one.
660660
|----------------------|-------------------------------------------------------------------------------------------------|
661661
| `io` | Source, Sink, BufferedSource, BufferedSink, Buffer, IoProvider, Io, TeeSink (internal) |
662662
| `http.request` | Request, RequestBody, FileRequestBody, LoggableRequestBody, Method |
663-
| `http.response` | Response, ResponseBody, LoggableResponseBody, Status, HttpResponseException |
664-
| `http.response.exception` | HttpException, HttpExceptionFactory, RequestTimeoutException (and siblings), NetworkException |
663+
| `http.response` | Response, ResponseBody, LoggableResponseBody, Status |
664+
| `http.response.exception` | HttpException, HttpExceptionFactory, Retryable, RequestTimeoutException (and siblings), NetworkException |
665665
| `http.common` | Headers, MediaType, CommonMediaTypes, Protocol, ETag, HttpRange, RequestConditions |
666666
| `http.auth` | Credential, KeyCredential, BearerToken, ChallengeHandler, Basic/Digest/CompositeChallengeHandler, AuthChallengeParser |
667667
| `http.context` | CallContext, DispatchContext, RequestContext, ExchangeContext, ContextStore |

docs/http.md

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,9 @@ on whether a code is one the SDK recognizes.
286286
The SDK ships a typed exception hierarchy under `org.dexpace.sdk.core.http.response.exception`.
287287
The shape mirrors `gax`'s `ApiException` taxonomy translated to HTTP terms: one base class plus
288288
one concrete subclass per canonical status code. The base class derives its retryable flag
289-
from a single source of truth, so a downstream retry policy can read `exception.retryable`
290-
instead of maintaining a parallel predicate map.
289+
from a single source of truth and exposes it through the `Retryable` interface, so a downstream
290+
retry policy can read `(t as? Retryable)?.isRetryable` instead of maintaining a parallel
291+
predicate map or matching concrete exception types.
291292

292293
### HttpException Hierarchy
293294

@@ -302,16 +303,21 @@ abstract class HttpException(
302303
val body: ResponseBody?, // lazy — NOT eagerly buffered
303304
message: String? = null,
304305
cause: Throwable? = null,
305-
) : RuntimeException(message, cause) {
306-
val retryable: Boolean = RetryUtils.isRetryable(status.code)
306+
val value: Any? = null, // slot for a deserialized error payload (generated layer)
307+
) : RuntimeException(message, cause), Retryable {
308+
override val isRetryable: Boolean = RetryUtils.isRetryable(status.code)
307309
fun bodySnapshot(maxBytes: Int = DEFAULT_SNAPSHOT_BYTES): ByteArray?
308310
}
309311
```
310312

311-
`retryable` is a `val` **derived** at construction from `RetryUtils.isRetryable(status.code)`,
313+
`isRetryable` is a `val` **derived** at construction from `RetryUtils.isRetryable(status.code)`,
312314
not hardcoded per subclass and not a constructor parameter. This guarantees the baked flag
313315
can never disagree with the live retry policy: 408 / 429 and the 5xx range except 501 and 505
314-
are retryable, everything else is not.
316+
are retryable, everything else is not. It satisfies the `Retryable` interface (shared with
317+
`NetworkException`), which is what the retry step keys off.
318+
319+
`value` is a slot for a deserialized error payload, left `null` here — `sdk-core` does not
320+
parse bodies. The generated service layer populates it on a per-operation typed subclass.
315321

316322
`bodySnapshot()` returns a non-consuming preview of the body bytes — it reads from a fresh
317323
`source().peek()` view so the primary read path is undisturbed — capped at `maxBytes`
@@ -355,19 +361,21 @@ subclass: it extends `java.io.IOException` so existing `catch (IOException)` cal
355361
working, and it carries no status/headers/body because none arrived.
356362

357363
```kotlin
358-
open class NetworkException(message: String? = null, cause: Throwable? = null) : IOException(message, cause) {
359-
val retryable: Boolean = true // always retryable at the SDK level
364+
open class NetworkException(message: String? = null, cause: Throwable? = null) :
365+
IOException(message, cause), Retryable {
366+
override val isRetryable: Boolean = true // always retryable at the SDK level
360367
}
361368
```
362369

363-
The `retryable` flag is always `true`: nothing reached the server, so the SDK can safely
370+
The `isRetryable` flag is always `true`: nothing reached the server, so the SDK can safely
364371
attempt the request again. Whether the request itself is *safe* to retry (HTTP method
365372
idempotency, replayable body) is the retry policy's call, not this class's.
366373

367374
### HttpExceptionFactory
368375

369-
`HttpExceptionFactory.fromResponse(response)` maps a non-2xx `Response` to the right
370-
subclass:
376+
`HttpExceptionFactory.fromResponse(response)` maps a 4xx/5xx `Response` to the right subclass.
377+
It is the one seam that turns an error response into a typed exception, so the family is
378+
produced consistently rather than re-derived at each call site:
371379

372380
```kotlin
373381
val response = httpClient.execute(request)
@@ -379,6 +387,14 @@ if (!response.status.isSuccess) {
379387
The factory throws `IllegalArgumentException` if called with a status outside 400..599 —
380388
1xx/2xx/3xx outcomes are not exceptions and should not be funneled through this path.
381389

390+
For the recovery-aware pipeline, `ThrowOnHttpErrorStep` packages this mapping as a
391+
`ResponsePipelineStep`: drop it into a `ResponsePipeline.responseSteps` list and it calls
392+
`fromResponse` on a 4xx/5xx response and throws the result. `ResponsePipeline` converts that
393+
throw into a `ResponseOutcome.Failure`, which then flows through the recovery chain (e.g.
394+
`RetryStep`) exactly like a transport failure — and because the thrown `HttpException` is
395+
`Retryable`, retry classification keys off it uniformly. The step is a building block; no
396+
default pipeline in `sdk-core` assembles it for you.
397+
382398
---
383399

384400
## Common Types

docs/pipelines.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,11 +356,13 @@ public class RetryStep(
356356
)
357357
```
358358

359-
It retries only when the outcome is a `Failure` whose throwable is classified retryable:
359+
It retries only when the outcome is a `Failure` whose throwable is classified retryable. The
360+
classifier keys off the `Retryable` interface, not concrete exception types:
360361

361-
- An `HttpException` with `retryable == true` whose status code is in
362+
- An `HttpException` (which is `Retryable`) with `isRetryable == true` whose status code is in
362363
`RetrySettings.retryableStatuses`.
363-
- A `NetworkException` (a transport failure with no response on the wire — always retryable).
364+
- A `NetworkException` (also `Retryable`, always `true` — a transport failure with no response
365+
on the wire).
364366

365367
Idempotency is enforced independently of classification, keyed off whether the request carries
366368
a body. A request **with a body** is eligible only when its body is replayable (a non-replayable

docs/refs-comparison.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ below records where each scheme's design was sourced from:
223223
**What shipped, and what's left:**
224224

225225
1. `HttpException` is the base, with status-code-keyed subclasses (`NotFoundException`, `UnauthorizedException`, `TooManyRequestsException`, `InternalServerErrorException`, etc.) plus `ClientErrorException`/`ServerErrorException` fallbacks for unmapped 4xx/5xx. `NetworkException` is a sibling for transport failures. The set mirrors gax's taxonomy scaled to HTTP statuses.
226-
2. `retryable: Boolean` is a `val` derived once at construction from `RetryUtils.isRetryable(status.code)` — not a per-subclass constant. This is the single source of truth, so it can never disagree with the live retry policy (408 retryable; 501/505 not). A retry predicate is just `(t as? HttpException)?.retryable == true`.
226+
2. `isRetryable: Boolean` (from the `Retryable` interface) is a `val` derived once at construction from `RetryUtils.isRetryable(status.code)` — not a per-subclass constant. This is the single source of truth, so it can never disagree with the live retry policy (408 retryable; 501/505 not). `NetworkException` implements the same interface (always `true`), so a retry predicate keys off the interface: `(t as? Retryable)?.isRetryable == true`.
227227
3. The base exposes `status` + `headers` + a **lazy** `body: ResponseBody?` (not eagerly buffered), plus a non-consuming `bodySnapshot()` that reads from a `peek()` view so the primary read path is undisturbed — large 5xx bodies don't OOM. Per-operation per-status subclasses carrying typed bodies (Expedia pattern) are still codegen's job.
228228
4. Tolerant error-body parsing (Square's `SquareApiException.parseErrors`) remains future work — never throw inside an exception constructor; pass through the raw body on parse failure.
229229

0 commit comments

Comments
 (0)