Summary
sdk-core ships two cooperating dispatch layers (product-spec §8, docs/product-spec.md:293):
- the stage-based
http.pipeline layer, and
- the recovery-chain primitives in the
pipeline package: RecoveryChain (sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt:58) over RequestRecoveryChain (.../pipeline/RequestRecoveryChain.kt:33) and ResponseRecoveryChain (.../pipeline/ResponseRecoveryChain.kt:67), threading a sealed ResponseOutcome (.../pipeline/ResponseOutcome.kt), with the shipped steps RetryRecovery (.../pipeline/step/retry/RetryRecovery.kt:108), ThrowOnHttpErrorRecovery (.../pipeline/step/ThrowOnHttpErrorRecovery.kt:71), IdempotencyKeyStep (.../pipeline/step/IdempotencyKeyStep.kt:65), and ClientIdentityStep (.../pipeline/step/ClientIdentityStep.kt:53).
Only the stage-based layer has an on-ramp a consumer can follow end to end. It has a turnkey preset (HttpPipeline.standard(transport), sdk-core/.../http/pipeline/HttpPipeline.kt:119; HttpPipelineBuilder.appendStandardResilience(), HttpPipelineBuilder.kt:160), an async mirror (AsyncHttpPipeline.standard, AsyncHttpPipeline.kt:165), and the runnable samples in sdk-example — both of which assemble a stage pipeline and nothing else (sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt:139, via HttpPipelineBuilder; SimpleGetApp.kt:38, via HttpPipeline.of).
The recovery-chain layer is public, committed API surface (tracked by apiCheck), but it is instantiated nowhere outside its own pipeline package and its unit tests. A repo-wide search finds RecoveryChain / RequestRecoveryChain / ResponseRecoveryChain / RetryRecovery / IdempotencyKeyStep / ClientIdentityStep referenced only in their own definitions, in KDoc cross-references and code comments, and in sdk-core/src/test/.... (ExampleApp.kt:146 mentions IdempotencyKeyStep in a code comment only; it does not construct one.) A consumer therefore has:
- no runnable, compiled worked example that dispatches through
RecoveryChain.recover(request, context) over a real transport, and
- no consumer-facing rule for choosing this layer over the stage pipeline.
docs/pipelines.md does describe the recovery chain (sections at docs/pipelines.md:302, :264, :202, :155) and carries an "End-to-end execution with recovery" snippet (docs/pipelines.md:618), but that snippet references symbols it never defines (authStep, userAgentStep, decodingStep) — it is illustrative, not compiled or exercised. The only crisp "use layer A when X, layer B when Y" text lives in §8.3 of docs/product-spec.md:347, which is by its own title a Language-Agnostic Product and Porting Specification aimed at porters, not at consumers assembling a client.
This is explicitly not a request to remove the recovery-chain layer. §8.3 mandates keeping both (docs/product-spec.md:351: "A port MUST NOT collapse the two layers into one"). The gap is discoverability, so the fix is documentation plus a runnable example.
Rationale
The dexpace SDK is a platform: its consumers are external service clients, downstream SDKs, and application code assembled on top of these primitives. For such a consumer, a public API with no worked example and no selection guidance is effectively undiscoverable surface, even though the code is present and tested. The recovery-chain layer is not dead code that "nothing in the repo calls" — it exists precisely so a consumer can assemble a dispatch path with the guarantee the stage pipeline does not spell out: every failure, whether it originates in a pre-request step, the transport, or a response step, is observed through one code path as a ResponseOutcome, and a pre-transport throw can never bypass recovery (the RECOV-2 invariant, docs/product-spec.md:338). A consumer who needs uniform error mapping or rescue cannot know this layer is the right tool without a decision guide.
The two layers also differ in ways a consumer must know before choosing:
- The recovery-chain layer is synchronous only; async concerns are expressed through the stage-based async pipeline (§8.3,
docs/product-spec.md:351).
- The recovery-aware retry stack enforces an optional total-timeout budget that the stage-based retry step deliberately omits (RETRY-27 / RETRY-28,
docs/product-spec.md:379; noted at docs/pipelines.md:431).
RetryRecovery captures its httpClient, settings, and the request template at construction (RetryRecovery.kt:108), so a RetryRecovery — and therefore any ResponseRecoveryChain / RecoveryChain that contains one — is effectively per-request, unlike the shareable, request-agnostic stage steps. That is exactly the kind of assembly detail a consumer trips on with no worked example to copy.
Because this is core platform surface, the cost of the gap is paid by every consumer and every generated client independently; a worked example plus a short decision guide is a one-time fix that removes that repeated cost.
Proposed change
Documentation and example only. No public-API change (so no apiCheck / apiDump impact). The example lives in sdk-example (which already depends on a transport, serde, and an I/O adapter); sdk-core is untouched apart from docs, so its near-zero-runtime-dependency constraint is unaffected.
-
Ship a runnable, tested recovery-chain assembly. Add a new sample in sdk-example (a companion entry point alongside ExampleApp.kt and SimpleGetApp.kt) that dispatches via RecoveryChain.recover(request, context) over a real transport against an embedded MockWebServer, mirroring the deterministic, network-free style of the existing samples. It should wire, using the real APIs:
- a
RequestRecoveryChain with request steps, e.g. IdempotencyKeyStep and ClientIdentityStep (both RequestPipelineSteps);
- a
ResponseRecoveryChain with ThrowOnHttpErrorRecovery in responseSteps (it is a ResponsePipelineStep, so it runs on the success path and maps 4xx/5xx to a typed HttpException) and a RetryRecovery in recoverySteps (it is the ResponseRecoveryStep);
- the whole thing behind
RecoveryChain(httpClient = transport, requestChain = ..., responseChain = ...), dispatched with recover(request, DispatchContext.default()) (DispatchContext.default() exists at DispatchContext.kt:69).
The example should make the RetryRecovery per-request construction explicit (see Rationale), so a reader sees why the recovery chain is assembled the way it is. Cover it with a test in the same style as sdk-example/src/test/.../ExampleAppTest.kt so it stays compiled and green.
A rough shape (to be verified against the source when implemented):
val retry = RetryRecovery(httpClient = transport, settings = settings, request = request)
val chain = RecoveryChain(
httpClient = transport,
requestChain = RequestRecoveryChain(
steps = listOf(IdempotencyKeyStep.default(), ClientIdentityStep()),
),
responseChain = ResponseRecoveryChain(
responseSteps = listOf(ThrowOnHttpErrorRecovery),
recoverySteps = listOf(retry),
),
)
val response = chain.recover(request, DispatchContext.default())
-
Add a consumer-facing "choosing between the two layers" section. Lift the §8.3 guidance out of the porting spec into docs/pipelines.md (and a short pointer from README.md near the pipeline module row at README.md:417), phrased for a consumer rather than a porter. State the rule crisply: reach for the stage pipeline as the default composition surface for assembling a client (ordering of redirect/retry/auth/logging/serde pillars, per-call re-drive, nestable-as-a-transport, real async mirror); reach for the recovery chain when a concern must observe every outcome uniformly through one code path (error mapping, rescue, retry that must see transport and response failures identically and must never let a pre-transport throw bypass it). Include the three consumer-visible differences from the Rationale (sync-only, opt-in total-timeout budget, per-request RetryRecovery).
-
Fix or replace the illustrative snippet at docs/pipelines.md:618 so its symbols resolve, ideally by pointing at (or excerpting from) the new runnable example rather than leaving free-floating undefined identifiers.
Acceptance criteria
- A runnable sample exists that calls
RecoveryChain.recover(request, context) over a real transport and runs deterministically with no network access, wiring a RequestRecoveryChain, ThrowOnHttpErrorRecovery in responseSteps, and RetryRecovery in recoverySteps.
- The sample is covered by a test in the existing
sdk-example style, so it is compiled and exercised by the build.
docs/pipelines.md has a consumer-facing section that states when to use each layer and lists the sync-only, total-timeout-budget, and per-request-RetryRecovery differences; README.md points to it from the pipeline module entry.
- The end-to-end snippet in
docs/pipelines.md no longer references undefined symbols (it compiles as written or is replaced by a reference to the runnable example).
- No change to any public signature;
apiCheck stays green without an apiDump.
References
- Recovery-chain layer:
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt:58 (class; recover entry point at :79), RequestRecoveryChain.kt:33 (class; recover at :45), ResponseRecoveryChain.kt:67 (class; apply at :88; constructor responseSteps / recoverySteps params at :70 / :71), ResponseOutcome.kt
- Steps:
pipeline/step/retry/RetryRecovery.kt:108 (implements ResponseRecoveryStep at :115), pipeline/step/ThrowOnHttpErrorRecovery.kt:71 (a ResponsePipelineStep), pipeline/step/IdempotencyKeyStep.kt:65, pipeline/step/ClientIdentityStep.kt:53 (both RequestPipelineSteps)
- Step contracts:
pipeline/step/RequestPipelineStep.kt, ResponsePipelineStep.kt, ResponseRecoveryStep.kt
- Stage layer on-ramp (for contrast):
http/pipeline/HttpPipeline.kt:119 (standard), http/pipeline/HttpPipelineBuilder.kt:160 (appendStandardResilience), http/pipeline/AsyncHttpPipeline.kt:165
- Existing runnable samples (stage pipeline only):
sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt:139 (buildPipeline, via HttpPipelineBuilder) and SimpleGetApp.kt:38 (via HttpPipeline.of); tests at sdk-example/src/test/kotlin/org/dexpace/sdk/example/ExampleAppTest.kt and SimpleGetAppTest.kt
- Existing docs:
docs/pipelines.md:302 (RecoveryChain), :618 (illustrative end-to-end snippet), README.md:417 (pipeline module row)
- Normative spec:
docs/product-spec.md:293 (§8 two layers), :335 (§8.2 RECOV-1..16, incl. RECOV-2 at :338), :347 (§8.3 choosing between the layers), :379 (RETRY-27 / RETRY-28 total-timeout-budget difference)
- Context factory used by the example:
sdk-core/.../http/context/DispatchContext.kt:69 (DispatchContext.default())
Summary
sdk-coreships two cooperating dispatch layers (product-spec §8,docs/product-spec.md:293):http.pipelinelayer, andpipelinepackage:RecoveryChain(sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt:58) overRequestRecoveryChain(.../pipeline/RequestRecoveryChain.kt:33) andResponseRecoveryChain(.../pipeline/ResponseRecoveryChain.kt:67), threading a sealedResponseOutcome(.../pipeline/ResponseOutcome.kt), with the shipped stepsRetryRecovery(.../pipeline/step/retry/RetryRecovery.kt:108),ThrowOnHttpErrorRecovery(.../pipeline/step/ThrowOnHttpErrorRecovery.kt:71),IdempotencyKeyStep(.../pipeline/step/IdempotencyKeyStep.kt:65), andClientIdentityStep(.../pipeline/step/ClientIdentityStep.kt:53).Only the stage-based layer has an on-ramp a consumer can follow end to end. It has a turnkey preset (
HttpPipeline.standard(transport),sdk-core/.../http/pipeline/HttpPipeline.kt:119;HttpPipelineBuilder.appendStandardResilience(),HttpPipelineBuilder.kt:160), an async mirror (AsyncHttpPipeline.standard,AsyncHttpPipeline.kt:165), and the runnable samples insdk-example— both of which assemble a stage pipeline and nothing else (sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt:139, viaHttpPipelineBuilder;SimpleGetApp.kt:38, viaHttpPipeline.of).The recovery-chain layer is public, committed API surface (tracked by
apiCheck), but it is instantiated nowhere outside its ownpipelinepackage and its unit tests. A repo-wide search findsRecoveryChain/RequestRecoveryChain/ResponseRecoveryChain/RetryRecovery/IdempotencyKeyStep/ClientIdentityStepreferenced only in their own definitions, in KDoc cross-references and code comments, and insdk-core/src/test/.... (ExampleApp.kt:146mentionsIdempotencyKeyStepin a code comment only; it does not construct one.) A consumer therefore has:RecoveryChain.recover(request, context)over a real transport, anddocs/pipelines.mddoes describe the recovery chain (sections atdocs/pipelines.md:302,:264,:202,:155) and carries an "End-to-end execution with recovery" snippet (docs/pipelines.md:618), but that snippet references symbols it never defines (authStep,userAgentStep,decodingStep) — it is illustrative, not compiled or exercised. The only crisp "use layer A when X, layer B when Y" text lives in §8.3 ofdocs/product-spec.md:347, which is by its own title a Language-Agnostic Product and Porting Specification aimed at porters, not at consumers assembling a client.This is explicitly not a request to remove the recovery-chain layer. §8.3 mandates keeping both (
docs/product-spec.md:351: "A port MUST NOT collapse the two layers into one"). The gap is discoverability, so the fix is documentation plus a runnable example.Rationale
The dexpace SDK is a platform: its consumers are external service clients, downstream SDKs, and application code assembled on top of these primitives. For such a consumer, a public API with no worked example and no selection guidance is effectively undiscoverable surface, even though the code is present and tested. The recovery-chain layer is not dead code that "nothing in the repo calls" — it exists precisely so a consumer can assemble a dispatch path with the guarantee the stage pipeline does not spell out: every failure, whether it originates in a pre-request step, the transport, or a response step, is observed through one code path as a
ResponseOutcome, and a pre-transport throw can never bypass recovery (the RECOV-2 invariant,docs/product-spec.md:338). A consumer who needs uniform error mapping or rescue cannot know this layer is the right tool without a decision guide.The two layers also differ in ways a consumer must know before choosing:
docs/product-spec.md:351).docs/product-spec.md:379; noted atdocs/pipelines.md:431).RetryRecoverycaptures itshttpClient,settings, and therequesttemplate at construction (RetryRecovery.kt:108), so aRetryRecovery— and therefore anyResponseRecoveryChain/RecoveryChainthat contains one — is effectively per-request, unlike the shareable, request-agnostic stage steps. That is exactly the kind of assembly detail a consumer trips on with no worked example to copy.Because this is core platform surface, the cost of the gap is paid by every consumer and every generated client independently; a worked example plus a short decision guide is a one-time fix that removes that repeated cost.
Proposed change
Documentation and example only. No public-API change (so no
apiCheck/apiDumpimpact). The example lives insdk-example(which already depends on a transport, serde, and an I/O adapter);sdk-coreis untouched apart from docs, so its near-zero-runtime-dependency constraint is unaffected.Ship a runnable, tested recovery-chain assembly. Add a new sample in
sdk-example(a companion entry point alongsideExampleApp.ktandSimpleGetApp.kt) that dispatches viaRecoveryChain.recover(request, context)over a real transport against an embeddedMockWebServer, mirroring the deterministic, network-free style of the existing samples. It should wire, using the real APIs:RequestRecoveryChainwith request steps, e.g.IdempotencyKeyStepandClientIdentityStep(bothRequestPipelineSteps);ResponseRecoveryChainwithThrowOnHttpErrorRecoveryinresponseSteps(it is aResponsePipelineStep, so it runs on the success path and maps 4xx/5xx to a typedHttpException) and aRetryRecoveryinrecoverySteps(it is theResponseRecoveryStep);RecoveryChain(httpClient = transport, requestChain = ..., responseChain = ...), dispatched withrecover(request, DispatchContext.default())(DispatchContext.default()exists atDispatchContext.kt:69).The example should make the
RetryRecoveryper-request construction explicit (see Rationale), so a reader sees why the recovery chain is assembled the way it is. Cover it with a test in the same style assdk-example/src/test/.../ExampleAppTest.ktso it stays compiled and green.A rough shape (to be verified against the source when implemented):
Add a consumer-facing "choosing between the two layers" section. Lift the §8.3 guidance out of the porting spec into
docs/pipelines.md(and a short pointer fromREADME.mdnear thepipelinemodule row atREADME.md:417), phrased for a consumer rather than a porter. State the rule crisply: reach for the stage pipeline as the default composition surface for assembling a client (ordering of redirect/retry/auth/logging/serde pillars, per-call re-drive, nestable-as-a-transport, real async mirror); reach for the recovery chain when a concern must observe every outcome uniformly through one code path (error mapping, rescue, retry that must see transport and response failures identically and must never let a pre-transport throw bypass it). Include the three consumer-visible differences from the Rationale (sync-only, opt-in total-timeout budget, per-requestRetryRecovery).Fix or replace the illustrative snippet at
docs/pipelines.md:618so its symbols resolve, ideally by pointing at (or excerpting from) the new runnable example rather than leaving free-floating undefined identifiers.Acceptance criteria
RecoveryChain.recover(request, context)over a real transport and runs deterministically with no network access, wiring aRequestRecoveryChain,ThrowOnHttpErrorRecoveryinresponseSteps, andRetryRecoveryinrecoverySteps.sdk-examplestyle, so it is compiled and exercised by the build.docs/pipelines.mdhas a consumer-facing section that states when to use each layer and lists the sync-only, total-timeout-budget, and per-request-RetryRecoverydifferences;README.mdpoints to it from thepipelinemodule entry.docs/pipelines.mdno longer references undefined symbols (it compiles as written or is replaced by a reference to the runnable example).apiCheckstays green without anapiDump.References
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt:58(class;recoverentry point at:79),RequestRecoveryChain.kt:33(class;recoverat:45),ResponseRecoveryChain.kt:67(class;applyat:88; constructorresponseSteps/recoveryStepsparams at:70/:71),ResponseOutcome.ktpipeline/step/retry/RetryRecovery.kt:108(implementsResponseRecoveryStepat:115),pipeline/step/ThrowOnHttpErrorRecovery.kt:71(aResponsePipelineStep),pipeline/step/IdempotencyKeyStep.kt:65,pipeline/step/ClientIdentityStep.kt:53(bothRequestPipelineSteps)pipeline/step/RequestPipelineStep.kt,ResponsePipelineStep.kt,ResponseRecoveryStep.kthttp/pipeline/HttpPipeline.kt:119(standard),http/pipeline/HttpPipelineBuilder.kt:160(appendStandardResilience),http/pipeline/AsyncHttpPipeline.kt:165sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt:139(buildPipeline, viaHttpPipelineBuilder) andSimpleGetApp.kt:38(viaHttpPipeline.of); tests atsdk-example/src/test/kotlin/org/dexpace/sdk/example/ExampleAppTest.ktandSimpleGetAppTest.ktdocs/pipelines.md:302(RecoveryChain),:618(illustrative end-to-end snippet),README.md:417(pipelinemodule row)docs/product-spec.md:293(§8 two layers),:335(§8.2 RECOV-1..16, incl. RECOV-2 at:338),:347(§8.3 choosing between the layers),:379(RETRY-27 / RETRY-28 total-timeout-budget difference)sdk-core/.../http/context/DispatchContext.kt:69(DispatchContext.default())