Pace provider traffic and identify genuine foreground bank requests

This commit is contained in:
Lars Nolden
2026-09-10 17:47:44 +02:00
parent ba3ea6ae5a
commit 4324660888
13 changed files with 817 additions and 32 deletions
+130 -1
View File
@@ -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 {