Files
cypress/packages/https-proxy/lib/server.coffee
Zach Bloomquist b8a6baaca3 Retry certain requests on failure (#4015)
* retry requests, basic e2e test

* basic e2e test for chrome behavior

* don't use timeouts to test

* some minor cleanup

* validate google chrome's retry behavior w/ a proxy

* get retries on network errors workin

* cleanup

* final changes before switching approach

* Reverting previous approach in request.coffee, server.coffee

* add retryOnNetworkFailure

* now works with retryOnStatusCodeFailure

* retry 4 times in addition to the 1 initial attempt

* add tests for subresources

* much improved error handling

* have the e2e test really work

* e2e baseurl check

* retry baseurl check

* remove special handling for node 8.2.1 bug

* WIP: continue making progress building out request retry

- swap out passthrough’s for pumpify / duplexify / pump
- clean up error handling / messaging / retry logic

* pipe clientrequest events

* buffer req body to disk, restore error handling/retrying

* don't bubble up errors from failed attempts

* actually pipe reqstream, oops

* add some e2e tests for request body

* revert lib/request.coffee to 7be1051

* add almost-working lazy-stream

* manually fire the 'pipe' event on the reqStream to copy headers to the outgoing message

- restore the ‘error’ propagation so that all tests pass for now

* cleanup leaking 'undefined' into stdout, causing failing e2e tests

- do not set onWarning as a property of project, just pass as an
argument

* add new options to request_spec, deduplicate default opts

* use stream buffer in request.coffee

* revert request.coffee

* last stream_buffer changes before removing fs stuff

* remove fs stuff from stream_buffer, add stream piping tests

* it works! :tada::tada:🎉 using duplexify to separate delayStream and reqBodyBuffer

* retry for .5s max on ECONNREFUSED

* add error codes

* don't timeout proxied requests

* restore baseurl check

* update new e2e tests

* make delay work with rp

* propagate clientresponse events

* removing tests that don't do anything now that we don't ESOCKETTIMEOUT on proxied requests

* add new visit, req options to index.d.ts

* don't fail on server-performance-test

* make retries with status code work again

* account for different stack trace in ci

* fix test

* retry https requests

* add tests for https passthru retries working

* clean up error handling for https-proxy

* fix failing https-proxy tests, tweak agent error handling to prevent multiple callbacks

* make expectedExitCode actual vs. expected in the correct order

* bump up e2e test timeout so it can retry and still work

* update tests

* retry up to 500ms on proxied requests

* add tests for incompatible options errors

* remove .only

* maybe this will help it act more consistently

* help e2e test work in ci

* don't reconnect on already made connections

* clarify naming

* wip: testing https proxy

* better debug calls

* WIP: getting proxy passthrough retry tests going

* handle retrying upstream proxy errors

- add tests for successfully retrying proxy errors and for unsuccessful
retries
- fix onClose errors when proxy connection is closed before enough data
is received
- fix not returning setTimeout correctly

* group related code accordingly

* do not build typescript by default, drop extension from main

* more TODO notes

* don't set a default NO_PROXY if NO_PROXY = ''

* debugging-proxy@2.0.0

* null out reqBodyBuffer when req finishes

* wip: retry in agent, not https-proxy [skip-ci]

* update https-proxy to use @packages/network retries

* retry after connection but before proxy tunnel established

* appease my linty overlords

* update https-proxy tests

* update agent specs, decided to still use tls.connect

it's easier to test and has less complexity

* test retrying HTTPS passthru

* debugging-proxy@2.0.1

* increase defaultCommandTimeout 100 -> 200 to prevent flake in CI

* auto formatting

* fix test to be dynamic and not rely on magic constants

* copy types field when linking proxy images, update packages/network types field

* linting

* add network index.js file

* linting

* improve error messaging experience when verifying base url

* only insert 1 new line

* fix failing test not binding to localhost

* removed test that's covered by e2e specs

* remove dash in 're-try'

* some cleanup for readability

* use allocUnsafe per perf

* unset NO_PROXY with an empty string

* move retry ensuring the baseUrl into url, cleanup some imperative code

* if the head is already present during connect, make connection, else wait for first head bytes

* minor formatting, clarity around conditions, naming

* rename retryInterval -> retryIntervals

* set defaults for requests during creation for clarity

* rename send -> sendPromise to pair better with sendStream

* use retryIntervals instead of juggling MAX_REQUEST_RETRIES

- ensure debug messages are consistent between request streams +
promises
- set static constants

* DRY up status check + network failure + retry logic

- keeps the debug logic identical between promises + streams
- ensures all logic paths are also consistent
- consolidates the pop’ing of intervals in a single place

* find / replace fail

* derp

* make the logic actually correct, set intervals as cloned defaults for recursive lookup

* pass arg correctly

* reduce debugging noise, formatting

* rename intervals -> delaysRemaining for clarity

* added unit tests around getDelayForRetry

* set retryIntervals as default options correctly, add unit tests


Co-authored-by: Brian Mann <brian.mann86@gmail.com>
Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Gleb Bahmutov <gleb.bahmutov@gmail.com>
2019-05-15 12:46:55 -04:00

250 lines
6.8 KiB
CoffeeScript

_ = require("lodash")
{ agent, connect } = require("@packages/network")
allowDestroy = require("server-destroy-vvo")
debug = require("debug")("cypress:https-proxy")
fs = require("fs-extra")
getProxyForUrl = require("proxy-from-env").getProxyForUrl
https = require("https")
net = require("net")
parse = require("./util/parse")
Promise = require("bluebird")
semaphore = require("semaphore")
url = require("url")
fs = Promise.promisifyAll(fs)
sslServers = {}
sslSemaphores = {}
## https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record
SSL_RECORD_TYPES = [
22 ## Handshake
128, 0 ## TODO: what do these unknown types mean?
]
class Server
constructor: (@_ca, @_port) ->
@_onError = null
connect: (req, browserSocket, head, options = {}) ->
## don't buffer writes - thanks a lot, Nagle
## https://github.com/cypress-io/cypress/issues/3192
browserSocket.setNoDelay(true)
debug("Writing browserSocket connection headers %o", { url: req.url })
browserSocket.on "error", (err) =>
## TODO: shouldn't we destroy the upstream socket here?
## and also vise versa if the upstream socket throws?
## we may get this "for free" though because piping will
## automatically forward the TCP errors...?
## nothing to do except catch here, the browser has d/c'd
debug("received error on client browserSocket %o", {
err, url: req.url
})
browserSocket.write "HTTP/1.1 200 OK\r\n"
if req.headers["proxy-connection"] is "keep-alive"
browserSocket.write("Proxy-Connection: keep-alive\r\n")
browserSocket.write("Connection: keep-alive\r\n")
browserSocket.write("\r\n")
## if we somehow already have the head here
if _.get(head, "length")
## then immediately make up the connection
return @_onFirstHeadBytes(req, browserSocket, head, options)
## else once we get it make the connection later
browserSocket.once "data", (data) =>
@_onFirstHeadBytes(req, browserSocket, data, options)
_onFirstHeadBytes: (req, browserSocket, head, options) ->
browserSocket.pause()
if odc = options.onDirectConnection
## if onDirectConnection return true
## then dont proxy, just pass this through
if odc.call(@, req, browserSocket, head) is true
return @_makeDirectConnection(req, browserSocket, head)
else
debug("Not making direct connection %o", { url: req.url })
@_onServerConnectData(req, browserSocket, head)
_onUpgrade: (fn, req, browserSocket, head) ->
if fn
fn.call(@, req, browserSocket, head)
_onRequest: (fn, req, res) ->
hostPort = parse.hostAndPort(req.url, req.headers, 443)
req.url = url.format({
protocol: "https:"
hostname: hostPort.host
port: hostPort.port
}) + req.url
if fn
return fn.call(@, req, res)
req.pipe(request(req.url))
.on "error", ->
res.statusCode = 500
res.end()
.pipe(res)
_makeDirectConnection: (req, browserSocket, head) ->
{ port, hostname } = url.parse("http://#{req.url}")
debug("Making connection to #{hostname}:#{port}")
@_makeConnection(browserSocket, head, port, hostname)
_makeConnection: (browserSocket, head, port, hostname) ->
onSocket = (err, upstreamSocket) =>
debug('received upstreamSocket callback for request %o', { port, hostname, err })
onError = (err) =>
browserSocket.destroy(err)
if @_onError
@_onError(err, browserSocket, head, port)
if err
return onError(err)
upstreamSocket.setNoDelay(true)
upstreamSocket.on "error", onError
browserSocket.pipe(upstreamSocket)
upstreamSocket.pipe(browserSocket)
upstreamSocket.write(head)
browserSocket.resume()
if upstreamProxy = getProxyForUrl("https://#{hostname}:#{port}")
# todo: as soon as all requests are intercepted, this can go away since this is just for pass-through
debug("making proxied connection %o", {
host: "#{hostname}:#{port}",
proxy: upstreamProxy,
})
return agent.httpsAgent.createUpstreamProxyConnection {
proxy: upstreamProxy
href: "https://#{hostname}:#{port}"
uri: {
port
hostname
}
shouldRetry: true
}, onSocket
return connect.createRetryingSocket({ port, host: hostname }, onSocket)
_onServerConnectData: (req, browserSocket, head) ->
firstBytes = head[0]
makeConnection = (port) =>
debug("Making intercepted connection to %s", port)
@_makeConnection(browserSocket, head, port)
if firstBytes not in SSL_RECORD_TYPES
## if this isn't an SSL request then go
## ahead and make the connection now
return makeConnection(@_port)
## else spin up the SNI server
{ hostname } = url.parse("http://#{req.url}")
if sslServer = sslServers[hostname]
return makeConnection(sslServer.port)
## only be creating one SSL server per hostname at once
if not sem = sslSemaphores[hostname]
sem = sslSemaphores[hostname] = semaphore(1)
sem.take =>
leave = ->
process.nextTick ->
sem.leave()
if sslServer = sslServers[hostname]
leave()
return makeConnection(sslServer.port)
@_getPortFor(hostname)
.then (port) ->
sslServers[hostname] = { port: port }
leave()
makeConnection(port)
_normalizeKeyAndCert: (certPem, privateKeyPem) ->
return {
key: privateKeyPem
cert: certPem
}
_getCertificatePathsFor: (hostname) ->
@_ca.getCertificateKeysForHostname(hostname)
.spread(@_normalizeKeyAndCert)
_generateMissingCertificates: (hostname) ->
@_ca.generateServerCertificateKeys(hostname)
.spread(@_normalizeKeyAndCert)
_getPortFor: (hostname) ->
@_getCertificatePathsFor(hostname)
.catch (err) =>
@_generateMissingCertificates(hostname)
.then (data = {}) =>
@_sniServer.addContext(hostname, data)
return @_sniPort
listen: (options = {}) ->
new Promise (resolve) =>
@_onError = options.onError
@_sniServer = https.createServer({})
allowDestroy(@_sniServer)
@_sniServer.on "upgrade", @_onUpgrade.bind(@, options.onUpgrade)
@_sniServer.on "request", @_onRequest.bind(@, options.onRequest)
@_sniServer.listen 0, '127.0.0.1', =>
## store the port of our current sniServer
@_sniPort = @_sniServer.address().port
debug("Created SNI HTTPS Proxy on port %s", @_sniPort)
resolve()
close: ->
close = =>
new Promise (resolve) =>
@_sniServer.destroy(resolve)
close()
.finally ->
sslServers = {}
module.exports = {
reset: ->
sslServers = {}
create: (ca, port, options = {}) ->
srv = new Server(ca, port)
srv
.listen(options)
.return(srv)
}