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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationsdk-coresdk-core toolkit

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions