Pace provider traffic and identify genuine foreground bank requests
This commit is contained in:
@@ -17,8 +17,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -62,6 +64,8 @@ type EnableBanking struct {
|
||||
key *rsa.PrivateKey
|
||||
redirectURL string
|
||||
requests ratelimit.Controller
|
||||
// Accessed only while requests is acquired. Keys exclude pagination/query data.
|
||||
backgroundQuotas map[string]time.Time
|
||||
}
|
||||
|
||||
var _ Provider = (*EnableBanking)(nil)
|
||||
@@ -116,7 +120,7 @@ func NewEnableBanking(appID string, keyPEM []byte, redirectURL string) (*EnableB
|
||||
if err = key.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid Enable Banking RSA private key")
|
||||
}
|
||||
return &EnableBanking{HTTPClient: &http.Client{Timeout: 30 * time.Second}, BaseURL: "https://api.enablebanking.com", appID: appID, key: key, redirectURL: redirectURL}, nil
|
||||
return &EnableBanking{HTTPClient: &http.Client{Timeout: 30 * time.Second}, BaseURL: "https://api.enablebanking.com", appID: appID, key: key, redirectURL: redirectURL, requests: ratelimit.Controller{MinimumInterval: time.Second, InitialBackoff: 30 * time.Second}}, nil
|
||||
}
|
||||
func (p *EnableBanking) jwt() (string, error) {
|
||||
if p.key == nil || p.appID == "" {
|
||||
@@ -133,11 +137,114 @@ func (p *EnableBanking) jwt() (string, error) {
|
||||
}
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// PSU contains only the documented, nonsecret browser metadata for a person
|
||||
// actively requesting account data. It must never be stored on a shared client.
|
||||
type PSU struct {
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
Accept string
|
||||
AcceptCharset string
|
||||
AcceptEncoding string
|
||||
AcceptLanguage string
|
||||
}
|
||||
|
||||
type psuContextKey struct{}
|
||||
|
||||
// WithPSU marks this request context as user initiated. Call only at a manual
|
||||
// HTTP boundary, never for scheduled retrieval. Unknown metadata stays empty.
|
||||
func WithPSU(ctx context.Context, psu PSU) context.Context {
|
||||
return context.WithValue(ctx, psuContextKey{}, psu)
|
||||
}
|
||||
|
||||
func (psu PSU) setHeaders(header http.Header) {
|
||||
if ip := net.ParseIP(psu.IPAddress); ip != nil {
|
||||
header.Set("Psu-Ip-Address", ip.String())
|
||||
}
|
||||
for _, field := range []struct{ name, value string }{
|
||||
{"Psu-User-Agent", psu.UserAgent},
|
||||
{"Psu-Accept", psu.Accept},
|
||||
{"Psu-Accept-Charset", psu.AcceptCharset},
|
||||
{"Psu-Accept-Encoding", psu.AcceptEncoding},
|
||||
{"Psu-Accept-Language", psu.AcceptLanguage},
|
||||
} {
|
||||
if field.value != "" {
|
||||
header.Set(field.name, field.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// accountDataPath admits only the internally generated account GET endpoints.
|
||||
// The escaped account segment is retained; dates and continuation keys are not.
|
||||
func accountDataPath(method, path string) string {
|
||||
if method != http.MethodGet {
|
||||
return ""
|
||||
}
|
||||
path, _, _ = strings.Cut(path, "?")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 4 || parts[0] != "" || parts[1] != "accounts" || (parts[3] != "balances" && parts[3] != "transactions") {
|
||||
return ""
|
||||
}
|
||||
account, err := url.PathUnescape(parts[2])
|
||||
if err != nil || account == "" || account == "." || account == ".." || strings.ContainsAny(account, "/\\") || url.PathEscape(account) != parts[2] {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// BackgroundQuotaError identifies the bank's background-only account quota.
|
||||
// It preserves RateLimitError identity and exposes no provider response text.
|
||||
type BackgroundQuotaError struct {
|
||||
*ratelimit.RateLimitError
|
||||
}
|
||||
|
||||
func (e *BackgroundQuotaError) Error() string {
|
||||
return "background bank retrieval quota (normally six hours): " + e.RateLimitError.Error()
|
||||
}
|
||||
|
||||
func (e *BackgroundQuotaError) Unwrap() error {
|
||||
return e.RateLimitError
|
||||
}
|
||||
|
||||
// backgroundRetryAt follows the bank's six-hour guidance, never shortening a
|
||||
// longer provider deadline. Zero retains the limiter's unbounded-delay meaning.
|
||||
func backgroundRetryAt(header string, now time.Time) time.Time {
|
||||
next := now.Add(6 * time.Hour)
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return next
|
||||
}
|
||||
if strings.Trim(header, "0123456789") == "" {
|
||||
seconds, err := strconv.ParseUint(header, 10, 64)
|
||||
if err != nil || seconds > uint64((1<<63-1)/int64(time.Second)) {
|
||||
return time.Time{}
|
||||
}
|
||||
if delay := time.Duration(seconds) * time.Second; delay > 6*time.Hour {
|
||||
return now.Add(delay)
|
||||
}
|
||||
} else if date, err := http.ParseTime(header); err == nil && date.After(next) {
|
||||
return date
|
||||
}
|
||||
return next
|
||||
}
|
||||
func (p *EnableBanking) request(ctx context.Context, method, path string, input, output any) error {
|
||||
if err := p.requests.Acquire(ctx); err != nil {
|
||||
return fmt.Errorf("Enable Banking: %w", err)
|
||||
}
|
||||
defer p.requests.Release()
|
||||
scope := accountDataPath(method, path)
|
||||
psu, foreground := ctx.Value(psuContextKey{}).(PSU)
|
||||
now := time.Now()
|
||||
for key, next := range p.backgroundQuotas {
|
||||
if !next.IsZero() && !next.After(now) {
|
||||
delete(p.backgroundQuotas, key)
|
||||
}
|
||||
}
|
||||
if scope != "" && !foreground {
|
||||
if next, limited := p.backgroundQuotas[scope]; limited {
|
||||
return fmt.Errorf("Enable Banking: %w", &BackgroundQuotaError{ratelimit.NewError(next)})
|
||||
}
|
||||
}
|
||||
token, err := p.jwt()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -160,6 +267,9 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if scope != "" && foreground {
|
||||
psu.setHeaders(req.Header)
|
||||
}
|
||||
if input != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
@@ -184,6 +294,25 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
|
||||
}
|
||||
return nil, errors.New("connection failed")
|
||||
}
|
||||
if response.StatusCode == http.StatusTooManyRequests && scope != "" && !foreground {
|
||||
// Inspect only a small structured error envelope, never exposing its
|
||||
// detail/message or retaining response bytes. Other 429s remain the
|
||||
// common controller's responsibility (including body closure).
|
||||
const errorLimit = 16 << 10
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, errorLimit+1))
|
||||
var envelope struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if readErr == nil && len(body) <= errorLimit && json.Unmarshal(body, &envelope) == nil && envelope.Error == "ASPSP_RATE_LIMIT_EXCEEDED" {
|
||||
next := backgroundRetryAt(response.Header.Get("Retry-After"), time.Now())
|
||||
if p.backgroundQuotas == nil {
|
||||
p.backgroundQuotas = make(map[string]time.Time)
|
||||
}
|
||||
p.backgroundQuotas[scope] = next
|
||||
response.Body.Close()
|
||||
return nil, &BackgroundQuotaError{ratelimit.NewError(next)}
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}, method == http.MethodGet)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user