From 4e15ede60fe122e5dfaf18104498b8f697007e52 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 29 May 2019 17:41:59 -0400 Subject: [PATCH] garbage collector for limits, add readme --- README.md | 48 ++++++++++++++++++++++++++++++++++++- config.go | 5 +++- config.json | 2 +- gc.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 58 ++++++++++++++++++++++++--------------------- test/web.py | 1 + use_case.dia | Bin 0 -> 2080 bytes use_case.png | Bin 0 -> 18201 bytes 8 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 gc.go create mode 100644 use_case.dia create mode 100644 use_case.png diff --git a/README.md b/README.md index 62b7e0a..7e21e7f 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ -wip \ No newline at end of file +# Architeuthis ? + +[![CodeFactor](https://www.codefactor.io/repository/github/simon987/architeuthis/badge)](https://www.codefactor.io/repository/github/simon987/architeuthis) + +*NOTE: this is very WIP* + +HTTP(S) proxy with integrated load-balancing, rate-limiting +and error handling. Built for automated web scraping. + +* Strictly obeys configured rate-limiting for each IP & Host +* Seamless exponential backoff retries on timeout or error HTTP codes +* Requires no additional configuration for integration into existing programs + +### Typical use case +![user_case](use_case.png) + +### Sample configuration + +```json +{ + "addr": "localhost:5050", + "proxies": [ + { + "name": "squid_P0", + "url": "http://p0.exemple.com:8080" + }, + { + "name": "privoxy_P1", + "url": "http://p1.exemple.com:8080" + } + ], + "hosts": { + "*": { + "every": "750ms", + "burst": 5, + "headers": {} + }, + "reddit.com": { + "every": "2s", + "burst": 1, + "headers": {"User-Agent": "mybot_v0.1"} + }, + ... + } +} +``` + diff --git a/config.go b/config.go index f7da327..c7aa662 100644 --- a/config.go +++ b/config.go @@ -42,7 +42,10 @@ func applyConfig(proxy *Proxy) { for host, conf := range config.Hosts { duration, err := time.ParseDuration(conf.Every) handleErr(err) - proxy.Limiters.Store(host, rate.NewLimiter(rate.Every(duration), conf.Burst)) + proxy.Limiters[host] = &ExpiringLimiter{ + rate.NewLimiter(rate.Every(duration), conf.Burst), + time.Now(), + } } } diff --git a/config.json b/config.json index 1e7ec8b..f115997 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,7 @@ }, { "name": "p1", - "url": "http://localhost:3128" + "url": "" } ], "hosts": { diff --git a/gc.go b/gc.go new file mode 100644 index 0000000..f274a83 --- /dev/null +++ b/gc.go @@ -0,0 +1,65 @@ +package main + +import ( + "github.com/robfig/cron" + "github.com/sirupsen/logrus" + "time" +) + +func (b *Balancer) setupGarbageCollector() { + + const gcInterval = time.Minute * 5 + + gcCron := cron.New() + gcSchedule := cron.Every(gcInterval) + gcCron.Schedule(gcSchedule, cron.FuncJob(b.cleanAllExpiredLimits)) + + go gcCron.Run() + + logrus.WithFields(logrus.Fields{ + "every": gcInterval, + }).Info("Started task cleanup cron") +} + +func (b *Balancer) cleanAllExpiredLimits() { + before := 0 + after := 0 + for _, p := range b.proxies { + before += len(p.Limiters) + cleanExpiredLimits(p) + after += len(p.Limiters) + } + + logrus.WithFields(logrus.Fields{ + "removed": before - after, + }).Info("Did limiters cleanup") +} + +func cleanExpiredLimits(proxy *Proxy) { + + const ttl = time.Second + + limits := make(map[string]*ExpiringLimiter, 0) + now := time.Now() + + for host, limiter := range proxy.Limiters { + if now.Sub(limiter.LastRead) > ttl && shouldPruneLimiter(host) { + logrus.WithFields(logrus.Fields{ + "proxy": proxy.Name, + "limiter": host, + "last_read": now.Sub(limiter.LastRead), + }).Trace("Pruning limiter") + } else { + limits[host] = limiter + } + } + + proxy.Limiters = limits +} + +func shouldPruneLimiter(host string) bool { + + // Don't remove hosts that are coming from the config + _, ok := config.Hosts[host] + return !ok +} diff --git a/main.go b/main.go index aa230c8..1a28fc2 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,6 @@ import ( "net/url" "sort" "strings" - "sync" "time" ) @@ -19,10 +18,15 @@ type Balancer struct { proxies []*Proxy } +type ExpiringLimiter struct { + Limiter *rate.Limiter + LastRead time.Time +} + type Proxy struct { Name string Url *url.URL - Limiters sync.Map + Limiters map[string]*ExpiringLimiter HttpClient *http.Client Connections int } @@ -41,33 +45,33 @@ func (a ByConnectionCount) Less(i, j int) bool { return a[i].Connections < a[j].Connections } -func LogRequestMiddleware(h goproxy.FuncReqHandler) goproxy.ReqHandler { - return goproxy.FuncReqHandler(func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { - - logrus.WithFields(logrus.Fields{ - "host": r.Host, - }).Trace(strings.ToUpper(r.URL.Scheme) + " " + r.Method) - - return h(r, ctx) - }) -} - -//TODO: expiration ? func (p *Proxy) getLimiter(host string) *rate.Limiter { - limiter, ok := p.Limiters.Load(host) + expLimit, ok := p.Limiters[host] if !ok { - - every, _ := time.ParseDuration("1ms") - limiter = rate.NewLimiter(rate.Every(every), 1) - p.Limiters.Store(host, limiter) - - logrus.WithFields(logrus.Fields{ - "host": host, - }).Trace("New limiter") + newExpiringLimiter := p.makeNewLimiter(host) + return newExpiringLimiter.Limiter } - return limiter.(*rate.Limiter) + expLimit.LastRead = time.Now() + return expLimit.Limiter +} + +func (p *Proxy) makeNewLimiter(host string) *ExpiringLimiter { + every := time.Millisecond //todo load default from conf + + newExpiringLimiter := &ExpiringLimiter{ + LastRead: time.Now(), + Limiter: rate.NewLimiter(rate.Every(every), 1), + } + + p.Limiters[host] = newExpiringLimiter + + logrus.WithFields(logrus.Fields{ + "host": host, + }).Trace("New limiter") + + return newExpiringLimiter } func simplifyHost(host string) string { @@ -92,7 +96,7 @@ func New() *Balancer { balancer.server.OnRequest().HandleConnect(goproxy.AlwaysMitm) - balancer.server.OnRequest().Do(LogRequestMiddleware( + balancer.server.OnRequest().DoFunc( func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { p := balancer.chooseProxy() @@ -110,7 +114,7 @@ func New() *Balancer { } return nil, resp - })) + }) return balancer } @@ -234,6 +238,7 @@ func NewProxy(name, stringUrl string) (*Proxy, error) { Name: name, Url: parsedUrl, HttpClient: httpClient, + Limiters: make(map[string]*ExpiringLimiter), }, nil } @@ -256,5 +261,6 @@ func main() { }).Info("Proxy") } + balancer.setupGarbageCollector() balancer.Run() } diff --git a/test/web.py b/test/web.py index cebbdd7..d63c50c 100644 --- a/test/web.py +++ b/test/web.py @@ -17,6 +17,7 @@ def e500(): @app.route("/404") def e404(): + time.sleep(0.5) return Response(status=404) diff --git a/use_case.dia b/use_case.dia new file mode 100644 index 0000000000000000000000000000000000000000..2575c2bc60fc3da1f627ecc1744227d4391af175 GIT binary patch literal 2080 zcmV+*2;cV~iwFP!000021MOT}Z{s!^eV<=pcwRFy@0Yqs(GF0|LxC=Ky4XiUwiugR zS@KG968B+$`;wG0v1Q2@$?_zr49oyhC?1mLch2D_(m($6vocgmr=SjSdf?-~&C^w74 zI2k`7cRUmTykAFolaJ9k|n;*_93Tn94bG`QL|*Nn&YH1KQvS9P`6t2;$CN2e5C#N zewd!pf&BKjXgfD@=ZOvu2kuUZnpz%pkS!)p9Sv7r`=+DXA9Sjxp{i=Vj2X?xTZp6tmyYXL=zXl0Z_7i9|#&#@k>ml%6lLdNAlo zkbfYN078bEflcEiP|2~b0T1q?!G>@;GGEqYmqDuBD<)GOG=oSN@#CbzA|KcPb5QbW zH%a_JuhUZo?)d|o;jR22*!3EFNVHkanR?2GM+Br)rf%e&j?|aWy8z2+6wDW5fz1 zESc3~iHW(U?z&?=Zr{n%^q4ZbEn2-C_j26J@s=F_Bhe4>)8^VsadgWrjU01hUCvu{~2RuE`FF^Bf4%F%upa zj98&$2sT7ss?0w|CxM=2T`K-1M(`BDH^xG8jEpK2MK;$Px4>s@8HuCjJkcIGPcj$Or7 zf(~3|-OdcxqEJV}U<2tdFoB#3nACGG0aph<`&AOIRa`JS>zm{a9X`|Qzs%O3rl%YM2>l z!X@2PvFE=G7AC$5=Fei6kR81I)p!B6H&H4chQ-66{^4i7DK+U#4*1Qu%L)>Ztx3S; za@`9kEE(^T1Pl_P+#*4Q)2`=5N=0tYs?=2EyZjp^Jq+`2B zlVO)zlazGr?`!*6xp|aOnD3%ufcAm@z5<=?#ws}{&S~pqe17eIlJD-r&TsbJefU4( K4sgHMR{#LBfAlW^ literal 0 HcmV?d00001 diff --git a/use_case.png b/use_case.png new file mode 100644 index 0000000000000000000000000000000000000000..90aa0f41bbcee741744a3f3a7c3ee05ec43eb3e4 GIT binary patch literal 18201 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�lU13wNf>4E2Rs%a^QyI9Vz? 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 literal 0 HcmV?d00001