Skip to content

Add pipeline and fan-out-merge composite route strategies#192

Open
Copilot wants to merge 4 commits intomainfrom
copilot/add-composite-route-support
Open

Add pipeline and fan-out-merge composite route strategies#192
Copilot wants to merge 4 commits intomainfrom
copilot/add-composite-route-support

Conversation

Copy link

Copilot AI commented Mar 6, 2026

Composite routes lacked support for inter-backend data dependencies — e.g., fetching conversation IDs from backend A, then querying backend B with those IDs for follow-up details. Also no way to correlate parallel responses by ID across backends.

New Strategies

  • pipeline — Sequential execution where each stage's response constructs the next stage's request via PipelineRequestBuilder. Optional PipelineResponseMerger assembles the final response.
  • fan-out-merge — Parallel execution (like merge) but with a custom FanOutMerger function for ID-based matching, filtering, and correlation across responses.

Empty Response Policies

Configurable per-route via config (empty_policy) or SetEmptyResponsePolicy():

Policy Behavior
allow-empty Include empty responses (default)
skip-empty Drop empty responses silently
fail-on-empty 502 if any backend returns empty

Usage

// Pipeline: chain backend A's IDs into backend B's request
proxyModule.SetPipelineConfig("/api/conversations", reverseproxy.PipelineConfig{
    RequestBuilder: func(ctx context.Context, orig *http.Request,
        prev map[string][]byte, nextBackend string) (*http.Request, error) {
        var resp struct{ Conversations []struct{ ID string `json:"id"` } `json:"conversations"` }
        json.Unmarshal(prev["conversations-backend"], &resp)
        ids := /* extract IDs */
        return http.NewRequestWithContext(ctx, "GET", followupURL+"?ids="+strings.Join(ids, ","), nil)
    },
    ResponseMerger: func(ctx context.Context, orig *http.Request,
        all map[string][]byte) (*http.Response, error) {
        // merge follow-up data into conversations
        return reverseproxy.MakeJSONResponse(http.StatusOK, merged)
    },
})

// Fan-out-merge: parallel requests, correlate by ID
proxyModule.SetFanOutMerger("/api/tickets", func(ctx context.Context,
    orig *http.Request, responses map[string][]byte) (*http.Response, error) {
    // match tickets to assignments by ticket ID
    return reverseproxy.MakeJSONResponse(http.StatusOK, enrichedTickets)
})

Changes

  • composite_pipeline.go — New types (PipelineConfig, PipelineRequestBuilder, PipelineResponseMerger, FanOutMerger, EmptyResponsePolicy), execution logic, MakeJSONResponse helper
  • composite.go — Extended CompositeHandler with pipeline/fan-out-merge fields, ServeHTTP dispatch, setter methods, config wiring in createCompositeHandler
  • module.go — Storage maps and SetPipelineConfig/SetFanOutMerger/SetEmptyResponsePolicy on the module
  • config.goEmptyPolicy field on CompositeRoute
  • Tests — 28 unit tests (composite_pipeline_test.go) + 5 BDD scenarios (features/composite_pipeline.feature)
  • Example — Updated examples/reverse-proxy/ with pipeline and fan-out-merge backends, config, and registrations
  • README — Documented all 5 composite strategies with config and code examples

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • tenant-secondary
    • Triggering command: /tmp/go-build1261898992/b001/reverseproxy.test /tmp/go-build1261898992/b001/reverseproxy.test -test.paniconexit0 -test.v=true -test.count=1 -test.timeout=5m0s 7362309/b001/reverseproxy.test --log-level e/git --log-target journal-or-kmsg (dns block)

If you need me to access, download, or install something from one of these locations, you can either:


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 3 commits March 6, 2026 21:41
…ests

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
…ategies

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for mapping responses from multiple backends Add pipeline and fan-out-merge composite route strategies Mar 6, 2026
@intel352 intel352 marked this pull request as ready for review March 7, 2026 06:53
Copilot AI review requested due to automatic review settings March 7, 2026 06:53
@github-actions
Copy link

github-actions bot commented Mar 7, 2026

📋 API Contract Changes Summary

No breaking changes detected - only additions and non-breaking modifications

Changed Components:

Core Framework

Contract diff saved to artifacts/diffs/core.json

Module: auth

Contract diff saved to artifacts/diffs/auth.json

Module: cache

Contract diff saved to artifacts/diffs/cache.json

Module: chimux

Contract diff saved to artifacts/diffs/chimux.json

Module: database

Contract diff saved to artifacts/diffs/database.json

Module: eventbus

Contract diff saved to artifacts/diffs/eventbus.json

Module: eventlogger

Contract diff saved to artifacts/diffs/eventlogger.json

Module: httpclient

Contract diff saved to artifacts/diffs/httpclient.json

Module: httpserver

Contract diff saved to artifacts/diffs/httpserver.json

Module: jsonschema

Contract diff saved to artifacts/diffs/jsonschema.json

Module: letsencrypt

Contract diff saved to artifacts/diffs/letsencrypt.json

Module: logmasker

Contract diff saved to artifacts/diffs/logmasker.json

Module: reverseproxy

Contract diff saved to artifacts/diffs/reverseproxy.json

Module: scheduler

Contract diff saved to artifacts/diffs/scheduler.json

Artifacts

📁 Full contract diffs and JSON artifacts are available in the workflow artifacts.

@codecov
Copy link

codecov bot commented Mar 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.73%. Comparing base (d84294f) to head (ec268bd).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #192      +/-   ##
==========================================
+ Coverage   56.66%   58.73%   +2.07%     
==========================================
  Files          82       82              
  Lines       17660    17680      +20     
==========================================
+ Hits        10007    10385     +378     
+ Misses       6551     6231     -320     
+ Partials     1102     1064      -38     
Flag Coverage Δ
bdd-auth 70.97% <ø> (ø)
bdd-cache 67.15% <ø> (ø)
bdd-chimux 73.80% <ø> (ø)
bdd-database 51.85% <ø> (ø)
bdd-eventbus 69.91% <ø> (ø)
bdd-eventlogger 61.59% <ø> (ø)
bdd-httpclient 42.50% <ø> (ø)
bdd-httpserver 62.29% <ø> (ø)
bdd-jsonschema 77.31% <ø> (ø)
bdd-letsencrypt 19.70% <ø> (ø)
bdd-logmasker 36.02% <ø> (ø)
bdd-reverseproxy 54.71% <ø> (+0.02%) ⬆️
bdd-scheduler 60.97% <ø> (-0.39%) ⬇️
cli 47.27% <ø> (ø)
core-bdd 17.70% <ø> (ø)
merged-bdd 46.06% <ø> (+<0.01%) ⬆️
reverseproxy 66.63% <ø> (?)
total 57.11% <ø> (ø)
unit 64.16% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds two new composite route strategies to the modules/reverseproxy module to support dependent multi-backend workflows (sequential request chaining) and ID-correlated parallel response merging, plus configurable “empty backend response” handling.

Changes:

  • Introduces pipeline and fan-out-merge composite strategies with new config/types and execution logic.
  • Adds per-route empty-response policy support via config (empty_policy) and programmatic setters.
  • Adds unit tests + BDD scenarios, and updates the reverse-proxy example and README to document/illustrate usage.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
modules/reverseproxy/module.go Stores per-route pipeline configs, fan-out mergers, and empty-response policies; adds setter APIs.
modules/reverseproxy/composite.go Wires new strategies into CompositeHandler + dispatch; plumbs config/module wiring into handler creation.
modules/reverseproxy/composite_pipeline.go Implements pipeline + fan-out-merge execution logic and supporting types/helpers.
modules/reverseproxy/config.go Adds CompositeRoute.EmptyPolicy config field (empty_policy).
modules/reverseproxy/composite_pipeline_test.go New unit tests covering pipeline/fan-out-merge and empty-policy behavior.
modules/reverseproxy/features/composite_pipeline.feature New BDD feature scenarios for pipeline/fan-out-merge and empty-policy behavior.
modules/reverseproxy/bdd_step_registry_test.go Registers new godog steps for the added feature scenarios.
modules/reverseproxy/bdd_composite_pipeline_test.go Implements BDD step definitions for the new feature scenarios.
modules/reverseproxy/README.md Documents all composite strategies (including the two new ones) and empty-policy usage.
examples/reverse-proxy/main.go Adds runnable example usage for pipeline and fan-out-merge strategies and mock backends.
examples/reverse-proxy/config.yaml Adds example backends and composite route config entries for the new strategies.


// Set empty response policy from config if specified
if routeConfig.EmptyPolicy != "" {
handler.SetEmptyResponsePolicy(EmptyResponsePolicy(routeConfig.EmptyPolicy))
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty_policy is applied by casting the raw config string to EmptyResponsePolicy without validation. If a user misspells the value, the handler will silently fall back to the default behavior, making misconfigurations hard to detect. Consider validating routeConfig.EmptyPolicy against the allowed set (allow-empty/skip-empty/fail-on-empty) and returning a config error (or at least logging a warning and defaulting explicitly).

Suggested change
handler.SetEmptyResponsePolicy(EmptyResponsePolicy(routeConfig.EmptyPolicy))
switch routeConfig.EmptyPolicy {
case "allow-empty", "skip-empty", "fail-on-empty":
handler.SetEmptyResponsePolicy(EmptyResponsePolicy(routeConfig.EmptyPolicy))
default:
return nil, fmt.Errorf("invalid empty response policy %q for route %q: must be one of allow-empty, skip-empty, fail-on-empty", routeConfig.EmptyPolicy, routeConfig.Pattern)
}

Copilot uses AI. Check for mistakes.
Comment on lines +293 to +304
wg.Wait()

// Apply empty response policy
filteredResponses := make(map[string][]byte)
for backendID, body := range responses {
if isEmptyBody(body) {
switch h.emptyResponsePolicy {
case EmptyResponseFail:
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, "Backend %s returned empty response", backendID)
return
case EmptyResponseSkip:
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executeFanOutMerge will call the user-provided FanOutMerger even when no backend responses were collected (e.g., all requests failed or were skipped due to open circuit breakers). This differs from the existing merge/pipeline behavior which returns 502 when there are no successful responses, and it can lead to confusing 204/500 responses depending on the merger implementation. Consider short-circuiting with http.StatusBadGateway when len(responses)==0 (or after empty-policy filtering when len(filteredResponses)==0).

Copilot uses AI. Check for mistakes.
Comment on lines +397 to +402
return &http.Response{
Status: http.StatusText(statusCode),
StatusCode: statusCode,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(body)),
}, nil
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MakeJSONResponse sets http.Response.Status to http.StatusText(statusCode) (e.g., "OK"), but per net/http conventions it should be the full status line (e.g., "200 OK") or left empty so it can be derived from StatusCode. Returning a response with an invalid Status can break callers that write the response via (*http.Response).Write. Consider setting Status to fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)) or leaving it empty.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants