Skip to content

Add a runnable recovery-chain dispatch example and selection guidance for the two pipeline layers #225

Description

@OmarAlJarrah

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:

  1. no runnable, compiled worked example that dispatches through RecoveryChain.recover(request, context) over a real transport, and
  2. 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.

  1. 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())
  2. 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).

  3. 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())

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationsdk-coresdk-core toolkit

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions