Repository: fortio/fortio (go)

Issue:
jgrpc needs to let caller add optional headers 
eg to call for instance apis that require a token:
```
req.Header = http.Header{
		"Authorization": {"Token token=" + pagerduty_token},
		"Accept":        {"application/vnd.pagerduty+json;version=2"},
}
```
cc @wiardvanrij

Pull request:
support passing headers and timeout in jrpc calls
fixes #619 

sort of breaking api change, added some "jrpc.xxxxURL" backward compatible string url call points


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8445274..cf0394d 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -8,7 +8,7 @@ defaultEnv:
   &defaultEnv
   docker:
     # specify the version
-    - image: docker.io/fortio/fortio.build:v45
+    - image: docker.io/fortio/fortio.build:v46
   working_directory: /go/src/fortio.org/fortio
 
 jobs:
diff --git a/Dockerfile b/Dockerfile
index e49ee56..3fc8141 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Build the binaries in larger image
-FROM docker.io/fortio/fortio.build:v45 as build
+FROM docker.io/fortio/fortio.build:v46 as build
 WORKDIR /go/src/fortio.org
 COPY . fortio
 ARG MODE=install
diff --git a/Dockerfile.build b/Dockerfile.build
index ab08b4a..dcd0ad0 100644
--- a/Dockerfile.build
+++ b/Dockerfile.build
@@ -1,5 +1,5 @@
 # Dependencies and linters for build:
-FROM golang:1.18.5
+FROM golang:1.18.6
 # Need gcc for -race test (and some linters though those work with CGO_ENABLED=0)
 RUN apt-get -y update && \
   apt-get --no-install-recommends -y upgrade && \
diff --git a/Dockerfile.echosrv b/Dockerfile.echosrv
index 032317b..db36b8b 100644
--- a/Dockerfile.echosrv
+++ b/Dockerfile.echosrv
@@ -1,5 +1,5 @@
 # Build the binaries in larger image
-FROM docker.io/fortio/fortio.build:v45 as build
+FROM docker.io/fortio/fortio.build:v46 as build
 WORKDIR /go/src/fortio.org
 COPY . fortio
 RUN make -C fortio official-build-version BUILD_DIR=/build OFFICIAL_TARGET=fortio.org/fortio/echosrv OFFICIAL_BIN=../echosrv.bin
diff --git a/Dockerfile.fcurl b/Dockerfile.fcurl
index e4c6bbc..96383a4 100644
--- a/Dockerfile.fcurl
+++ b/Dockerfile.fcurl
@@ -1,5 +1,5 @@
 # Build the binaries in larger image
-FROM docker.io/fortio/fortio.build:v45 as build
+FROM docker.io/fortio/fortio.build:v46 as build
 WORKDIR /go/src/fortio.org
 COPY . fortio
 # fcurl should not need vendor/no dependencies
diff --git a/Makefile b/Makefile
index cb593fa..48a3874 100755
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@
 IMAGES=echosrv fcurl # plus the combo image / Dockerfile without ext.
 
 DOCKER_PREFIX := docker.io/fortio/fortio
-BUILD_IMAGE_TAG := v45
+BUILD_IMAGE_TAG := v46
 BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
 BUILDX_POSTFIX :=
 ifeq '$(shell echo $(BUILDX_PLATFORMS) | awk -F "," "{print NF-1}")' '0'
diff --git a/README.md b/README.md
index b160c98..0386491 100644
--- a/README.md
+++ b/README.md
@@ -52,13 +52,13 @@ You can install from source:
 The [releases](https://github.com/fortio/fortio/releases) page has binaries for many OS/architecture combinations (see assets).
 
 ```shell
-curl -L https://github.com/fortio/fortio/releases/download/v1.35.0/fortio-linux_amd64-1.35.0.tgz \
+curl -L https://github.com/fortio/fortio/releases/download/v1.36.0/fortio-linux_amd64-1.36.0.tgz \
  | sudo tar -C / -xvzpf -
 # or the debian package
-wget https://github.com/fortio/fortio/releases/download/v1.35.0/fortio_1.35.0_amd64.deb
-dpkg -i fortio_1.35.0_amd64.deb
+wget https://github.com/fortio/fortio/releases/download/v1.36.0/fortio_1.36.0_amd64.deb
+dpkg -i fortio_1.36.0_amd64.deb
 # or the rpm
-rpm -i https://github.com/fortio/fortio/releases/download/v1.35.0/fortio-1.35.0-1.x86_64.rpm
+rpm -i https://github.com/fortio/fortio/releases/download/v1.36.0/fortio-1.36.0-1.x86_64.rpm
 # and more, see assets in release page
 ```
 
@@ -68,7 +68,7 @@ On a MacOS you can also install Fortio using [Homebrew](https://brew.sh/):
 brew install fortio
 ```
 
-On Windows, download https://github.com/fortio/fortio/releases/download/v1.35.0/fortio_win_1.35.0.zip and extract `fortio.exe` to any location, then using the Windows Command Prompt:
+On Windows, download https://github.com/fortio/fortio/releases/download/v1.36.0/fortio_win_1.36.0.zip and extract `fortio.exe` to any location, then using the Windows Command Prompt:
 ```
 fortio.exe server
 ```
@@ -116,7 +116,7 @@ Full list of command line flags (`fortio help`):
 <details>
 <!-- use release/updateFlags.sh to update this section -->
 <pre>
-Φορτίο 1.35.0 usage:
+Φορτίο 1.36.0 usage:
     fortio command [flags] target
 where command is one of: load (load testing), server (starts ui, rest api,
  http-echo, redirect, proxies, tcp-echo and grpc ping servers), tcp-echo (only
diff --git a/fhttp/http_client.go b/fhttp/http_client.go
index 36f861f..f7b8559 100644
--- a/fhttp/http_client.go
+++ b/fhttp/http_client.go
@@ -32,9 +32,9 @@ import (
 	"time"
 
 	"fortio.org/fortio/fnet"
+	"fortio.org/fortio/jrpc"
 	"fortio.org/fortio/log"
 	"fortio.org/fortio/stats"
-	"fortio.org/fortio/version"
 	"github.com/google/uuid"
 )
 
@@ -151,8 +151,6 @@ func (h *HTTPOptions) URLSchemeCheck() {
 	}
 }
 
-var userAgent = "fortio.org/fortio-" + version.Short()
-
 const (
 	retcodeOffset = len("HTTP/1.X ")
 	// HTTPReqTimeOutDefaultValue is the default timeout value. 3s.
@@ -199,7 +197,7 @@ func (h *HTTPOptions) ResetHeaders() {
 // InitHeaders initialize and/or resets the default headers (ie just User-Agent).
 func (h *HTTPOptions) InitHeaders() {
 	h.ResetHeaders()
-	h.extraHeaders.Add("User-Agent", userAgent)
+	h.extraHeaders.Set(jrpc.UserAgentHeader, jrpc.UserAgent)
 	// No other headers should be added here based on options content as this is called only once
 	// before command line option -H are parsed/set.
 }
diff --git a/fhttp/http_forwarder.go b/fhttp/http_forwarder.go
index c057872..acac345 100644
--- a/fhttp/http_forwarder.go
+++ b/fhttp/http_forwarder.go
@@ -29,6 +29,7 @@ import (
 	"sync"
 
 	"fortio.org/fortio/fnet"
+	"fortio.org/fortio/jrpc"
 	"fortio.org/fortio/log"
 )
 
@@ -97,9 +98,9 @@ func MakeSimpleRequest(url string, r *http.Request, copyAllHeaders bool) *http.R
 	// Copy only trace headers or all of them:
 	CopyHeaders(req, r, copyAllHeaders)
 	if copyAllHeaders {
-		req.Header.Add("X-Proxy-Agent", userAgent)
+		req.Header.Add("X-Proxy-Agent", jrpc.UserAgent)
 	} else {
-		req.Header.Add("User-Agent", userAgent)
+		req.Header.Set(jrpc.UserAgentHeader, jrpc.UserAgent)
 	}
 	return req
 }
@@ -117,8 +118,10 @@ func (mcfg *MultiServerConfig) TeeHandler(w http.ResponseWriter, r *http.Request
 	}
 	r.Body.Close()
 	if mcfg.Serial {
+		//nolint:contextcheck // bug I think as we transfer the context - asked in https://github.com/kkHAIKE/contextcheck/issues/3
 		mcfg.TeeSerialHandler(w, r, data)
 	} else {
+		//nolint:contextcheck // bug I think as we transfer the context - asked in https://github.com/kkHAIKE/contextcheck/issues/3
 		mcfg.TeeParallelHandler(w, r, data)
 	}
 }
diff --git a/fhttp/http_server.go b/fhttp/http_server.go
index f6dc602..bc02645 100644
--- a/fhttp/http_server.go
+++ b/fhttp/http_server.go
@@ -420,6 +420,7 @@ func FetcherHandler2(w http.ResponseWriter, r *http.Request) {
 	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
 		url = "http://" + url
 	}
+	//nolint:contextcheck // bug(?) we transfer the context from the http request https://github.com/kkHAIKE/contextcheck/issues/3
 	req := MakeSimpleRequest(url, r, fetch2CopiesAllHeader.Get())
 	if req == nil {
 		http.Error(w, "parsing url failed, invalid url", http.StatusBadRequest)
diff --git a/jrpc/jrpcClient.go b/jrpc/jrpcClient.go
index 8e1b0d3..047bd4b 100644
--- a/jrpc/jrpcClient.go
+++ b/jrpc/jrpcClient.go
@@ -16,8 +16,8 @@
 // using generics to serialize/deserialize any type.
 package jrpc // import "fortio.org/fortio/jrpc"
 
-// This package is a true self contained library, doesn't rely on our logger nor other packages in fortio/.
-// Client side and common code.
+// This package is a true self contained library, that doesn't rely on our logger nor other packages
+// in fortio/ outside of version/ (which now also doesn't rely on logger or any other package).
 import (
 	"bytes"
 	"context"
@@ -26,13 +26,27 @@ import (
 	"io"
 	"net/http"
 	"time"
+
+	"fortio.org/fortio/version"
+)
+
+// Client side and common code.
+
+const (
+	UserAgentHeader = "User-Agent"
 )
 
 // Default timeout for Call.
 var timeout = 60 * time.Second
 
+// UserAgent is the User-Agent header used by client calls (also used in fhttp/).
+var UserAgent = "fortio.org/fortio-" + version.Short()
+
 // SetCallTimeout changes the timeout for further Call calls, returns
-// the previous value (default in 60s).
+// the previous value (default in 60s). Value is used when a timeout
+// isn't passed in the options. Note this is not thread safe,
+// use Destination.Timeout for changing values outside of main/single
+// thread.
 func SetCallTimeout(t time.Duration) time.Duration {
 	previous := timeout
 	timeout = t
@@ -50,6 +64,13 @@ type FetchError struct {
 	Bytes []byte
 }
 
+// Destination is the URL and optional additional headers.
+type Destination struct {
+	URL     string
+	Headers *http.Header
+	Timeout time.Duration
+}
+
 func (fe *FetchError) Error() string {
 	return fmt.Sprintf("%s, code %d: %v (raw reply: %s)", fe.Message, fe.Code, fe.Err, DebugSummary(fe.Bytes, 256))
 }
@@ -61,7 +82,7 @@ func (fe *FetchError) Unwrap() error {
 // Call calls the url endpoint, POSTing a serialized as json optional payload
 // (pass nil for a GET http request) and returns the result, deserializing
 // json into type Q. T can be inferred so we declare Response Q first.
-func Call[Q any, T any](url string, payload *T) (*Q, error) {
+func Call[Q any, T any](url *Destination, payload *T) (*Q, error) {
 	var bytes []byte
 	var err error
 	if payload != nil {
@@ -73,15 +94,27 @@ func Call[Q any, T any](url string, payload *T) (*Q, error) {
 	return CallWithPayload[Q](url, bytes)
 }
 
+// CallURL is Call without any options/non default headers, timeout etc and just the URL.
+func CallURL[Q any, T any](url string, payload *T) (*Q, error) {
+	return Call[Q](NewDestination(url), payload)
+}
+
 // CallNoPayload is for an API call without json payload.
-func CallNoPayload[Q any](url string) (*Q, error) {
+func CallNoPayload[Q any](url *Destination) (*Q, error) {
 	return CallWithPayload[Q](url, []byte{})
 }
 
+// CallNoPayloadURL short cut for CallNoPayload with url as a string (default Send()/Destination options).
+func CallNoPayloadURL[Q any](url string) (*Q, error) {
+	return CallWithPayload[Q](NewDestination(url), []byte{})
+}
+
+// Serialize serializes the object as json.
 func Serialize(obj interface{}) ([]byte, error) {
 	return json.Marshal(obj)
 }
 
+// Deserialize deserializes json as a new object of desired type.
 func Deserialize[Q any](bytes []byte) (*Q, error) {
 	var result Q
 	err := json.Unmarshal(bytes, &result)
@@ -89,7 +122,7 @@ func Deserialize[Q any](bytes []byte) (*Q, error) {
 }
 
 // CallWithPayload is for cases where the payload is already serialized (or empty).
-func CallWithPayload[Q any](url string, bytes []byte) (*Q, error) {
+func CallWithPayload[Q any](url *Destination, bytes []byte) (*Q, error) {
 	code, bytes, err := Send(url, bytes) // returns -1 on other errors
 	if err != nil {
 		return nil, err
@@ -110,24 +143,43 @@ func CallWithPayload[Q any](url string, bytes []byte) (*Q, error) {
 	return result, nil
 }
 
+// SetHeaderIfMissing utility function to not overwrite nor append to existing headers.
+func SetHeaderIfMissing(headers http.Header, name, value string) {
+	if headers.Get(name) != "" {
+		return
+	}
+	headers.Set(name, value)
+}
+
 // Send fetches the result from url and sends optional payload as a POST, GET if missing.
 // Returns the http code (if no other error before then, -1 if there are errors),
 // the bytes from the reply and error if any.
-func Send(url string, jsonPayload []byte) (int, []byte, error) {
-	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+func Send(dest *Destination, jsonPayload []byte) (int, []byte, error) {
+	curTimeout := dest.Timeout
+	if curTimeout == 0 {
+		curTimeout = timeout
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), curTimeout)
 	defer cancel()
 	var req *http.Request
 	var err error
 	var res []byte
 	if len(jsonPayload) > 0 {
-		req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonPayload))
-		req.Header.Set("Content-Type", "application/json; charset=utf-8")
+		req, err = http.NewRequestWithContext(ctx, http.MethodPost, dest.URL, bytes.NewReader(jsonPayload))
 	} else {
-		req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+		req, err = http.NewRequestWithContext(ctx, http.MethodGet, dest.URL, nil)
 	}
 	if err != nil {
 		return -1, res, err
 	}
+	if dest.Headers != nil {
+		req.Header = dest.Headers.Clone()
+	}
+	if len(jsonPayload) > 0 {
+		SetHeaderIfMissing(req.Header, "Content-Type", "application/json; charset=utf-8")
+	}
+	SetHeaderIfMissing(req.Header, "Accept", "application/json")
+	SetHeaderIfMissing(req.Header, UserAgentHeader, UserAgent)
 	var resp *http.Response
 	resp, err = http.DefaultClient.Do(req)
 	if err != nil {
@@ -138,8 +190,19 @@ func Send(url string, jsonPayload []byte) (int, []byte, error) {
 	return resp.StatusCode, res, err
 }
 
-// Fetch is Send without a payload.
-func Fetch(url string) (int, []byte, error) {
+// NewDestination returns a Destination object set for the given url
+// (and default/nil replacement headers and default global timeout).
+func NewDestination(url string) *Destination {
+	return &Destination{URL: url}
+}
+
+// FetchURL is Send without a payload and no additional options (default timeout and headers).
+func FetchURL(url string) (int, []byte, error) {
+	return Send(NewDestination(url), []byte{})
+}
+
+// Fetch is Send without a payload (so will be a GET request).
+func Fetch(url *Destination) (int, []byte, error) {
 	return Send(url, []byte{})
 }
 
diff --git a/jrpc/jrpcServer.go b/jrpc/jrpcServer.go
index 7e5237d..35ac90f 100644
--- a/jrpc/jrpcServer.go
+++ b/jrpc/jrpcServer.go
@@ -14,18 +14,21 @@
 
 package jrpc // import "fortio.org/fortio/jrpc"
 
-// Server side additional code (compared to restClient.go).
+// Server side additional code (compared to jrpcClient.go).
 import (
 	"io"
 	"net/http"
 )
 
+// ServerReply is used to reply errors but can also be the base for Ok replies,
+// see the unit tests `Response` and ../rapi for examples of use.
 type ServerReply struct {
 	Error     bool   `json:"error,omitempty"` // Success if false/omitted, Error/Failure when true
 	Message   string `json:"message,omitempty"`
 	Exception string `json:"exception,omitempty"`
 }
 
+// NewErrorReply creates a new error reply with the message and err error.
 func NewErrorReply(message string, err error) *ServerReply {
 	res := ServerReply{Error: true, Message: message}
 	if err != nil {
@@ -53,26 +56,41 @@ func Reply[T any](w http.ResponseWriter, code int, data *T) error {
 	return err
 }
 
+// ReplyNoPayload is a short cut for Reply() with empty (nil) payload.
 func ReplyNoPayload(w http.ResponseWriter, code int) error {
 	return Reply[int](w, code, nil)
 }
 
+// ReplyOk is a short cut for Reply() with http.StatusOK as the result code.
 func ReplyOk[T any](w http.ResponseWriter, data *T) error {
 	return Reply(w, http.StatusOK, data)
 }
 
+// ReplyClientError is a short cut for Reply() with http.StatusBadRequest as the result code.
 func ReplyClientError[T any](w http.ResponseWriter, data *T) error {
 	return Reply(w, http.StatusBadRequest, data)
 }
 
+// ReplyServerError is a short cut for Reply() with http.StatusServiceUnavailable as the result code.
 func ReplyServerError[T any](w http.ResponseWriter, data *T) error {
 	return Reply(w, http.StatusServiceUnavailable, data)
 }
 
+// ReplyError is to send back a client error with exception details.
 func ReplyError(w http.ResponseWriter, extraMsg string, err error) error {
 	return ReplyClientError(w, NewErrorReply(extraMsg, err))
 }
 
+// HandleCall deserializes the expected type from the request body.
+// Sample usage code:
+// ```
+//
+//	 req, err := jrpc.HandleCall[Request](w, r)
+//		if err != nil {
+//		    _ = jrpc.ReplyError(w, "request error", err)
+//		}
+//
+// ```.
 func HandleCall[Q any](w http.ResponseWriter, r *http.Request) (*Q, error) {
 	data, err := io.ReadAll(r.Body)
 	if err != nil {
diff --git a/rapi/restHandler.go b/rapi/restHandler.go
index 92302b7..4fe6f52 100644
--- a/rapi/restHandler.go
+++ b/rapi/restHandler.go
@@ -321,12 +321,12 @@ func RESTRunHandler(w http.ResponseWriter, r *http.Request) { //nolint:funlen
 		if err != nil {
 			log.Errf("Error replying to start: %v", err)
 		}
-		//nolint:errcheck // all cases handled inside for rapi callers
+		//nolint:errcheck,contextcheck // all cases handled inside for rapi callers. async code with our own aborter
 		// returned values are for the ui/uihandler.go caller.
 		go Run(nil, r, jd, runner, url, &ro, httpopts, false)
 		return
 	}
-	//nolint:errcheck // all cases handled inside for rapi callers
+	//nolint:errcheck,contextcheck // all cases handled inside for rapi callers. aborter handles context.
 	Run(w, r, jd, runner, url, &ro, httpopts, false)
 }
 
diff --git a/release/Dockerfile.in b/release/Dockerfile.in
index 65c8b3e..90fc70c 100644
--- a/release/Dockerfile.in
+++ b/release/Dockerfile.in
@@ -1,5 +1,5 @@
 # Concatenated after ../Dockerfile to create the tgz
-FROM docker.io/fortio/fortio.build:v45 as stage
+FROM docker.io/fortio/fortio.build:v46 as stage
 ARG archs="amd64 arm64 ppc64le s390x"
 ENV archs=${archs}
 # Build image defaults to build user, switch back to root for
diff --git a/ui/uihandler.go b/ui/uihandler.go
index 796ffe9..c4a45ca 100644
--- a/ui/uihandler.go
+++ b/ui/uihandler.go
@@ -270,6 +270,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
 		}
 		// A bit awkward api because of trying to reuse yet be compatible from old UI code with
 		// new `rapi` code.
+		//nolint:contextcheck // we use the internal Aborter to cancel run through api/stop button in UI.
 		res, savedAs, json, err := rapi.Run(runWriter, r, nil, runner, url, &ro, httpopts, true /*html mode*/)
 		if err != nil {
 			_, _ = w.Write([]byte(fmt.Sprintf(
diff --git a/version/version.go b/version/version.go
index 63d8dbb..b58cd31 100644
--- a/version/version.go
+++ b/version/version.go
@@ -16,11 +16,10 @@
 package version // import "fortio.org/fortio/version"
 import (
 	"fmt"
+	"log"
 	"runtime"
 	"runtime/debug"
 	"strings"
-
-	"fortio.org/fortio/log"
 )
 
 var (
@@ -87,7 +86,7 @@ func FromBuildInfoPath(path string) (short, long, full string) {
 	binfo, ok := debug.ReadBuildInfo()
 	if !ok {
 		full = "fortio version module error, no build info"
-		log.Errf(full)
+		log.Print("Error calling debug.ReadBuildInfo() for fortio version module")
 		return
 	}
 	short, sum := getVersion(binfo, path)


Test patch:
diff --git a/Webtest.sh b/Webtest.sh
index eb6394e..82df15e 100755
--- a/Webtest.sh
+++ b/Webtest.sh
@@ -125,7 +125,7 @@ fi
 PPROF_URL="$BASE_URL/debug/pprof/heap?debug=1"
 $CURL "$PPROF_URL" | grep -i TotalAlloc # should find this in memory profile
 # creating dummy container to hold a volume for test certs due to remote docker bind mount limitation.
-DOCKERCURLID=$(docker run -d -v $TEST_CERT_VOL --net host --name $DOCKERSECVOLNAME docker.io/fortio/fortio.build:v45 sleep 120)
+DOCKERCURLID=$(docker run -d -v $TEST_CERT_VOL --net host --name $DOCKERSECVOLNAME docker.io/fortio/fortio.build:v46 sleep 120)
 # while we have something with actual curl binary do
 # Test for h2c upgrade (#562)
 docker exec $DOCKERSECVOLNAME /usr/bin/curl -v --http2 -m 10 -d foo42 http://localhost:8080/debug | tee >(cat 1>&2) | grep foo42
diff --git a/fhttp/http_test.go b/fhttp/http_test.go
index 696546d..0dc4c42 100644
--- a/fhttp/http_test.go
+++ b/fhttp/http_test.go
@@ -30,6 +30,7 @@ import (
 	"unicode/utf8"
 
 	"fortio.org/fortio/fnet"
+	"fortio.org/fortio/jrpc"
 	"fortio.org/fortio/log"
 	"github.com/google/uuid"
 )
@@ -889,18 +890,18 @@ func TestPayloadForFastClient(t *testing.T) {
 			"application/json",
 			[]byte("{\"test\" : \"test\"}"),
 			fmt.Sprintf("POST / HTTP/1.1\r\nHost: www.google.com\r\nContent-Length: 17\r\nContent-Type: "+
-				"application/json\r\nUser-Agent: %s\r\n\r\n{\"test\" : \"test\"}", userAgent),
+				"application/json\r\nUser-Agent: %s\r\n\r\n{\"test\" : \"test\"}", jrpc.UserAgent),
 		},
 		{
 			"application/xml",
 			[]byte("<test test=\"test\">"),
 			fmt.Sprintf("POST / HTTP/1.1\r\nHost: www.google.com\r\nContent-Length: 18\r\nContent-Type: "+
-				"application/xml\r\nUser-Agent: %s\r\n\r\n<test test=\"test\">", userAgent),
+				"application/xml\r\nUser-Agent: %s\r\n\r\n<test test=\"test\">", jrpc.UserAgent),
 		},
 		{
 			"",
 			nil,
-			fmt.Sprintf("GET / HTTP/1.1\r\nHost: www.google.com\r\nUser-Agent: %s\r\n\r\n", userAgent),
+			fmt.Sprintf("GET / HTTP/1.1\r\nHost: www.google.com\r\nUser-Agent: %s\r\n\r\n", jrpc.UserAgent),
 		},
 	}
 	for _, test := range tests {
@@ -1133,7 +1134,7 @@ func TestDebugHandlerSortedHeaders(t *testing.T) {
 		"Ccc: ccc\n"+
 		"User-Agent: %s\n"+
 		"Zzz: zzz\n\n"+
-		"body:\n\n\n", a.Port, userAgent)
+		"body:\n\n\n", a.Port, jrpc.UserAgent)
 	if body != expected {
 		t.Errorf("Get body: %s not as expected: %s", body, expected)
 	}
diff --git a/jrpc/jrpc_test.go b/jrpc/jrpc_test.go
index 2f254a5..7ef19de 100644
--- a/jrpc/jrpc_test.go
+++ b/jrpc/jrpc_test.go
@@ -24,6 +24,7 @@ import (
 	"testing"
 	"time"
 
+	"fortio.org/assert"
 	"fortio.org/fortio/fhttp"
 	"fortio.org/fortio/jrpc"
 )
@@ -69,11 +70,11 @@ func TestJPRC(t *testing.T) {
 	if prev != 60*time.Second {
 		t.Errorf("Expected default call timeout to be 60 seconds, got %v", prev)
 	}
-	mux, addr := fhttp.HTTPServer("test", "0")
+	mux, addr := fhttp.HTTPServer("test1", "0")
 	port := addr.(*net.TCPAddr).Port
 	var bad chan struct{}
 	mux.HandleFunc("/test-api", func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != "POST" {
+		if r.Method != http.MethodPost {
 			err := jrpc.ReplyError(w, "should be a POST", nil)
 			if err != nil {
 				t.Errorf("Error in replying error: %v", err)
@@ -129,7 +130,7 @@ func TestJPRC(t *testing.T) {
 	})
 	url := fmt.Sprintf("http://localhost:%d/test-api", port)
 	req := Request{42, []string{"ab", "cd"}}
-	res, err := jrpc.Call[Response](url, &req)
+	res, err := jrpc.CallURL[Response](url, &req)
 	if err != nil {
 		t.Errorf("failed Call: %v", err)
 	}
@@ -144,7 +145,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// Error cases
 	// Empty request, using Fetch()
-	code, bytes, err := jrpc.Fetch(url)
+	code, bytes, err := jrpc.Fetch(jrpc.NewDestination(url))
 	if err != nil {
 		t.Errorf("failed Fetch: %v - %s", err, jrpc.DebugSummary(bytes, 256))
 	}
@@ -163,7 +164,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// bad url
 	badURL := "http://doesnotexist.fortio.org/"
-	_, err = jrpc.Call[Response](badURL, &req)
+	_, err = jrpc.CallURL[Response](badURL, &req)
 	if err == nil {
 		t.Errorf("expected error for bad url")
 	}
@@ -176,7 +177,7 @@ func TestJPRC(t *testing.T) {
 		t.Errorf("expected dns error to start with %q, got %q", expected, de.Error())
 	}
 	// bad json payload sent
-	errReply, err := jrpc.CallWithPayload[Response](url, []byte(`{foo: missing-quotes}`))
+	errReply, err := jrpc.CallWithPayload[Response](jrpc.NewDestination(url), []byte(`{foo: missing-quotes}`))
 	if err == nil {
 		t.Errorf("expected error, got nil and %v", res)
 	}
@@ -195,7 +196,7 @@ func TestJPRC(t *testing.T) {
 		t.Errorf("expected Exception in body to be %q, got %+v", expected, errReply)
 	}
 	// bad json response, using Fetch()
-	errReply, err = jrpc.CallNoPayload[Response](url)
+	errReply, err = jrpc.CallNoPayloadURL[Response](url)
 	if err == nil {
 		t.Errorf("expected error %v", errReply)
 	}
@@ -207,7 +208,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// trigger empty reply
 	req.SomeInt = -7
-	res, err = jrpc.Call[Response](url, &req)
+	res, err = jrpc.CallURL[Response](url, &req)
 	if err == nil {
 		t.Errorf("error expected %v: %v", res, err)
 	}
@@ -219,7 +220,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// trigger server error
 	req.SomeInt = -8
-	res, err = jrpc.Call[Response](url, &req)
+	res, err = jrpc.CallURL[Response](url, &req)
 	if err == nil {
 		t.Errorf("error expected %v: %v", res, err)
 	}
@@ -238,7 +239,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// trigger bad json response - and non ok code
 	req.SomeInt = -9
-	res, err = jrpc.Call[Response](url, &req)
+	res, err = jrpc.CallURL[Response](url, &req)
 	if err == nil {
 		t.Errorf("error expected %v: %v", res, err)
 	}
@@ -263,7 +264,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// trigger bad json response - and ok http code
 	req.SomeInt = -10
-	res, err = jrpc.Call[Response](url, &req)
+	res, err = jrpc.CallURL[Response](url, &req)
 	if err == nil {
 		t.Errorf("error expected %v: %v", res, err)
 	}
@@ -273,7 +274,7 @@ func TestJPRC(t *testing.T) {
 	}
 	// trigger reply bad serialization
 	req.SomeInt = -11
-	res, err = jrpc.Call[Response](url, &req)
+	res, err = jrpc.CallURL[Response](url, &req)
 	if err == nil {
 		t.Errorf("error expected %v", res)
 	}
@@ -282,7 +283,7 @@ func TestJPRC(t *testing.T) {
 		t.Errorf("error string expected %q, got %q, %+v", expected, err.Error(), res)
 	}
 	// Unserializable client side
-	res, err = jrpc.Call[Response](url, &bad)
+	res, err = jrpc.CallURL[Response](url, &bad)
 	if err == nil {
 		t.Errorf("error expected %v", res)
 	}
@@ -292,6 +293,32 @@ func TestJPRC(t *testing.T) {
 	}
 }
 
+func TestJPRCHeaders(t *testing.T) {
+	mux, addr := fhttp.HTTPServer("test2", "0")
+	port := addr.(*net.TCPAddr).Port
+	mux.HandleFunc("/test-headers", func(w http.ResponseWriter, r *http.Request) {
+		// Send back the headers
+		jrpc.ReplyOk(w, &r.Header)
+	})
+	url := fmt.Sprintf("http://localhost:%d/test-headers", port)
+	inp := make(http.Header)
+	inp.Set("Test1", "ValT1.1")
+	inp.Add("Test1", "ValT1.2")
+	inp.Set("Test2", "ValT2")
+	jrpc.SetHeaderIfMissing(inp, "Test2", "ShouldNotSet") // test along the way
+	dest := &jrpc.Destination{
+		URL:     url,
+		Headers: &inp,
+	}
+	res, err := jrpc.CallNoPayload[http.Header](dest)
+	if err != nil {
+		t.Errorf("failed Call: %v", err)
+	}
+	// order etc is preserved, keys are not case sensitive (kinda tests go http api too)
+	assert.Equal(t, res.Values("test1"), []string{"ValT1.1", "ValT1.2"}, "Expecting echoed back Test1 multi valued header")
+	assert.CheckEquals(t, res.Get("test2"), "ValT2", "Expecting echoed back Test2 header")
+}
+
 type ErrReader struct{}
 
 const ErrReaderMessage = "simulated IO error"
@@ -313,7 +340,7 @@ func TestHandleCallError(t *testing.T) {
 
 func TestSendBadURL(t *testing.T) {
 	badURL := "bad\001url" // something caught in NewRequest
-	_, _, err := jrpc.Fetch(badURL)
+	_, _, err := jrpc.FetchURL(badURL)
 	if err == nil {
 		t.Errorf("expected error, got nil")
 	}
diff --git a/rapi/restHandler_test.go b/rapi/restHandler_test.go
index d0d8232..2948e14 100644
--- a/rapi/restHandler_test.go
+++ b/rapi/restHandler_test.go
@@ -37,7 +37,7 @@ import (
 
 // Generics ftw.
 func FetchResult[T any](t *testing.T, url string, jsonPayload string) *T {
-	r, err := jrpc.CallWithPayload[T](url, []byte(jsonPayload))
+	r, err := jrpc.CallWithPayload[T](jrpc.NewDestination(url), []byte(jsonPayload))
 	if err != nil {
 		t.Errorf("Got unexpected error for URL %s: %v - %v", url, err, r)
 	}
@@ -63,7 +63,7 @@ func GetAsyncResult(t *testing.T, url string, jsonPayload string) *AsyncReply {
 
 // Same as above but when expecting to get an error reply.
 func GetErrorResult(t *testing.T, url string, jsonPayload string) *jrpc.ServerReply {
-	r, err := jrpc.CallWithPayload[jrpc.ServerReply](url, []byte(jsonPayload))
+	r, err := jrpc.CallWithPayload[jrpc.ServerReply](jrpc.NewDestination(url), []byte(jsonPayload))
 	if err == nil {
 		t.Errorf("Got unexpected no error for URL %s: %v", url, r)
 	}
@@ -195,7 +195,11 @@ func TestHTTPRunnerRESTApi(t *testing.T) {
 	}
 	// Get status
 	statusURL := fmt.Sprintf("http://localhost:%d%s%s?runid=%d", addr.Port, uiPath, RestStatusURI, runID)
-	statuses, err := jrpc.CallNoPayload[StatusReply](statusURL)
+	statusDest := &jrpc.Destination{
+		URL:     statusURL,
+		Timeout: 3 * time.Second,
+	}
+	statuses, err := jrpc.CallNoPayload[StatusReply](statusDest)
 	if err != nil {
 		t.Errorf("Error getting status %q: %v", statusURL, err)
 	}
@@ -255,7 +259,7 @@ func TestHTTPRunnerRESTApi(t *testing.T) {
 		t.Errorf("2nd stop should be noop, got %+v", asyncObj)
 	}
 	// Status should be empty (nothing running)
-	statuses, err = jrpc.CallNoPayload[StatusReply](statusURL)
+	statuses, err = jrpc.CallNoPayload[StatusReply](statusDest)
 	if err != nil {
 		t.Errorf("Error getting status %q: %v", statusURL, err)
 	}
@@ -269,7 +273,8 @@ func TestHTTPRunnerRESTApi(t *testing.T) {
 	_ = GetAsyncResult(t, runURL, jsonData)
 	// Get all statuses
 	statusURL = fmt.Sprintf("http://localhost:%d%s%s", addr.Port, uiPath, RestStatusURI)
-	statuses, err = jrpc.CallNoPayload[StatusReply](statusURL)
+	statusDest.URL = statusURL
+	statuses, err = jrpc.CallNoPayload[StatusReply](statusDest)
 	if err != nil {
 		t.Errorf("Error getting status %q: %v", statusURL, err)
 	}
@@ -292,7 +297,7 @@ func TestHTTPRunnerRESTApi(t *testing.T) {
 	}
 
 	tsvURL := fmt.Sprintf("http://localhost:%d%s%s", addr.Port, uiPath, "data/index.tsv")
-	code, bytes, err := jrpc.Fetch(tsvURL)
+	code, bytes, err := jrpc.FetchURL(tsvURL)
 	if err != nil {
 		t.Errorf("Unexpected error for %s: %v", tsvURL, err)
 	}
@@ -318,7 +323,7 @@ func TestHTTPRunnerRESTApi(t *testing.T) {
 	}
 	// Check we can't fetch the foo.txt
 	fetchTxt := fmt.Sprintf("http://localhost:%d%sdata/foo.txt", addr.Port, uiPath)
-	code, bytes, err = jrpc.Fetch(fetchTxt)
+	code, bytes, err = jrpc.FetchURL(fetchTxt)
 	if err != nil {
 		t.Errorf("Unexpected error in fetch %q: %v", fetchTxt, err)
 	}
@@ -326,7 +331,7 @@ func TestHTTPRunnerRESTApi(t *testing.T) {
 		t.Errorf("foo.txt should have been not found, got %d %s", code, fnet.DebugSummary(bytes, 256))
 	}
 	SetDataDir("/does/not/exist")
-	code, bytes, err = jrpc.Fetch(tsvURL)
+	code, bytes, err = jrpc.FetchURL(tsvURL)
 	if err != nil {
 		t.Errorf("Unexpected low level error for %s: %v", tsvURL, err)
 	}
@@ -370,7 +375,8 @@ func TestRESTStopTimeBased(t *testing.T) {
 	}
 	// Get status
 	statusURL := fmt.Sprintf("http://localhost:%d%s%s?runid=%d", addr.Port, uiPath, RestStatusURI, runID)
-	statuses, err := jrpc.CallNoPayload[StatusReply](statusURL)
+	statusDest := jrpc.NewDestination(statusURL)
+	statuses, err := jrpc.CallNoPayload[StatusReply](statusDest)
 	if err != nil {
 		t.Errorf("Error getting status %q: %v", statusURL, err)
 	}
@@ -412,7 +418,7 @@ func TestRESTStopTimeBased(t *testing.T) {
 		t.Errorf("2nd stop should be noop, got %+v", asyncObj)
 	}
 	// Status should be empty (nothing running)
-	statuses, err = jrpc.CallNoPayload[StatusReply](statusURL)
+	statuses, err = jrpc.CallNoPayload[StatusReply](statusDest)
 	if err != nil {
 		t.Errorf("Error getting status %q: %v", statusURL, err)
 	}
@@ -438,7 +444,7 @@ func TestRESTStopTimeBased(t *testing.T) {
 		t.Errorf("Mismatch between ids %q vs result %q", fileID, res.Result().ID)
 	}
 	tsvURL := fmt.Sprintf("http://localhost:%d%s%s", addr.Port, uiPath, "data/index.tsv")
-	code, bytes, err := jrpc.Fetch(tsvURL)
+	code, bytes, err := jrpc.FetchURL(tsvURL)
 	if err != nil {
 		t.Errorf("Unexpected error for %s: %v", tsvURL, err)
 	}
@@ -450,7 +456,7 @@ func TestRESTStopTimeBased(t *testing.T) {
 		t.Errorf("Base url not found in result %s", dataStr)
 	}
 	indexURL := fmt.Sprintf("http://localhost:%d%s%s", addr.Port, uiPath, "data/index.html")
-	code, bytes, err = jrpc.Fetch(indexURL)
+	code, bytes, err = jrpc.FetchURL(indexURL)
 	if err != nil {
 		t.Errorf("Unexpected error for %s: %v", indexURL, err)
 	}
@@ -469,7 +475,7 @@ func TestRESTStopTimeBased(t *testing.T) {
 
 // If jsonPayload isn't empty we POST otherwise get the url.
 func GetGRPCResult(t *testing.T, url string, jsonPayload string) *fgrpc.GRPCRunnerResults {
-	r, err := jrpc.CallWithPayload[fgrpc.GRPCRunnerResults](url, []byte(jsonPayload))
+	r, err := jrpc.CallWithPayload[fgrpc.GRPCRunnerResults](jrpc.NewDestination(url), []byte(jsonPayload))
 	if err != nil {
 		t.Errorf("Got unexpected err for URL %s: %v", url, err)
 	}